diff --git a/add_short_aliases.py b/add_short_aliases.py new file mode 100644 index 0000000..9230199 --- /dev/null +++ b/add_short_aliases.py @@ -0,0 +1,108 @@ +#!/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() diff --git a/find_unresolved_links.py b/find_unresolved_links.py new file mode 100644 index 0000000..2ee4e17 --- /dev/null +++ b/find_unresolved_links.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Scan /vault for [[wikilinks]] and report which targets don't resolve +to an actual note. Obsidian resolves links by basename match anywhere +in the vault (unless the link includes a path), so we build an index +of all note basenames (and their full relative paths) first. +""" +import os, re, json +from collections import defaultdict + +VAULT = "/vault" +LINK_RE = re.compile(r'\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]') + +def all_notes(): + idx = defaultdict(list) # basename (no ext, lowercase) -> [full paths] + all_paths = [] + for dp, dirnames, fns in os.walk(VAULT): + if ".git" in dp.split(os.sep): + continue + for fn in fns: + if fn.lower().endswith(".md"): + p = os.path.join(dp, fn) + base = fn[:-3] + idx[base.lower()].append(p) + all_paths.append(p) + return idx, all_paths + +def resolve(link_target, index, current_dir): + # Obsidian: link may be a bare name, or a relative/vault path (with /) + target = link_target.strip() + if "/" in target: + # path-style link; try resolving relative to vault root or current dir + candidates = [ + os.path.join(VAULT, target + ".md"), + os.path.join(VAULT, target), + os.path.join(current_dir, target + ".md"), + os.path.join(current_dir, target), + ] + for c in candidates: + if os.path.isfile(c): + return True + # also try basename match as fallback + base = os.path.basename(target) + return base.lower() in index + else: + return target.lower() in index + +def main(): + index, all_paths = all_notes() + unresolved = defaultdict(list) # target -> [source files] + total_links = 0 + for p in all_paths: + try: + with open(p, "r", encoding="utf-8", errors="ignore") as f: + text = f.read() + except Exception: + continue + for m in LINK_RE.finditer(text): + target = m.group(1) + total_links += 1 + if not resolve(target, index, os.path.dirname(p)): + unresolved[target].append(os.path.relpath(p, VAULT)) + + print(f"Total wikilinks scanned: {total_links}") + print(f"Unique unresolved targets: {len(unresolved)}") + print(f"Total unresolved link occurrences: {sum(len(v) for v in unresolved.values())}") + + # dump full detail to json for further processing + out = {t: srcs for t, srcs in unresolved.items()} + with open("/tmp/unresolved_links.json", "w") as f: + json.dump(out, f, indent=2) + print("Full detail written to /tmp/unresolved_links.json") + + # sorted by occurrence count desc + by_count = sorted(unresolved.items(), key=lambda kv: -len(kv[1])) + print("\n=== TOP 40 unresolved targets by occurrence count ===") + for target, srcs in by_count[:40]: + print(f" {len(srcs):4d}x [[{target}]]") + +if __name__ == "__main__": + main() diff --git a/fix_wikilinks.py b/fix_wikilinks.py new file mode 100644 index 0000000..1aa61f7 --- /dev/null +++ b/fix_wikilinks.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Apply the two link-fix passes across /vault: +1. fix_map: [[OldTarget]] -> [[NewNote|OldTarget]] (preserves display text) + Also handles piped/heading variants: [[OldTarget|Display]] -> [[NewNote|Display]] + [[OldTarget#Heading]] -> [[NewNote#Heading]] +2. unlink_targets: [[Target]] -> Target (plain text, drop brackets) + Also handles piped: [[Target|Display]] -> Display +""" +import os, re, json, sys + +VAULT = "/vault" + +with open("/tmp/link_fix_map.json") as f: + FIX_MAP = json.load(f) +with open("/tmp/unlink_targets.json") as f: + UNLINK = set(json.load(f)) + +# Full link regex capturing target, optional #heading, optional |display +LINK_RE = re.compile(r'\[\[([^\]|#]+)(#[^\]|]*)?(\|[^\]]*)?\]\]') + +def process_text(text): + changed = False + def repl(m): + nonlocal changed + target = m.group(1) + heading = m.group(2) or "" + pipe_display = m.group(3) # includes leading | + target_stripped = target.strip() + + if target_stripped in FIX_MAP: + new_target = FIX_MAP[target_stripped] + changed = True + if pipe_display: + # keep existing custom display text + return f"[[{new_target}{heading}{pipe_display}]]" + else: + # preserve original text as display so prose doesn't change visually + return f"[[{new_target}{heading}|{target_stripped}]]" + + if target_stripped in UNLINK: + changed = True + if pipe_display: + display = pipe_display[1:] # strip leading | + return display + else: + return target_stripped + + return m.group(0) # unchanged + + new_text = LINK_RE.sub(repl, text) + return new_text, changed + +def main(): + execute = "--execute" in sys.argv + files_changed = 0 + total_fix_applied = 0 + total_unlink_applied = 0 + + 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 + p = os.path.join(dp, fn) + try: + with open(p, "r", encoding="utf-8", errors="ignore") as f: + text = f.read() + except Exception: + continue + new_text, changed = process_text(text) + if changed: + files_changed += 1 + if execute: + with open(p, "w", encoding="utf-8") as f: + f.write(new_text) + + print(f"Files changed: {files_changed}") + if not execute: + print("(dry run -- pass --execute to write)") + +if __name__ == "__main__": + main() diff --git a/sort_unsorted.py b/sort_unsorted.py new file mode 100644 index 0000000..9080900 --- /dev/null +++ b/sort_unsorted.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Sort /data/_unsorted (old pre-RJFI JD tree) into /data/R,J,F,I storage tree. + +Dry-run by default; pass --execute to actually move files. +""" +import os, shutil, sys, csv + +UNSORTED = "/data/_unsorted" +DATA = "/data" + +# old_prefix -> new_prefix (folder-level rename/move rules) +# Order matters: longer/more specific prefixes first. +MAPPING = [ + # --- WhatsApp exports: wholesale move, old->new folder rename --- + ("1 Life admin/11 Me & other living things/11.54 Whatsapp exports", + "R Ryan/R.1 Life admin/R.11 Identity & vital records/R.11.54 Whatsapp exports"), + + # --- 12 Home, house, and transport -> split F.11 (home) / F.12 (vehicles) --- + ("1 Life admin/12 Home, house, and transport/12.12 Home insurance & claims", + "F Family/F.1 Home, household & finances/F.11 Home & property/F.11.12 Home insurance & claims"), + ("1 Life admin/12 Home, house, and transport/12.21 Electricity, gas, & water", + "F Family/F.1 Home, household & finances/F.11 Home & property/F.11.21 Electricity, gas, & water"), + ("1 Life admin/12 Home, house, and transport/12.23 All other utilities & services", + "F Family/F.1 Home, household & finances/F.11 Home & property/F.11.23 All other utilities & services"), + ("1 Life admin/12 Home, house, and transport/12.24 Renovations & improvements", + "F Family/F.1 Home, household & finances/F.11 Home & property/F.11.24 Renovations & improvements"), + ("1 Life admin/12 Home, house, and transport/12.25 House repairs, maintenance, & upkeep", + "F Family/F.1 Home, household & finances/F.11 Home & property/F.11.24 Renovations & improvements"), + ("1 Life admin/12 Home, house, and transport/12.61 Move out of 6 Dorothy", + "F Family/F.1 Home, household & finances/F.11 Home & property/F.11.61 Move out of 6 Dorothy"), + ("1 Life admin/12 Home, house, and transport/12.30 Vehicles", + "F Family/F.1 Home, household & finances/F.12 Vehicles/F.12.30 Vehicles"), + ("1 Life admin/12 Home, house, and transport/12.32 Motor vehicle auto insurance & claims", + "F Family/F.1 Home, household & finances/F.12 Vehicles/F.12.32 Motor vehicle auto insurance & claims"), + ("1 Life admin/12 Home, house, and transport/12.33 Car repairs, maintenance, & upkeep", + "F Family/F.1 Home, household & finances/F.12 Vehicles/F.12.33 Car repairs, maintenance, & upkeep"), + + # --- 13 Money -> split R.13 (solo) / F.13 (joint); tax + financial insurance are joint per design doc --- + ("1 Life admin/13 Money earned, spent, saved, & owed/13.54 Tax returns & accounting", + "F Family/F.1 Home, household & finances/F.13 Household finances/F.13.05 Tax returns & accounting"), + ("1 Life admin/13 Money earned, spent, saved, & owed/13.12 Financial insurance & claims", + "F Family/F.1 Home, household & finances/F.13 Household finances/F.13.12 Financial insurance & claims"), + ("1 Life admin/13 Money earned, spent, saved, & owed/13.44 Cryptocurrency", + "R Ryan/R.1 Life admin/R.13 Personal finance/R.13.44 Cryptocurrency"), + + # --- 11 Me & other living things -> mostly R (Ryan personal) --- + ("1 Life admin/11 Me & other living things/11.11 Birth certificate & proof of name", + "R Ryan/R.1 Life admin/R.11 Identity & vital records/R.11.11 Birth certificate & proof of name"), + ("1 Life admin/11 Me & other living things/11.14 Licenses", + "R Ryan/R.1 Life admin/R.11 Identity & vital records/R.11.14 Licenses"), + ("1 Life admin/11 Me & other living things/11.16 Legal documents & certificates", + "R Ryan/R.1 Life admin/R.11 Identity & vital records/R.11.16 Legal documents & certificates"), + ("1 Life admin/11 Me & other living things/11.28 Reproductive health", + "R Ryan/R.1 Life admin/R.12 Health & body/R.12.28 Reproductive health"), + ("1 Life admin/11 Me & other living things/11.83 My library", + "R Ryan/R.1 Life admin/R.11 Identity & vital records/R.11.83 Ryan's library"), + + # --- 15 Travel -> R.15 Travel (solo trips; matches exactly) --- + ("1 Life admin/15 Travel, events, & entertainment/15.41 All short trips", + "R Ryan/R.1 Life admin/R.15 Travel/R.15.41 All short trips"), + + # --- 2 Career -> R.2 Career, ~1:1 --- + ("2 Career/21 Perfect Day", "R Ryan/R.2 Career/R.21 Perfect Day"), + ("2 Career/22 Sejour", "R Ryan/R.2 Career/R.22 Sejour"), + ("2 Career/23 Consulting", "R Ryan/R.2 Career/R.23 Consulting"), + ("2 Career/24 Stanford", "R Ryan/R.2 Career/R.24 Stanford"), + ("2 Career/29 Old applications & archives", "R Ryan/R.2 Career/R.29 Old applications & archives"), + + # --- 3 Hobbies -> R.3 Hobbies, ~1:1 --- + ("3 Hobbies & interests/31 Language learning", "R Ryan/R.3 Hobbies & interests/R.31 Language learning"), + ("3 Hobbies & interests/36 Projects", "R Ryan/R.3 Hobbies & interests/R.36 Projects"), + + # --- 4 Flying -> R.4 Flying, ~1:1 --- + ("4 Flying/41 Certification", "R Ryan/R.4 Flying/R.41 Certification, legal, & insurance"), + ("4 Flying/43 Resources", "R Ryan/R.4 Flying/R.43 Resources"), +] + +# Items needing human judgment - do NOT auto-move, just report. +FLAG_FOR_REVIEW = [ + "1 Life admin/10 Management of area 10-19", # SMS backups - unclear whose/where + "1 Life admin/11 Me & other living things/11.43 My family", # Mom's book proposal + "1 Life admin/11 Me & other living things/11.09 Archive for category 11", # AI deck for Jen + "1 Life admin/11 Me & other living things/11.71 My career sales pitch", + "1 Life admin/13 Money earned, spent, saved, & owed/13.41 Bank accounts", # solo or joint? + "1 Life admin/13 Money earned, spent, saved, & owed/13.01 Inbox for category 13", + "1 Life admin/12 Home, house, and transport/12.01 Inbox for category 12", + "1 Life admin/14 My digital life/14.14 My data storage & backups", + "3 Hobbies & interests/30 Management for Hobbies", +] + + +def find_files(root): + for dirpath, _, filenames in os.walk(root): + for fn in filenames: + yield os.path.join(dirpath, fn) + + +def plan_moves(): + moves = [] # (src, dst) + unmatched = [] + for src in find_files(UNSORTED): + rel = os.path.relpath(src, UNSORTED) + matched = False + for old_prefix, new_prefix in MAPPING: + if rel == old_prefix or rel.startswith(old_prefix + os.sep): + suffix = rel[len(old_prefix):] + dst = os.path.join(DATA, new_prefix.replace("/", os.sep) + suffix) + moves.append((src, dst)) + matched = True + break + if not matched: + is_flagged = any(rel.startswith(f) for f in FLAG_FOR_REVIEW) + unmatched.append((rel, is_flagged)) + return moves, unmatched + + +def main(): + execute = "--execute" in sys.argv + moves, unmatched = plan_moves() + + print(f"=== PLANNED MOVES: {len(moves)} files ===") + by_target = {} + for src, dst in moves: + key = os.path.dirname(dst) + by_target[key] = by_target.get(key, 0) + 1 + for k, v in sorted(by_target.items()): + print(f" {v:5d} -> {k}") + + print(f"\n=== FLAGGED FOR REVIEW (not moved): {sum(1 for _, f in unmatched if f)} files ===") + flagged_dirs = {} + for rel, is_flagged in unmatched: + if is_flagged: + d = os.path.dirname(rel) + flagged_dirs[d] = flagged_dirs.get(d, 0) + 1 + for k, v in sorted(flagged_dirs.items()): + print(f" {v:5d} :: {k}") + + print(f"\n=== TRULY UNMATCHED (no mapping rule, not flagged): {sum(1 for _, f in unmatched if not f)} files ===") + other_dirs = {} + for rel, is_flagged in unmatched: + if not is_flagged: + d = os.path.dirname(rel) + other_dirs[d] = other_dirs.get(d, 0) + 1 + for k, v in sorted(other_dirs.items()): + print(f" {v:5d} :: {k}") + + if execute: + print("\n=== EXECUTING MOVES ===") + moved = 0 + for src, dst in moves: + os.makedirs(os.path.dirname(dst), exist_ok=True) + if os.path.exists(dst): + print(f" SKIP (dst exists): {dst}") + continue + shutil.move(src, dst) + moved += 1 + print(f"Moved {moved} files.") + else: + print("\n(dry run only -- pass --execute to actually move files)") + + +if __name__ == "__main__": + main() diff --git a/sort_unsorted_final.py b/sort_unsorted_final.py new file mode 100644 index 0000000..ec725aa --- /dev/null +++ b/sort_unsorted_final.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Final placement pass for the 8 flagged/reviewed _unsorted items.""" +import os, shutil, sys + +U = "/data/_unsorted" +D = "/data" + +MOVES = [ + # (src relative to _unsorted, dst absolute under /data) + ("1 Life admin/10 Management of area 10-19/10.09 Archive for area 10-19/SMS Backups as of 2026-04-07/data", + "R Ryan/R.1 Life admin/R.11 Identity & vital records/R.11.55 SMS backups"), + ("1 Life admin/11 Me & other living things/11.09 Archive for category 11/2025-08-07 AI Deck for Jen.pdf", + "J Jen/J.0 Meta/J.00 Meta/J.00.01 Inbox for Jen/2025-08-07 AI Deck for Jen.pdf"), + ("1 Life admin/11 Me & other living things/11.43 My family/Moms Anandi Book & Screenplay/America's Niece - Proposal.pdf", + "F Family/F.5 People/F.50 Family/F.50.20 Pandyas/F.50.21 Nandini Pandya/America's Niece - Proposal.pdf"), + ("1 Life admin/11 Me & other living things/11.71 My career sales pitch/Ryan_Pandya_Resume___2024_10-1.pdf", + "R Ryan/R.2 Career/R.20 Curiosity/R.20.02 Resume/Ryan_Pandya_Resume___2024_10-1.pdf"), + ("1 Life admin/11 Me & other living things/11.71 My career sales pitch/Ryan_Pandya_Resume.pdf", + "R Ryan/R.2 Career/R.20 Curiosity/R.20.02 Resume/Ryan_Pandya_Resume.pdf"), + ("1 Life admin/12 Home, house, and transport/12.01 Inbox for category 12/2021-03-08 - 6 Dorothy sheet from Jason.pdf", + "F Family/F.1 Home, household & finances/F.11 Home & property/F.11.09 Old Homes/2021-2026 Dorothy/2021-03-08 - 6 Dorothy sheet from Jason.pdf"), + ("1 Life admin/13 Money earned, spent, saved, & owed/13.01 Inbox for category 13/Voided check.jpg", + "R Ryan/R.1 Life admin/R.13 Personal finance/R.13.01 Inbox/Voided check.jpg"), + ("1 Life admin/13 Money earned, spent, saved, & owed/13.41 Bank accounts/Ryan Wells Fargo direct deposit instructions.pdf", + "R Ryan/R.1 Life admin/R.13 Personal finance/R.13.01 Inbox/Ryan Wells Fargo direct deposit instructions.pdf"), + ("1 Life admin/14 My digital life/14.14 My data storage & backups/Google backup key.pdf", + "R Ryan/R.1 Life admin/R.14 My digital life/R.14.01 Inbox/Google backup key.pdf"), + ("3 Hobbies & interests/30 Management for Hobbies/30.01 Inbox for Hobbies/elixirbook.pdf", + "R Ryan/R.3 Hobbies & interests/R.35 Computers/R.35.21 Computer & programming language textbooks/elixirbook.pdf"), +] + +execute = "--execute" in sys.argv +for rel_src, rel_dst in MOVES: + src = os.path.join(U, rel_src) + dst = os.path.join(D, rel_dst) + exists = os.path.exists(src) + print(f"{'OK ' if exists else 'MISS'} {src} -> {dst}") + if execute and exists: + os.makedirs(os.path.dirname(dst), exist_ok=True) + shutil.move(src, dst) + +if execute: + print("Done.") +else: + print("\n(dry run -- pass --execute)")