wiki-vault/migrate.py
Travis Herbranson 34a268d8dc migration: copy 62 notes from pbs-projects and homelab-projects
Two-layer structure: Sources (raw notes) + Wiki (compile output)
Four domains: Dev (40), Venture (3), Homelab (23), Reference (0)
Includes CLAUDE.md spec, index pages at all levels, compile log

Co-Authored-By: Lovebug <lovebug@herbylab.dev>
2026-05-08 17:21:13 -04:00

269 lines
8.7 KiB
Python

#!/usr/bin/env python3
"""Migration script — copies project plans and session notes from
pbs-projects and homelab-projects into wiki-vault's Sources/ layer.
Copies only, never moves. Generates a report of what went where
and flags anything that couldn't be auto-categorized.
Usage:
python3 migrate.py --dry-run # preview only
python3 migrate.py # copy files
"""
import argparse
import shutil
import re
from pathlib import Path
from datetime import date
try:
import frontmatter
except ImportError:
print("Install python-frontmatter: pip3 install python-frontmatter --break-system-packages")
raise SystemExit(1)
# --- Configuration ---
PBS_PROJECTS = Path("/opt/projects/pbs-projects")
HOMELAB_PROJECTS = Path("/opt/projects/homelab-projects")
WIKI_VAULT = Path("/opt/projects/wiki-vault")
SOURCES = WIKI_VAULT / "Sources"
# Path pattern → domain mapping
PATH_RULES = [
# pbs-projects
(r"^Tech/Projects/", "Dev"),
(r"^Tech/Sessions/", "Dev"),
(r"^Tech/Reference/", "Reference"),
(r"^PBS/Content/", "Venture"),
(r"^PBS/Tech/Projects/", "Dev"), # PBS tech is Dev, tag with pbs
(r"^PBS/Tech/Sessions/", "Dev"),
(r"^PBS/Inbox/", None), # manual review
(r"^Business/", "Venture"),
(r"^PBS-Planning/", "Venture"),
# homelab-projects (only markdown notes, not code projects)
(r"^homelab/", "Homelab"),
(r"^Docker/", "Homelab"),
(r"^settings/", None), # skip config files
]
# Slug-based overrides: files that land in Dev by path rules but belong in Homelab
# These are infrastructure/deployment/networking projects, not coding projects
HOMELAB_OVERRIDES = {
"traefik-deployment",
"ob1-deployment",
"ob1-main-deployment",
"homelab-mcp-server",
"ssh-login-alerting",
"ssh-ca-with-step-ca-authentik",
"sshkm-local-ssh-key-manager",
"env-file-hardening",
"pbs-security-hardening",
"pbs-infrastructure-intelligence",
"cli-standardization",
"instantmesh-docker",
"hunyuan3d-sunnie-pipeline",
# Sessions
"dns-knot-pihole-end-to-end",
"homelab-dns-decision",
"2026-05-08-homelab-vlan-renumber-switch-chip-to-bridge-alignment",
"server-stability-and-security-hardening",
"server-stability-mysql-oom",
"ufw-docker-outage-fix",
"wp-cron-supercronic-deploy",
"2026-05-06-homelab-mcp-server-stand-up",
}
# Folders in homelab-projects that are code repos, not notes
SKIP_DIRS = {
"OB1", "homelab-mcp-server", "second-brain",
".git", ".claude", ".venv", "node_modules",
}
def determine_domain(relative_path: str, meta: dict) -> str | None:
"""Determine target domain from path and metadata."""
# Check slug-based overrides first (infrastructure projects that path rules would put in Dev)
filename_stem = relative_path.rsplit("/", 1)[-1].replace(".md", "")
if filename_stem in HOMELAB_OVERRIDES:
return "Homelab"
for pattern, domain in PATH_RULES:
if re.match(pattern, relative_path):
return domain
# Fallback: check tags for hints
tags = meta.get("tags", [])
if isinstance(tags, list):
if "homelab" in tags:
return "Homelab"
if "pbs" in tags or "content" in tags:
return "Venture"
return None
def is_note_file(path: Path) -> bool:
"""Check if a file is a markdown note (not config, not code)."""
if not path.is_file():
return False
if path.suffix != ".md":
return False
if path.name in ("README.md", "CLAUDE.md", "CONTRIBUTING.md",
"CODE_OF_CONDUCT.md", "SECURITY.md", "LICENSE.md",
"BUILD-LOG.md", "DEPLOYMENT-LOG.md", "SETUP.md"):
return False
return True
def scan_repo(repo_path: Path, repo_name: str) -> list[dict]:
"""Scan a repo for markdown notes with frontmatter."""
results = []
for md_file in sorted(repo_path.rglob("*.md")):
# Skip hidden dirs and code project dirs
rel = md_file.relative_to(repo_path)
parts = rel.parts
if any(p in SKIP_DIRS or p.startswith(".") for p in parts):
continue
if not is_note_file(md_file):
continue
try:
post = frontmatter.load(md_file)
meta = dict(post.metadata)
except Exception:
meta = {}
note_type = meta.get("type", "unknown")
# Only migrate project plans and session notes
if note_type not in ("project-plan", "session-notes", "session"):
# Check if it looks like a project plan even without correct type
content = md_file.read_text(encoding="utf-8", errors="replace")
if "## Goal" in content or "## Locked Decisions" in content or "## Phases" in content:
note_type = "project-plan"
elif "## Outcome" in content or "## Topics Covered" in content:
note_type = "session-notes"
else:
continue # skip non-note files
rel_str = str(rel)
domain = determine_domain(rel_str, meta)
results.append({
"source_repo": repo_name,
"source_path": str(md_file),
"relative_path": rel_str,
"filename": md_file.name,
"type": note_type,
"domain": domain,
"meta": meta,
"project": meta.get("project", ""),
"status": meta.get("status", ""),
"tags": meta.get("tags", []),
})
return results
def update_frontmatter(content: str, new_path: str) -> str:
"""Update the path field in frontmatter to reflect new location."""
try:
post = frontmatter.loads(content)
post.metadata["path"] = new_path
return frontmatter.dumps(post)
except Exception:
return content
def copy_file(entry: dict, dry_run: bool = False) -> str:
"""Copy a file to its target location. Returns status message."""
domain = entry["domain"]
if not domain:
return f"SKIP (no domain): {entry['relative_path']}"
target_dir = SOURCES / domain
target_path = target_dir / entry["filename"]
if target_path.exists():
return f"SKIP (exists): {entry['filename']} → Sources/{domain}/"
if dry_run:
return f"WOULD COPY: {entry['relative_path']} → Sources/{domain}/{entry['filename']}"
# Read, update frontmatter, write
source = Path(entry["source_path"])
content = source.read_text(encoding="utf-8", errors="replace")
updated = update_frontmatter(content, f"Sources/{domain}")
target_path.write_text(updated, encoding="utf-8")
return f"COPIED: {entry['relative_path']} → Sources/{domain}/{entry['filename']}"
def main():
parser = argparse.ArgumentParser(description="Migrate notes to wiki-vault")
parser.add_argument("--dry-run", action="store_true", help="Preview only, don't copy")
args = parser.parse_args()
print("Scanning repositories...\n")
all_entries = []
if PBS_PROJECTS.exists():
all_entries.extend(scan_repo(PBS_PROJECTS, "pbs-projects"))
if HOMELAB_PROJECTS.exists():
all_entries.extend(scan_repo(HOMELAB_PROJECTS, "homelab-projects"))
print(f"Found {len(all_entries)} note files\n")
# Categorize
by_domain = {"Dev": [], "Venture": [], "Homelab": [], "Reference": [], None: []}
for entry in all_entries:
by_domain.setdefault(entry["domain"], []).append(entry)
# Report
print("=" * 60)
print("MIGRATION REPORT")
print("=" * 60)
for domain in ["Dev", "Venture", "Homelab", "Reference"]:
entries = by_domain.get(domain, [])
print(f"\n{domain}: {len(entries)} files")
for e in entries:
print(f" {e['relative_path']} ({e['type']})")
uncategorized = by_domain.get(None, [])
if uncategorized:
print(f"\nUNCATEGORIZED (manual review needed): {len(uncategorized)} files")
for e in uncategorized:
print(f" {e['source_repo']}: {e['relative_path']}")
# Copy
print(f"\n{'=' * 60}")
print(f"{'DRY RUN' if args.dry_run else 'COPYING FILES'}")
print(f"{'=' * 60}\n")
copied = 0
skipped = 0
for entry in all_entries:
result = copy_file(entry, dry_run=args.dry_run)
print(f" {result}")
if result.startswith("COPIED") or result.startswith("WOULD COPY"):
copied += 1
else:
skipped += 1
print(f"\nDone: {copied} {'would be copied' if args.dry_run else 'copied'}, {skipped} skipped")
if not args.dry_run and copied > 0:
print(f"\nNext steps:")
print(f" cd /opt/projects/wiki-vault")
print(f" git add -A")
print(f" git commit -m 'migration: copy {copied} files from pbs-projects and homelab-projects'")
if __name__ == "__main__":
main()