#!/usr/bin/env python3 """HoffDoc — a dead-simple mobile-first markdown file viewer. Serves rendered markdown from configured workspace directories. Dark mode, zero JS framework, Jinja2 + Python-Markdown + Pygments. """ import os from pathlib import Path from datetime import datetime from fastapi import FastAPI, Request, Query from fastapi.responses import HTMLResponse, RedirectResponse, PlainTextResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates import markdown from markdown.extensions import codehilite, fenced_code, tables, toc # ── Configuration ──────────────────────────────────────────────────── # Which directories to expose (label → path) # These are the workspaces you want to browse from your phone. DIRECTORIES = { "📋 Wadsworth": "/home/hoffmann_admin/.openclaw/workspace", "🧠 Socrates": "/home/hoffmann_admin/.openclaw/workspace-socrates", "🎨 Daedalus": "/home/hoffmann_admin/.openclaw/workspace-daedalus", "🪙 Midas": "/home/hoffmann_admin/.openclaw/workspace-midas", "🚀 Icarus": "/home/hoffmann_admin/icarus", "🏗️ Shared": "/home/hoffmann_admin/.openclaw/shared", } # Files to skip (hide from listing) SKIP_FILES = {".gitignore", ".DS_Store", "HEARTBEAT.md", "TOOLS.md", "*.json"} SKIP_DIRS = {"__pycache__", ".git", ".archive", ".venv", "node_modules", ".clawhub"} APP_TITLE = "HoffDoc" APP_PORT = int(os.environ.get("HOFFDOC_PORT", "8004")) APP_HOST = os.environ.get("HOFFDOC_HOST", "127.0.0.1") # ── App ────────────────────────────────────────────────────────────── BASE_DIR = Path(__file__).parent app = FastAPI(title=APP_TITLE, docs_url=None, redoc_url=None) templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static") md = markdown.Markdown( extensions=[ "fenced_code", "codehilite", "tables", "toc", "nl2br", "sane_lists", ], extension_configs={ "codehilite": { "css_class": "highlight", "guess_lang": True, "use_pygments": True, }, "toc": {"permalink": False}, }, output_format="html", ) # ── Helpers ────────────────────────────────────────────────────────── def should_skip_name(name: str, is_dir: bool = False) -> bool: if name.startswith("."): return True if is_dir and name in SKIP_DIRS: return True if not is_dir and name in SKIP_FILES: return True return False def get_file_tree(dir_label: str, dir_path: str, subpath: str = "") -> dict: """Build a recursive tree of files/dirs for sidebar navigation.""" root = Path(dir_path) / subpath if not root.exists(): return None items = [] for entry in sorted(root.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())): if should_skip_name(entry.name, entry.is_dir()): continue if entry.is_dir(): # Only recurse one level for memory/ and other key dirs children = [] for child in sorted(entry.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())): if should_skip_name(child.name, child.is_dir()): continue children.append({ "name": child.name, "path": f"{subpath}/{entry.name}/{child.name}".lstrip("/"), "is_dir": child.is_dir(), "children": [], }) items.append({ "name": entry.name, "path": f"{subpath}/{entry.name}".lstrip("/"), "is_dir": True, "children": children[:30], # cap per directory }) elif entry.suffix in (".md", ".markdown", ".txt", ".yaml", ".yml", ".py", ".json", ".toml", ".cfg", ".ini"): items.append({ "name": entry.name, "path": f"{subpath}/{entry.name}".lstrip("/"), "is_dir": False, "children": [], }) return {"label": dir_label, "root": dir_path, "items": items[:50]} def render_markdown(filepath: Path) -> str: """Read a markdown file and return rendered HTML.""" text = filepath.read_text(encoding="utf-8", errors="replace") # Reset the markdown instance for each render md.reset() return md.convert(text) def format_mtime(ts: float) -> str: dt = datetime.fromtimestamp(ts) now = datetime.now() diff = now - dt if diff.days == 0: return dt.strftime("Today %H:%M") elif diff.days == 1: return dt.strftime("Yesterday %H:%M") elif diff.days < 7: return dt.strftime("%A %H:%M") return dt.strftime("%b %d, %Y") # ── Routes ─────────────────────────────────────────────────────────── @app.get("/", response_class=HTMLResponse) async def index(request: Request): """Home: show overview of all directories with file counts.""" trees = [] for label, path in DIRECTORIES.items(): p = Path(path) if p.exists(): md_files = list(p.rglob("*.md")) # Count non-skipped count = sum(1 for f in md_files if not should_skip_name(f.name) and not any(s in str(f) for s in SKIP_DIRS)) trees.append({"label": label, "path": path, "file_count": count}) return templates.TemplateResponse("index.html.j2", { "request": request, "title": APP_TITLE, "trees": trees, "directories": DIRECTORIES, }) @app.get("/browse/{dir_key:path}", response_class=HTMLResponse) async def browse(request: Request, dir_key: str, file: str = Query(None)): """Browse a directory and optionally view a file. dir_key format: label/path/to/file.md The label must match one of the DIRECTORIES keys. """ # Parse dir_key: first segment is the directory label, rest is subpath parts = dir_key.split("/", 1) label = parts[0] subpath = parts[1] if len(parts) > 1 else "" # Decode URL-encoded label from urllib.parse import unquote label = unquote(label) if label not in DIRECTORIES: return HTMLResponse(f"