#!/usr/bin/env python3 """Add a short-name alias (ID prefix stripped) to every ID-prefixed note's frontmatter, so Obsidian's alias-autocomplete can produce clean [[ID|Name]] links without the user typing the ID. Idempotent: skips notes that already have that alias or non-ID-prefixed notes. """ import os, re, sys VAULT = "/vault" # Matches "F.50.12 Jen", "R.31.10 Hindi", "F.22.11 Places We've Slept" etc. ID_TITLE_RE = re.compile(r'^([RJFI](?:\.\d+){1,3})\s+(.+)$') FRONTMATTER_RE = re.compile(r'^---\n(.*?)\n---\n', re.DOTALL) def split_frontmatter(text): m = FRONTMATTER_RE.match(text) if m: return m.group(1), text[m.end():] return None, text def add_alias(fm_text, alias): lines = fm_text.split("\n") if fm_text else [] # find aliases: line (list style) or aliases: [a, b] alias_line_idx = None for i, l in enumerate(lines): if re.match(r'^aliases\s*:', l): alias_line_idx = i break if alias_line_idx is None: lines.append(f"aliases:\n - {alias}") return "\n".join(lines) line = lines[alias_line_idx] # inline list form: aliases: [a, b] m = re.match(r'^aliases\s*:\s*\[(.*)\]\s*$', line) if m: existing = [x.strip().strip('"\'') for x in m.group(1).split(",") if x.strip()] if alias in existing: return None # already present existing.append(alias) lines[alias_line_idx] = "aliases: [" + ", ".join(existing) + "]" return "\n".join(lines) # block list form: aliases:\n - a\n - b if line.strip() == "aliases:" or re.match(r'^aliases\s*:\s*$', line): j = alias_line_idx + 1 existing = [] while j < len(lines) and re.match(r'^\s*-\s*', lines[j]): existing.append(lines[j].strip()[2:].strip().strip('"\'')) j += 1 if alias in existing: return None lines.insert(j, f" - {alias}") return "\n".join(lines) # single scalar alias: aliases: Foo scalar = line.split(":", 1)[1].strip().strip('"\'') if scalar == alias: return None lines[alias_line_idx] = "aliases:\n - " + scalar + f"\n - {alias}" return "\n".join(lines) def main(): execute = "--execute" in sys.argv changed = 0 skipped_no_id = 0 skipped_has_alias = 0 errors = [] for dp, _, fns in os.walk(VAULT): if ".git" in dp.split(os.sep): continue for fn in fns: if not fn.lower().endswith(".md"): continue base = fn[:-3] m = ID_TITLE_RE.match(base) if not m: skipped_no_id += 1 continue alias = m.group(2) p = os.path.join(dp, fn) try: with open(p, "r", encoding="utf-8", errors="ignore") as f: text = f.read() except Exception as e: errors.append((p, str(e))) continue fm, rest = split_frontmatter(text) new_fm = add_alias(fm or "", alias) if new_fm is None: skipped_has_alias += 1 continue new_text = f"---\n{new_fm}\n---\n\n{rest.lstrip(chr(10))}" if fm is None else f"---\n{new_fm}\n---\n{rest}" changed += 1 if execute: with open(p, "w", encoding="utf-8") as f: f.write(new_text) print(f"Notes with ID-prefixed title: {changed + skipped_has_alias}") print(f" Would add alias: {changed}") print(f" Already had alias: {skipped_has_alias}") print(f"Notes without ID prefix (skipped, not applicable): {skipped_no_id}") if errors: print(f"Errors: {len(errors)}") for p, e in errors[:10]: print(f" {p}: {e}") if not execute: print("\n(dry run -- pass --execute to write changes)") if __name__ == "__main__": main()