Adds packages/vault-cli (the `vault` CLI: search/show/tree, clobber-safe create/move/rename across both /vault and /data + manifest regen, sync-data, report, analyze) and wires it onto $PATH via modules/hosts/core/vault.nix for server + vault-client hosts. Scripts self-locate siblings via realpath so they run from the Nix store. Includes the deterministic $0 report generator, the topology-manifest generator, and the shared Librarian prompt.
286 lines
10 KiB
Python
286 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
vault_lib.py — shared library for Ryan's R/J/F/I Obsidian vault Librarian tooling.
|
|
|
|
Single source of truth for:
|
|
- tree walking + ID parsing (SYS.AC.ID)
|
|
- wikilink extraction / broken-link detection
|
|
- the topology manifest (machine-readable JSON block in a 00-area note)
|
|
- cross-tree (/vault <-> /data) topology diff
|
|
|
|
Used by:
|
|
- vault (the CLI: search / rename / create / move IDs across both trees)
|
|
- vault-librarian.py (the nightly deterministic report generator)
|
|
|
|
Conventions (see ryan-vault-rjfi-migration + obsidian-vault-navigation skills):
|
|
VAULT = /vault -> the JDex (markdown map). Obsidian-synced. Authoritative for structure.
|
|
DATA = /data -> blob/artifact territory. SMB-served. Mirrors the SYS.AC skeleton,
|
|
MINUS `0 System area`, PLUS `_unsorted/` (allowed, no vault counterpart).
|
|
Every level is prefixed: `R Ryan / R.1 Life admin / R.11 Identity... / R.11.84 ... / R.11.84 ....md`
|
|
Folder-note = a .md whose name == its parent folder name (Folder Notes plugin needs this).
|
|
"""
|
|
import os
|
|
import re
|
|
import json
|
|
|
|
VAULT = "/vault"
|
|
DATA = "/data"
|
|
|
|
# Directories never treated as vault structure.
|
|
SKIP_DIRS = {".git", ".obsidian"}
|
|
# Top-level vault dir that is meta, not a system (unprefixed).
|
|
SYSTEM_META = "0 System area"
|
|
# /data dirs allowed to exist without a /vault counterpart.
|
|
DATA_EXEMPT = {"_unsorted"}
|
|
|
|
# --- regexes on folder/leaf basenames ---
|
|
SYS_RE = re.compile(r'^([RJFI]) ') # "R Ryan"
|
|
AREA_RE = re.compile(r'^([RJFI])\.(\d) ') # "R.1 Life admin"
|
|
CAT_RE = re.compile(r'^([RJFI])\.(\d\d) ') # "R.11 Identity & vital records"
|
|
ID_RE = re.compile(r'^([RJFI])\.(\d\d)\.(\d\d) ') # "R.11.84 My journal & notes by day"
|
|
# extend-notation leaf that never got a real ID, e.g. "25+ Smoker Brisket.md"
|
|
EXTEND_RE = re.compile(r'^(\d\d)\+ ')
|
|
|
|
WIKILINK_RE = re.compile(r'!?\[\[([^\]]+)\]\]')
|
|
|
|
|
|
def parse_id(basename):
|
|
"""Return (sys, area_digit, cat, id) or None. basename is a folder/leaf name."""
|
|
m = ID_RE.match(basename)
|
|
if not m:
|
|
return None
|
|
return {"sys": m.group(1), "area": m.group(2), "cat": m.group(2) + m.group(3)[:0], "raw": basename}
|
|
|
|
|
|
def id_code(basename):
|
|
"""Return the 'R.11.84' code for a leaf name, or None."""
|
|
m = ID_RE.match(basename)
|
|
if not m:
|
|
return None
|
|
return f"{m.group(1)}.{m.group(2)}.{m.group(3)}"
|
|
|
|
|
|
def cat_code(basename):
|
|
m = CAT_RE.match(basename)
|
|
return f"{m.group(1)}.{m.group(2)}" if m else None
|
|
|
|
|
|
def area_code(basename):
|
|
m = AREA_RE.match(basename)
|
|
return f"{m.group(1)}.{m.group(2)}" if m else None
|
|
|
|
|
|
def walk_structure(root, include_meta=False):
|
|
"""
|
|
Walk a vault-shaped tree. Return dict:
|
|
systems: {name}
|
|
areas: {code -> relpath}
|
|
cats: {code -> relpath}
|
|
ids: {code -> relpath} (folder relpaths)
|
|
Skips .git/.obsidian. If include_meta False, skips the `0 System area` subtree.
|
|
"""
|
|
systems, areas, cats, ids = {}, {}, {}, {}
|
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
rel = os.path.relpath(dirpath, root)
|
|
top = rel.split(os.sep)[0]
|
|
if not include_meta and top == SYSTEM_META:
|
|
dirnames[:] = []
|
|
continue
|
|
base = os.path.basename(dirpath)
|
|
if ID_RE.match(base):
|
|
ids[id_code(base)] = rel
|
|
elif CAT_RE.match(base):
|
|
cats[cat_code(base)] = rel
|
|
elif AREA_RE.match(base):
|
|
areas[area_code(base)] = rel
|
|
elif SYS_RE.match(base) and os.sep not in rel:
|
|
systems[base[0]] = rel
|
|
return {"systems": systems, "areas": areas, "cats": cats, "ids": ids}
|
|
|
|
|
|
ARCHIVE_SUBPATH = os.path.join(SYSTEM_META, "09 Archive of pre-RJFI tree")
|
|
|
|
|
|
def is_archived(relpath):
|
|
"""True if a vault-relative path is inside the frozen pre-RJFI archive."""
|
|
return relpath.startswith(ARCHIVE_SUBPATH)
|
|
|
|
|
|
def all_notes(root, include_meta=True, include_archive=True):
|
|
"""Yield absolute paths of every .md note (skips .git/.obsidian).
|
|
include_archive=False skips the frozen `09 Archive of pre-RJFI tree` subtree."""
|
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
rel = os.path.relpath(dirpath, root)
|
|
if not include_archive and is_archived(rel):
|
|
dirnames[:] = []
|
|
continue
|
|
for f in filenames:
|
|
if f.endswith(".md"):
|
|
yield os.path.join(dirpath, f)
|
|
|
|
|
|
def is_folder_note(path):
|
|
"""A note whose filename stem == its parent directory name."""
|
|
stem = os.path.splitext(os.path.basename(path))[0]
|
|
parent = os.path.basename(os.path.dirname(path))
|
|
return stem == parent
|
|
|
|
|
|
def build_title_index(root):
|
|
"""
|
|
Map every resolvable wikilink target -> abs path.
|
|
Obsidian resolves by basename (title) AND by full relative path.
|
|
"""
|
|
idx = {}
|
|
for p in all_notes(root):
|
|
stem = os.path.splitext(os.path.basename(p))[0]
|
|
rel = os.path.relpath(p, root)
|
|
relnoext = os.path.splitext(rel)[0]
|
|
idx.setdefault(stem, p)
|
|
idx[relnoext] = p # full-path form always wins if explicit
|
|
return idx
|
|
|
|
|
|
def extract_links(text):
|
|
"""Return list of (raw_target, cleaned_target). Strips #heading and |alias."""
|
|
out = []
|
|
for m in WIKILINK_RE.finditer(text):
|
|
raw = m.group(1)
|
|
cleaned = raw.split("|")[0].split("#")[0].strip()
|
|
out.append((raw, cleaned))
|
|
return out
|
|
|
|
|
|
# link targets we deliberately skip (blobs, code, doc examples)
|
|
_BLOB_EXT = re.compile(r'\.(jpg|jpeg|png|gif|webp|pdf|svg|opus|mp3|mp4|mov|3gp|zip|xlsx?|docx?|pptx?|csv|heic)$', re.I)
|
|
_NOISE = ("this.file", "{{", "...", '"')
|
|
|
|
|
|
def link_is_checkable(target):
|
|
if not target:
|
|
return False
|
|
if _BLOB_EXT.search(target):
|
|
return False
|
|
if any(n in target for n in _NOISE):
|
|
return False
|
|
return True
|
|
|
|
|
|
def find_broken_links(root, include_archive=False):
|
|
"""
|
|
Return list of dicts: {file, target, guess}
|
|
guess = an existing note title that likely IS the intended target, else None.
|
|
Guess strategies (in order): case-insensitive title match; then match on the
|
|
NN.NN ID-number tail across systems (catches migration carry-forwards like
|
|
[[11.30 Mental health]] -> R.12.30, and stale un-prefixed self-links [[13.05 ..]] -> F.13.05).
|
|
Skips the frozen 09-archive subtree by default.
|
|
"""
|
|
idx = build_title_index(root)
|
|
low = {}
|
|
for k in idx:
|
|
low.setdefault(k.lower(), k)
|
|
# index existing notes by their NN.NN id-number tail -> [stems]
|
|
idnum_re = re.compile(r'(\d\d\.\d\d)')
|
|
by_idnum = {}
|
|
for k in idx:
|
|
m = re.match(r'^[RJFI]?\.?(\d\d\.\d\d)', k)
|
|
if m:
|
|
by_idnum.setdefault(m.group(1), []).append(k)
|
|
broken = []
|
|
for p in all_notes(root, include_archive=include_archive):
|
|
try:
|
|
text = open(p, encoding="utf-8").read()
|
|
except Exception:
|
|
continue
|
|
for raw, cleaned in extract_links(text):
|
|
if not link_is_checkable(cleaned):
|
|
continue
|
|
if cleaned in idx:
|
|
continue
|
|
base = cleaned.split("/")[-1]
|
|
if base in idx:
|
|
continue
|
|
guess = low.get(cleaned.lower()) or low.get(base.lower())
|
|
if not guess:
|
|
m = re.match(r'^[RJFI]?\.?(\d\d\.\d\d)', cleaned)
|
|
if m and m.group(1) in by_idnum:
|
|
cands = by_idnum[m.group(1)]
|
|
if len(cands) == 1:
|
|
guess = cands[0]
|
|
broken.append({
|
|
"file": os.path.relpath(p, root),
|
|
"target": cleaned,
|
|
"guess": guess,
|
|
})
|
|
return broken
|
|
|
|
|
|
def topology_diff(vault_root=VAULT, data_root=DATA):
|
|
"""
|
|
Compare the SYS.AC.ID skeleton of /vault (authoritative) vs /data.
|
|
/data skips `0 System area` and is allowed `_unsorted`.
|
|
Returns dict of missing/orphan at each level.
|
|
"""
|
|
v = walk_structure(vault_root, include_meta=False)
|
|
d = walk_structure(data_root, include_meta=False)
|
|
out = {}
|
|
for level in ("systems", "areas", "cats", "ids"):
|
|
vk = set(v[level].keys())
|
|
dk = set(d[level].keys())
|
|
out[level] = {
|
|
"missing_in_data": sorted(vk - dk), # vault has it, /data lacks a home
|
|
"orphan_in_data": sorted(dk - vk), # /data has it, no vault note
|
|
}
|
|
return out
|
|
|
|
|
|
# ---- topology manifest (the 00-area source-of-truth note) ----
|
|
MANIFEST_REL = "0 System area/00 System category/00.05 Vault topology (machine-readable)/00.05 Vault topology (machine-readable).md"
|
|
MANIFEST_BEGIN = "<!-- VAULT-TOPOLOGY-JSON:BEGIN -->"
|
|
MANIFEST_END = "<!-- VAULT-TOPOLOGY-JSON:END -->"
|
|
|
|
|
|
def build_manifest(vault_root=VAULT):
|
|
"""Build the topology manifest dict from the live vault (authoritative)."""
|
|
s = walk_structure(vault_root, include_meta=True)
|
|
return {
|
|
"systems": {k: os.path.basename(v) for k, v in sorted(s["systems"].items())},
|
|
"areas": {k: os.path.basename(v) for k, v in sorted(s["areas"].items())},
|
|
"categories": {k: os.path.basename(v) for k, v in sorted(s["cats"].items())},
|
|
"ids": {k: os.path.basename(v) for k, v in sorted(s["ids"].items())},
|
|
"counts": {
|
|
"systems": len(s["systems"]),
|
|
"areas": len(s["areas"]),
|
|
"categories": len(s["cats"]),
|
|
"ids": len(s["ids"]),
|
|
},
|
|
}
|
|
|
|
|
|
def read_manifest(vault_root=VAULT):
|
|
"""Parse the JSON block out of the manifest note. Return dict or None."""
|
|
p = os.path.join(vault_root, MANIFEST_REL)
|
|
if not os.path.exists(p):
|
|
return None
|
|
text = open(p, encoding="utf-8").read()
|
|
i = text.find(MANIFEST_BEGIN)
|
|
j = text.find(MANIFEST_END)
|
|
if i < 0 or j < 0:
|
|
return None
|
|
block = text[i + len(MANIFEST_BEGIN):j]
|
|
block = block.strip().strip("`").strip()
|
|
if block.startswith("json"):
|
|
block = block[4:].strip()
|
|
try:
|
|
return json.loads(block)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
s = walk_structure(VAULT, include_meta=True)
|
|
print(json.dumps({k: len(v) for k, v in s.items()}, indent=2))
|