diff --git a/__pycache__/vault-librarian.cpython-313.pyc b/__pycache__/vault-librarian.cpython-313.pyc new file mode 100644 index 0000000..8ae5df0 Binary files /dev/null and b/__pycache__/vault-librarian.cpython-313.pyc differ diff --git a/vault-librarian.py b/vault-librarian.py index c4bdea0..48d772a 100644 --- a/vault-librarian.py +++ b/vault-librarian.py @@ -147,66 +147,222 @@ def build_report(): } +# ───────────────────────────────────────────────────────────────────────────── +# TUI color helpers +# ───────────────────────────────────────────────────────────────────────────── +import shutil + +_TTY = sys.stdout.isatty() and os.environ.get("NO_COLOR") is None + +# ANSI color codes +_BOLD = "\033[1m" +_DIM = "\033[2m" +_RED = "\033[31m" +_GREEN = "\033[32m" +_YELLOW = "\033[33m" +_CYAN = "\033[36m" +_RESET = "\033[0m" + + +def c(text, code): + """Apply color code if TTY; return plain text otherwise.""" + if not _TTY: + return text + return f"{code}{text}{_RESET}" + + +def rule(title): + """Draw a full-width section header with title.""" + width = shutil.get_terminal_size((100, 20)).columns + # Format: "━ TITLE " padded to width with ━ + prefix = "━ " + suffix = " " + available = width - len(prefix) - len(suffix) - len(title) + if available < 0: + available = 0 + padding = "━" * available + return c(f"{prefix}{title}{suffix}{padding}", _CYAN) + + +def badge(count, good_if_zero=True): + """Return a color-coded count badge.""" + if count == 0: + return c("✓ none", _GREEN) if good_if_zero else c("0", _GREEN) + elif count <= 3: + return c(str(count), _YELLOW) + else: + return c(str(count), _RED) + + +def box_summary(counts): + """Render a boxed summary header with counts.""" + # Build content lines + items = [ + (c("Systems", _DIM), c(str(counts["systems"]), _CYAN + _BOLD)), + (c("Areas", _DIM), c(str(counts["areas"]), _CYAN + _BOLD)), + (c("Categories", _DIM), c(str(counts["categories"]), _CYAN + _BOLD)), + (c("IDs", _DIM), c(str(counts["ids"]), _CYAN + _BOLD)), + ] + # Calculate width accounting for ANSI codes (rough estimate) + box_width = shutil.get_terminal_size((100, 20)).columns + + # Build box + if _TTY: + top = f"┌─{c('─' * (box_width - 4), _CYAN)}─┐" + else: + top = f"┌─{'─' * (box_width - 4)}─┐" + + content = " " + " · ".join(f"{label} {value}" for label, value in items) + " " + mid = f"│ {content} │" + + if _TTY: + bot = f"└─{c('─' * (box_width - 4), _CYAN)}─┘" + else: + bot = f"└─{'─' * (box_width - 4)}─┘" + + return f"{top}\n{mid}\n{bot}" + + def pretty(r): L = [] - c = r["counts"] - L.append(f"# Vault Librarian report") - L.append(f"{c['systems']} systems · {c['areas']} areas · {c['categories']} categories · {c['ids']} IDs\n") - + c_counts = r["counts"] + + # Summary banner + L.append(box_summary(c_counts)) + L.append("") + + # Broken links bl = r["broken_links"] - L.append(f"## Broken wikilinks: {bl['count']}") - if bl["guessable"]: - L.append(f"**Likely fixable ({len(bl['guessable'])})** — target renamed, single candidate:") - for b in bl["guessable"][:30]: - L.append(f" - `{b['file']}`: [[{b['target']}]] → guess [[{b['guess']}]]") - if bl["unresolvable"]: - L.append(f"**Unresolvable ({len(bl['unresolvable'])})** — target never existed / dropped stub:") - for b in bl["unresolvable"][:30]: - L.append(f" - `{b['file']}`: [[{b['target']}]]") + if bl["count"] == 0: + L.append(c(rule("Broken wikilinks"), _GREEN)) + L.append(f" {c('✓ none', _GREEN)}") + else: + L.append(c(rule(f"Broken wikilinks — {bl['count']} found"), _RED)) + if bl["guessable"]: + guessable_len = len(bl["guessable"]) + L.append(f" {c(f'Likely fixable ({guessable_len})', _GREEN)} — target renamed, single candidate:") + for b in bl["guessable"][:30]: + target = b['target'] + guess = b['guess'] + L.append(f" {c(b['file'], _DIM)}: {c(f'[[{target}]]', _BOLD)} {c('→', _DIM)} guess {c(f'[[{guess}]]', _BOLD)}") + if len(bl["guessable"]) > 30: + more = len(bl["guessable"]) - 30 + L.append(f" {c(f'… and {more} more', _DIM)}") + if bl["unresolvable"]: + unresolvable_len = len(bl["unresolvable"]) + L.append(f" {c(f'Unresolvable ({unresolvable_len})', _YELLOW)} — target never existed / dropped stub:") + for b in bl["unresolvable"][:30]: + target = b['target'] + L.append(f" {c(b['file'], _DIM)}: {c(f'[[{target}]]', _BOLD)}") + if len(bl["unresolvable"]) > 30: + more = len(bl["unresolvable"]) - 30 + L.append(f" {c(f'… and {more} more', _DIM)}") L.append("") - + + # Topology t = r["topology"] - L.append("## Cross-tree topology (/vault vs /data)") - for level in ("systems", "areas", "cats", "ids"): - md = t[level]["missing_in_data"]; od = t[level]["orphan_in_data"] - if md: - L.append(f" - **{level} missing /data home ({len(md)}):** {', '.join(md[:20])}{' …' if len(md)>20 else ''}") - if od: - L.append(f" - **{level} orphan in /data ({len(od)}):** {', '.join(od[:20])}") + has_mismatch = any( + t[level]["missing_in_data"] or t[level]["orphan_in_data"] + for level in ("systems", "areas", "cats", "ids") + ) + + if not has_mismatch: + L.append(c(rule("Cross-tree topology (/vault vs /data)"), _GREEN)) + L.append(f" {c('✓ in sync', _GREEN)}") + else: + L.append(c(rule("Cross-tree topology — mismatches found"), _YELLOW)) + for level in ("systems", "areas", "cats", "ids"): + md = t[level]["missing_in_data"] + od = t[level]["orphan_in_data"] + if md: + md_str = ", ".join(md[:20]) + (f" {c('…', _DIM)}" if len(md) > 20 else "") + L.append(f" {c(f'{level} missing /data ({len(md)})', _YELLOW)}: {md_str}") + if od: + od_str = ", ".join(od[:20]) + (f" {c('…', _DIM)}" if len(od) > 20 else "") + L.append(f" {c(f'{level} orphan in /data ({len(od)})', _YELLOW)}: {od_str}") L.append("") - + + # Structure st = r["structure"] - L.append("## JD structural") - if st["extend_notation"]: - L.append(f" - **extend-notation `NN+` needing real IDs ({len(st['extend_notation'])}):**") - for e in st["extend_notation"]: - L.append(f" {e}") - if st["missing_dot00"]: - L.append(f" - **categories missing a .00 note ({len(st['missing_dot00'])}):** {', '.join(sorted(st['missing_dot00'])[:25])}") - if st["missing_folder_note"]: - L.append(f" - **folders missing a folder-note ({len(st['missing_folder_note'])}):** {len(st['missing_folder_note'])} (Folder Notes plugin)") - L.append("") - + has_struct_issues = bool(st["extend_notation"] or st["missing_dot00"] or st["missing_folder_note"]) + + if has_struct_issues: + L.append(c(rule("JD structural issues"), _YELLOW)) + if st["extend_notation"]: + extend_len = len(st["extend_notation"]) + L.append(f" {c(f'extend-notation `NN+` needing real IDs ({extend_len})', _YELLOW)}:") + for e in st["extend_notation"][:20]: + L.append(f" {c(e, _DIM)}") + if len(st["extend_notation"]) > 20: + more = len(st["extend_notation"]) - 20 + L.append(f" {c(f'… and {more} more', _DIM)}") + if st["missing_dot00"]: + codes = ", ".join(sorted(st["missing_dot00"])[:25]) + if len(st["missing_dot00"]) > 25: + codes += f" {c('…', _DIM)}" + dot00_len = len(st["missing_dot00"]) + L.append(f" {c(f'categories missing .00 note ({dot00_len})', _YELLOW)}: {codes}") + if st["missing_folder_note"]: + folder_len = len(st["missing_folder_note"]) + L.append(f" {c(f'folders missing folder-note ({folder_len})', _YELLOW)}") + L.append("") + + # To-dos td = r["todos"] - L.append(f"## To-dos: {td['count']} open ({len(td['pressing'])} pressing, {len(td['long_term'])} long-term)") - L.append(f"**Pressing ({len(td['pressing'])}):**") - for x in td["pressing"][:40]: - L.append(f" - `{x['file']}`:{x['line']} {x['text']}") - L.append(f"**Long-term ({len(td['long_term'])})** — standing checklists, not nagged:") - for x in td["long_term"][:15]: - L.append(f" - `{x['file']}`:{x['line']} {x['text']}") - if len(td["long_term"]) > 15: - L.append(f" … and {len(td['long_term'])-15} more long-term") + total_todos = td['count'] + L.append(c(rule(f"To-dos: {c(str(total_todos), _BOLD)} open"), _CYAN)) + pressing_len = len(td["pressing"]) + longterm_len = len(td["long_term"]) + L.append(f" {c(f'Pressing: {pressing_len}', _BOLD)} / {c(f'Long-term: {longterm_len}', _DIM)}") + + if td["pressing"]: + L.append(f" {c('Pressing:', _BOLD)}") + for x in td["pressing"][:40]: + file_info = c(f'{x["file"]}:{x["line"]}', _CYAN) + L.append(f" {file_info} {x['text']}") + if len(td["pressing"]) > 40: + more = len(td["pressing"]) - 40 + L.append(f" {c(f'… and {more} more', _DIM)}") + + if td["long_term"]: + L.append(f" {c('Long-term (standing checklists):', _DIM)}") + for x in td["long_term"][:15]: + file_info = c(f'{x["file"]}:{x["line"]}', _DIM) + text_info = c(x['text'], _DIM) + L.append(f" {file_info} {text_info}") + if len(td["long_term"]) > 15: + more = len(td["long_term"]) - 15 + L.append(f" {c(f'… and {more} more', _DIM)}") L.append("") - + + # Category health ch = r["category_health"] - if ch["near_id_limit"]: - L.append("## Category health") + if ch["near_id_limit"] or ch["duplicate_titles"]: + L.append(c(rule("Category health"), _YELLOW)) for x in ch["near_id_limit"]: - L.append(f" - `{x['category']}` has {x['ids']} IDs (near 10 limit)") - if ch["duplicate_titles"]: - L.append(f" - duplicate ID titles across systems ({len(ch['duplicate_titles'])}): " - + "; ".join(f"{t} {v}" for t, v in list(ch["duplicate_titles"].items())[:10])) + category = c(x['category'], _BOLD) + ids_count = c(str(x['ids']), _YELLOW) + L.append(f" {category} — {ids_count} IDs, near 10 limit") + if ch["duplicate_titles"]: + for t, codes in list(ch["duplicate_titles"].items())[:10]: + codes_str = ", ".join(codes) + L.append(f" {c('⚠ duplicate title:', _YELLOW)} {t} {c(codes_str, _DIM)}") + L.append("") + + # Footer status bar + issues_count = sum([ + bl["count"], + sum(1 for level in ("systems", "areas", "cats", "ids") + if t[level]["missing_in_data"] or t[level]["orphan_in_data"]), + len(st["extend_notation"]) + len(st["missing_dot00"]) + len(st["missing_folder_note"]), + len(ch["near_id_limit"]) + len(ch["duplicate_titles"]), + ]) + + if issues_count == 0: + L.append(c("● VAULT HEALTHY", _GREEN)) + else: + L.append(c(f"● {issues_count} item(s) need attention", _YELLOW)) + return "\n".join(L)