📄 main.py 11,047 bytes Today 15:36 📋 Raw

!/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"<h1>Unknown directory: {label}</h1>", status_code=404)

root = Path(DIRECTORIES[label])

# Determine which file to view
viewing_file = None
rendered_content = ""
file_meta = {}

if file:
    filepath = root / file
    if filepath.exists() and filepath.is_file() and filepath.suffix in (".md", ".markdown", ".txt", ".yaml", ".yml", ".py", ".json", ".toml"):
        viewing_file = str(file)
        rendered_content = render_markdown(filepath)
        file_meta = {
            "name": filepath.name,
            "size": filepath.stat().st_size,
            "modified": format_mtime(filepath.stat().st_mtime),
        }
elif subpath:
    # Subpath might be a file itself
    target = root / subpath
    if target.exists() and target.is_file() and target.suffix in (".md", ".markdown", ".txt", ".yaml", ".yml", ".py", ".json", ".toml"):
        viewing_file = subpath
        rendered_content = render_markdown(target)
        file_meta = {
            "name": target.name,
            "size": target.stat().st_size,
            "modified": format_mtime(target.stat().st_mtime),
        }

# Build tree for this directory
tree = get_file_tree(label, str(root))

# Determine breadcrumbs
breadcrumbs = [{"label": label, "path": f"/browse/{label}"}]
if "/" in (viewing_file or subpath or ""):
    accum = ""
    for segment in (viewing_file or subpath).split("/")[:-1]:
        accum = f"{accum}/{segment}".lstrip("/")
        breadcrumbs.append({"label": segment, "path": f"/browse/{label}/{accum}"})

return templates.TemplateResponse("browse.html.j2", {
    "request": request,
    "title": f"{label}{APP_TITLE}",
    "dir_label": label,
    "tree": tree,
    "breadcrumbs": breadcrumbs,
    "viewing_file": viewing_file,
    "rendered_content": rendered_content,
    "file_meta": file_meta,
    "directories": DIRECTORIES,
})

@app.get("/view/{dir_key:path}", response_class=HTMLResponse)
async def view_file(request: Request, dir_key: str):
"""Direct file view — full page markdown render."""
parts = dir_key.split("/", 1)
label = parts[0]
filepath_str = parts[1] if len(parts) > 1 else ""

from urllib.parse import unquote
label = unquote(label)

if label not in DIRECTORIES:
    return HTMLResponse(f"<h1>Unknown directory</h1>", status_code=404)

root = Path(DIRECTORIES[label])
filepath = root / filepath_str

if not filepath.exists():
    return HTMLResponse(f"<h1>File not found: {filepath_str}</h1>", status_code=404)

rendered = render_markdown(filepath)

return templates.TemplateResponse("view.html.j2", {
    "request": request,
    "title": f"{filepath.name}{APP_TITLE}",
    "file_name": filepath.name,
    "dir_label": label,
    "rendered_content": rendered,
    "file_size": filepath.stat().st_size,
    "file_modified": format_mtime(filepath.stat().st_mtime),
    "directories": DIRECTORIES,
})

@app.get("/raw/{dir_key:path}")
async def raw_file(dir_key: str):
"""Serve raw file content."""
parts = dir_key.split("/", 1)
label = parts[0]
filepath_str = parts[1] if len(parts) > 1 else ""

from urllib.parse import unquote
label = unquote(label)

if label not in DIRECTORIES:
    return PlainTextResponse("Not found", status_code=404)

root = Path(DIRECTORIES[label])
filepath = root / filepath_str

if not filepath.exists():
    return PlainTextResponse("Not found", status_code=404)

return PlainTextResponse(filepath.read_text(encoding="utf-8", errors="replace"))

── Main ─────────────────────────────────────────────────────────────

if name == "main":
import uvicorn
print(f"HoffDoc starting on {APP_HOST}:{APP_PORT}")
print(f"Exposing {len(DIRECTORIES)} directories:")
for label, path in DIRECTORIES.items():
exists = "✅" if Path(path).exists() else "❌"
print(f" {exists} {label}: {path}")
uvicorn.run(app, host=APP_HOST, port=APP_PORT, log_level="warning")