diff --git a/README.md b/README.md index 9cd799f..5cabe66 100644 --- a/README.md +++ b/README.md @@ -1,117 +1,117 @@ -# Hermes Wiki Viewer +# hermes-wiki-static -A customizable Obsidian-vault viewer with 3D Graph visualization, current-page highlighting, and responsive layout. Designed specifically for Lukas Huber's [Hermes Wiki](https://github.com/NousResearch/hermes-agent) workflow. +Static HTML wiki generator for Lukas Huber's karpathy-style Obsidian-vault. -**This is a side-project fork of [DanielCheer/obsidian-web-viewer](https://github.com/DanielCheer/obsidian-web-viewer)** (MIT, 2026-04) with customizations layered on top via an additive `vault-custom.js`. Upstream patches merge cleanly because we touch exactly one line of `vault.html`. +## What this is -## Features (in addition to upstream) +A Python tool that watches `/home/admin/my-karpathy-wiki/` for changes and +regenerates static HTML files on disk. A simple HTTP server serves them on +loopback port 8765; Tailscale exposes it to the tailnet. -- **Current-page highlighting in 3D Graph** — the node representing the open file gets a brighter material and a glow outline; connected nodes (via `[[wikilinks]]`) are also highlighted; unrelated nodes dim to 20% opacity -- **Click-to-navigate on 3D Graph** — Three.js raycaster triggers file navigation on node click -- **Responsive layout** — graph collapses to a bottom panel below 1024px viewport; tree collapses to hamburger menu below 768px -- **Backlinks panel** (planned) — see CHANGELOG.md +## Why this exists -## Features (from upstream, unchanged) +We previously used `DanielCheer/obsidian-web-viewer` (forked as +`hermes-wiki-viewer`). It had three blockers: -- File tree sidebar (collapsible folder tree) -- Markdown rendering with code blocks, tables, blockquotes, images -- Wikilink navigation (`[[links]]` click-to-traverse) -- YAML frontmatter rendered as styled card -- Full-text search by note name and content with snippets -- 3D graph visualization (Three.js) showing note connections -- Catppuccin-inspired dark theme -- Zero client-side setup — anyone with the URL can browse +- **No touch support on iOS Safari** — click handlers don't work reliably +- **3D-Graph auto-rotates** — prevents node selection via tap +- **Fixed 260px Graph column** — wastes horizontal space + +This tool replaces obv with our own renderer: + +- **Touch-first design** — bottom-nav on mobile, hamburger-tree, FAB+modal graph +- **Static HTML** — each page is a real URL, deep-linkable, PWA-installable +- **No auto-rotation** — graph is static, click-to-rotate, click-to-navigate +- **Native-feeling** — service worker, pull-to-refresh, swipe-back ## Architecture -See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the design rationale (why `vault-custom.js`, why the minimal `vault.html` patch, why we don't fork aggressively). +``` +my-karpathy-wiki/*.md (input) + | + v +[watchdog observer] (auto-regen on .md change) + | + v +generator.py (Python: markdown + frontmatter) + | + v +~/.local/share/hermes-wiki/site/ (static HTML output) + | + v +python3 http.server (loopback:8765) + | + v +tailscale serve (https://openclaw.wholphin-musical.ts.net/) +``` -## Quick Start +## Files + +- `generator.py` — daemon with watchdog + HTTP server +- `requirements.txt` — markdown, pyyaml, watchdog, python-frontmatter + +## Run ```bash -# Clone -git clone https://github.com/LukasHuber/hermes-wiki-viewer.git -cd hermes-wiki-viewer +# Install deps (one-time, system Python) +pip install --break-system-packages markdown pyyaml watchdog python-frontmatter -# Optional: PyYAML for frontmatter -pip install -r requirements.txt +# Start daemon +python3 generator.py --start -# Point to your vault -python3 server.py --vault /path/to/your/vault --host 127.0.0.1 --port 8765 +# Status / Stop +python3 generator.py --status +python3 generator.py --stop -# Open http://localhost:8765 +# One-shot regen (no watcher, no HTTP server) +python3 generator.py --once ``` -For Tailscale access (Lukas' typical setup), pair with `tailscale serve`: +## Output structure -```bash -tailscale serve --bg --https=443 http://127.0.0.1:8765 -# Access via https://..ts.net/ +``` +site/ +├── index.html (redirect to first note) +├── concepts/.html (one file per .md) +├── entities/.html +├── ... +└── __/ + ├── style.css + ├── app.js + ├── data.js (all metadata inlined for offline) + ├── tree.json + ├── graph.json + ├── tags.json + ├── backlinks.json + └── manifest.json (PWA) ``` -### One-command setup via wrapper script +## WikiLink syntax -The `scripts/hermes-wiki-serve.sh` wrapper handles start/stop/logs/status, PID-tracking, log-files, and Tailscale integration: +`[[entity-name]]` or `[[entity-name|display text]]` resolves to a real +`` if the target exists, +otherwise `` (greyed out). -```bash -# Install: copy script to PATH -cp scripts/hermes-wiki-serve.sh ~/.local/bin/ -chmod +x ~/.local/bin/hermes-wiki-serve.sh - -# Use: -hermes-wiki-serve.sh start # starts server, sets up tailscale serve -hermes-wiki-serve.sh status # shows PID, Tailscale-URL, health -hermes-wiki-serve.sh logs # tail all logs -hermes-wiki-serve.sh stop # stops server (keeps tailscale running) -hermes-wiki-serve.sh restart # stop + start -``` - -The script defaults to using this repo at `~/repos/hermes-wiki-viewer/`. Override via `HERMES_WIKI_OWV_DIR`. - -## Customization - -`vault-custom.js` is Lukas' own code, organized into clear sections: - -```js -// Current-page highlighting -window.HermesCustom = window.HermesCustom || {}; -HermesCustom.highlightCurrentNode = function() { ... }; - -// Graph click-to-navigate -HermesCustom.setupGraphClickHandler = function() { ... }; - -// Responsive layout -HermesCustom.responsiveLayout = function() { ... }; -``` - -To add a new customization, add a method to `HermesCustom` and call it from `HermesCustom.init()` at the bottom of the file. - -## Updating from upstream - -```bash -git remote add upstream https://github.com/DanielCheer/obsidian-web-viewer.git -git fetch upstream -git merge upstream/master -# Conflicts should only occur in vault.html — the one-line patch -``` - -If `vault.html` has been heavily modified upstream, manually re-apply the single patch: - -```html - - -``` - -## License - -MIT — same as upstream. See [LICENSE](LICENSE). - -## Author - -Lukas Huber — see [Personal-Profile](https://github.com/LukasHuber) for context. -This fork exists to make the agent-wiki viewing experience fit Lukas' specific needs (3D graph focus, responsive layout for mobile reading, current-page highlighting for fast cross-page navigation). +## Frontmatter +```yaml --- +title: My Note +type: concept +status: stable +updated: 2026-07-15 +sources: + - https://example.com +tags: + - hermes + - architecture +--- +``` -Upstream: [DanielCheer/obsidian-web-viewer](https://github.com/DanielCheer/obsidian-web-viewer) © 2026 Daniel Cheer -Hermes Wiki Viewer fork © 2026 Lukas Huber \ No newline at end of file +## Known limitations (Phase 1) + +- CSS is placeholder — touch design comes in Phase 2 +- JS is placeholder — search/tree/graph render comes in Phase 2 +- Bottom-Nav template is there but not styled +- 5 YAML files in the wiki have malformed frontmatter (parser warnings) +- Graph only shows 27 edges — some WikiLinks not resolving due to those YAML issues diff --git a/generator.py b/generator.py new file mode 100644 index 0000000..324a5ce --- /dev/null +++ b/generator.py @@ -0,0 +1,745 @@ +#!/tmp/ea-venv/bin/python3 +""" +hermes-wiki-generator: Static HTML generator for Lukas' karpathy-style Wiki. + +Architecture: + 1. Watchdog observes /home/admin/my-karpathy-wiki/ for changes + 2. On .md change → re-render single HTML file to /home/admin/.local/share/hermes-wiki/site/ + 3. On Wiki startup → generate tree.json, graph.json, tag-cloud.json (one-time) + 4. Python http.server serves /home/admin/.local/share/hermes-wiki/site/ on 127.0.0.1:8765 + +Why static HTML: + - Mobile-first: load once, works offline (with service worker) + - Fast: zero JS for content rendering, JSON data lazy-loaded + - Bookmarkable: each page is a real URL like /concepts/agent-reference-model.html + - Touch-friendly: no animation that fights tap-targets + +Run: + python3 ~/repos/hermes-wiki-static/generator.py --start + python3 ~/repos/hermes-wiki-static/generator.py --stop + python3 ~/repos/hermes-wiki-static/generator.py --status + python3 ~/repos/hermes-wiki-static/generator.py --once (one-shot regen, no watcher) +""" +import argparse +import json +import logging +import os +import re +import signal +import socket +import sys +import time +from http.server import HTTPServer, SimpleHTTPRequestHandler +from pathlib import Path +from threading import Lock, Thread +from urllib.parse import unquote + +import frontmatter +import markdown as md +import yaml +from watchdog.events import FileSystemEvent, FileSystemEventHandler +from watchdog.observers import Observer + +# ============================================================ +# Configuration +# ============================================================ + +WIKI_DIR = Path("/home/admin/my-karpathy-wiki") +SITE_DIR = Path("/home/admin/.local/share/hermes-wiki/site") +LOG_DIR = Path("/home/admin/.local/share/hermes-wiki/log") +PID_FILE = Path("/tmp/hermes-wiki-static.pid") +PORT = 8765 +BIND = "127.0.0.1" + +LOG_DIR.mkdir(parents=True, exist_ok=True) +SITE_DIR.mkdir(parents=True, exist_ok=True) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[ + logging.FileHandler(LOG_DIR / "generator.log"), + logging.StreamHandler(sys.stdout), + ], +) +log = logging.getLogger("hermes-wiki") + + +# ============================================================ +# Markdown → HTML rendering +# ============================================================ + +# WikiLink regex: [[entity-name]] or [[path/to/entity|display-text]] +WIKILINK_RE = re.compile(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]") +# Hashtag: #tag-name (max 30 chars, alphanumeric + dash) +TAG_RE = re.compile(r"(? str: + """Convert /concepts/agent-reference-model.md → concepts/agent-reference-model""" + rel = path.relative_to(WIKI_DIR).with_suffix("") + return str(rel).replace(os.sep, "/") + + +def read_markdown(path: Path) -> frontmatter.Post: + """Read .md with YAML frontmatter, fallback to plain text.""" + try: + return frontmatter.load(path) + except yaml.YAMLError as e: + log.warning(f"YAML-Fehler in {path}: {e} — versuche ohne Frontmatter") + text = path.read_text(encoding="utf-8", errors="replace") + return frontmatter.Post(text) + + +def resolve_wikilinks(text: str, slug_to_path: dict) -> str: + """Replace [[entity-name]] with entity-name.""" + def replace(m: re.Match) -> str: + target = m.group(1).strip() + display = (m.group(2) or target).strip() + # Try exact match first + if target in slug_to_path: + slug = slug_to_path[target] + return f'{display}' + # Try with .md suffix stripped + target_no_ext = target.replace(".md", "") + if target_no_ext in slug_to_path: + slug = slug_to_path[target_no_ext] + return f'{display}' + # Not found — render as broken link + return f'{display}' + return WIKILINK_RE.sub(replace, text) + + +def quote(s: str) -> str: + """URL-encode path components.""" + import urllib.parse + return urllib.parse.quote(s, safe="/-_~.") + + +def render_page(md_path: Path, slug_to_path: dict, all_meta: list) -> dict: + """Render one .md to (HTML + metadata). Returns dict with html, title, slug, frontmatter.""" + post = read_markdown(md_path) + slug = slugify(md_path) + + # Replace wikilinks before markdown rendering + body_with_links = resolve_wikilinks(post.content, slug_to_path) + html_body = md.markdown( + body_with_links, + extensions=MD_EXTENSIONS, + extension_configs={"toc": {"permalink": True}}, + ) + + # Extract title from H1 if not in frontmatter + title = post.metadata.get("title") or md_path.stem.replace("-", " ").title() + if not post.metadata.get("title"): + h1_match = re.search(r"]*>(.*?)", html_body, re.IGNORECASE) + if h1_match: + title = re.sub(r"<[^>]+>", "", h1_match.group(1)) + + return { + "slug": slug, + "title": title, + "html": html_body, + "frontmatter": dict(post.metadata), + "path": str(md_path.relative_to(WIKI_DIR)), + } + + +# ============================================================ +# Index data: tree, graph, tags, backlinks +# ============================================================ + +def build_index(slug_meta_list: list) -> dict: + """Build tree.json, graph.json, tags.json, backlinks.json.""" + slug_to_meta = {s["slug"]: s for s in slug_meta_list} + + # Tree: hierarchical folder structure + tree = {"name": "Vault", "children": {}, "files": []} + for s in slug_meta_list: + parts = s["slug"].split("/") + node = tree + for folder in parts[:-1]: + node = node["children"].setdefault(folder, {"name": folder, "children": {}, "files": []}) + node["files"].append({"name": parts[-1], "title": s["title"], "slug": s["slug"]}) + + # Recursively convert dict-of-children to list-of-children (easier JSON) + def tree_to_list(node): + result = { + "name": node["name"], + "files": sorted(node["files"], key=lambda f: f["name"].lower()), + } + result["folders"] = sorted( + [tree_to_list(child) for child in node["children"].values()], + key=lambda f: f["name"].lower() + ) + return result + tree_list = tree_to_list(tree) + + # Graph: extract [[wikilinks]] from each rendered HTML + nodes = [] + edges = [] + for s in slug_meta_list: + nodes.append({"id": s["slug"], "title": s["title"], "size": 1}) + # Find all wikilinks in the source + wikilinks = WIKILINK_RE.findall(s["html"] + " " + str(s["frontmatter"])) + for target, _disp in wikilinks: + target_slug = target.replace(".md", "") + if target_slug in slug_to_meta: + edges.append({"source": s["slug"], "target": target_slug}) + + # Tags: collect from frontmatter.tags + inline #tags + tags = {} # tag → [slugs] + for s in slug_meta_list: + # Frontmatter tags + fm_tags = s["frontmatter"].get("tags", []) + if isinstance(fm_tags, str): + fm_tags = [t.strip() for t in fm_tags.split(",")] + for tag in fm_tags or []: + tags.setdefault(str(tag).lower(), []).append(s["slug"]) + # Inline #tags in content + inline_tags = TAG_RE.findall(s["html"]) + for tag in inline_tags: + tags.setdefault(tag.lower(), []).append(s["slug"]) + + # Backlinks: for each page, list of pages that link TO it + backlinks = {s["slug"]: [] for s in slug_meta_list} + for edge in edges: + target = edge["target"] + source = edge["source"] + if source != target: # skip self-links + backlinks[target].append(source) + + return { + "tree": tree_list, + "graph": {"nodes": nodes, "edges": edges}, + "tags": tags, + "backlinks": backlinks, + } + + +# ============================================================ +# HTML page template +# ============================================================ + +PAGE_TEMPLATE = """ + + + + + +{title} · Hermes Wiki + + + + + +
+
+ + 📓 Hermes Wiki + + +
+ +
+
+ {frontmatter_card} +
{body}
+ + +
+ {path} +
+
+
+ + +
+ + + +""" + + +def render_frontmatter_card(meta: dict) -> str: + """Render frontmatter as a Catppuccin-styled card.""" + if not meta: + return "" + rows = [] + for key, value in meta.items(): + if key in ("title", "tags"): + continue # shown elsewhere + if isinstance(value, list): + value = ", ".join(str(v) for v in value) + rows.append(f'
{key}{value}
') + if not rows: + return "" + return f'' + + +def write_page(meta: dict, site_dir: Path) -> None: + """Write a single HTML page to SITE_DIR/.html.""" + target = site_dir / (quote(meta["slug"]) + ".html") + target.parent.mkdir(parents=True, exist_ok=True) + kind = "concept" if "concepts" in meta["slug"] else \ + "entity" if "entities" in meta["slug"] else \ + "scratch" if "scratch" in meta["slug"] else "note" + html = PAGE_TEMPLATE.format( + title=meta["title"], + body=meta["html"], + frontmatter_card=render_frontmatter_card(meta["frontmatter"]), + kind=kind, + path=meta["path"], + ) + target.write_text(html, encoding="utf-8") + + +def write_index_files(index_data: dict, site_dir: Path) -> None: + """Write tree.json, graph.json, tags.json, backlinks.json.""" + assets_dir = site_dir / "__" + assets_dir.mkdir(parents=True, exist_ok=True) + (assets_dir / "tree.json").write_text( + json.dumps(index_data["tree"], ensure_ascii=False, indent=2), + encoding="utf-8" + ) + (assets_dir / "graph.json").write_text( + json.dumps(index_data["graph"], ensure_ascii=False, indent=2), + encoding="utf-8" + ) + (assets_dir / "tags.json").write_text( + json.dumps(index_data["tags"], ensure_ascii=False, indent=2), + encoding="utf-8" + ) + (assets_dir / "backlinks.json").write_text( + json.dumps(index_data["backlinks"], ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + +def write_data_js(slug_meta_list: list, index_data: dict, site_dir: Path) -> None: + """Single data.js with all metadata + index baked in (avoids CORS issues).""" + assets_dir = site_dir / "__" + assets_dir.mkdir(parents=True, exist_ok=True) + pages = [{ + "slug": s["slug"], + "title": s["title"], + "tags": s["frontmatter"].get("tags", []) if isinstance(s["frontmatter"].get("tags"), list) else [], + "type": s["frontmatter"].get("type", "note"), + "path": s["path"], + } for s in slug_meta_list] + js = f"""// Auto-generated by hermes-wiki-generator +window.HERMES_DATA = {{ + pages: {json.dumps(pages, ensure_ascii=False)}, + tree: {json.dumps(index_data['tree'], ensure_ascii=False)}, + graph: {json.dumps(index_data['graph'], ensure_ascii=False)}, + tags: {json.dumps(index_data['tags'], ensure_ascii=False)}, + backlinks: {json.dumps(index_data['backlinks'], ensure_ascii=False)}, + currentSlug: "{slug_meta_list[0]['slug'] if slug_meta_list else ''}", +}}; +""" + (site_dir / "__/data.js").write_text(js, encoding="utf-8") + + +# ============================================================ +# Static assets (CSS, JS, PWA manifest) +# ============================================================ + +# CSS is a placeholder — touch-first design comes in next commit +CSS_PLACEHOLDER = """/* Hermes Wiki — placeholder CSS */ +:root { + --bg-primary: #0a0a14; + --bg-secondary: #0e0e1a; + --bg-surface: #1a1a2e; + --text-primary: #cdd6f4; + --text-secondary: #a6adc8; + --text-muted: #6c7086; + --accent: #FFD700; + --link: #FFD700; + --border: #313244; +} +* { margin: 0; padding: 0; box-sizing: border-box; } +body { background: var(--bg-primary); color: var(--text-primary); font-family: -apple-system, 'Segoe UI', Inter, sans-serif; } +.app-shell { display: grid; grid-template-columns: 280px 1fr 260px; grid-template-rows: 48px 1fr; height: 100vh; } +.app-header { grid-column: 1 / -1; background: var(--bg-secondary); border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 16px; gap: 12px; } +.brand { color: var(--accent); font-weight: 700; } +#search { flex: 1; max-width: 400px; padding: 6px 12px; background: rgba(30,30,50,0.6); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary); } +.icon-btn { background: none; border: 1px solid var(--border); color: var(--text-secondary); padding: 4px 10px; border-radius: 4px; cursor: pointer; } +.app-tree { background: var(--bg-secondary); border-right: 1px solid var(--border); overflow-y: auto; padding: 8px; } +.app-content { overflow-y: auto; padding: 32px 48px; } +.app-graph { background: var(--bg-secondary); border-left: 1px solid var(--border); } +.meta-card { background: var(--bg-surface); border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px; margin-bottom: 24px; font-size: 12px; } +.meta-row { display: flex; gap: 8px; padding: 2px 0; } +.meta-key { color: var(--accent); font-weight: 600; min-width: 80px; } +.wikilink-missing { color: var(--text-muted); text-decoration: line-through; } +.note-body { line-height: 1.7; } +.note-body h1, .note-body h2, .note-body h3 { margin-top: 1.5em; margin-bottom: 0.5em; } +.note-body code { background: var(--bg-surface); padding: 2px 6px; border-radius: 3px; } +.note-body pre { background: var(--bg-surface); padding: 12px; border-radius: 6px; overflow-x: auto; } +.app-bottom-nav { display: none; } +""" + +JS_PLACEHOLDER = """// Hermes Wiki — placeholder JS +(function() { + 'use strict'; + // Bootstrap from data.js + const data = window.HERMES_DATA || { pages: [], tree: [], graph: { nodes: [], edges: [] }, tags: {}, backlinks: {} }; + + // Render tree into sidebar + function renderTree(node, container, basePath = '') { + const ul = document.createElement('ul'); + if (node.folders) { + for (const folder of node.folders) { + const li = document.createElement('li'); + const header = document.createElement('div'); + header.textContent = '📁 ' + folder.name; + header.style.cursor = 'pointer'; + const childrenContainer = document.createElement('div'); + header.onclick = () => { + childrenContainer.style.display = childrenContainer.style.display === 'none' ? 'block' : 'none'; + }; + childrenContainer.style.display = 'none'; + childrenContainer.style.paddingLeft = '12px'; + li.appendChild(header); + li.appendChild(childrenContainer); + renderTree(folder, childrenContainer, basePath + '/' + folder.name); + ul.appendChild(li); + } + } + if (node.files) { + for (const file of node.files) { + const li = document.createElement('li'); + const a = document.createElement('a'); + a.href = '/' + file.slug + '.html'; + a.textContent = file.title; + a.style.color = 'var(--text-primary)'; + a.style.textDecoration = 'none'; + a.style.display = 'block'; + a.style.padding = '2px 8px'; + li.appendChild(a); + ul.appendChild(li); + } + } + container.appendChild(ul); + } + + const treeEl = document.getElementById('tree'); + if (treeEl && data.tree) { + renderTree(data.tree, treeEl); + } + + // Search (client-side) + const search = document.getElementById('search'); + if (search) { + search.addEventListener('input', (e) => { + const q = e.target.value.toLowerCase().trim(); + if (!q) return; + // Simple substring search on titles + tags + const matches = data.pages.filter(p => + p.title.toLowerCase().includes(q) || + (p.tags || []).some(t => t.toLowerCase().includes(q)) + ); + console.log('[Search]', q, '→', matches.length, 'results'); + // TODO: show results dropdown + }); + } + + // Toggle buttons (placeholder) + document.addEventListener('click', (e) => { + if (e.target.dataset.action === 'toggle-tree') { + document.querySelector('.app-tree').classList.toggle('open'); + } + if (e.target.dataset.action === 'toggle-graph') { + document.querySelector('.app-graph').classList.toggle('open'); + } + }); +})(); +""" + +PWA_MANIFEST = """{ + "name": "Hermes Wiki", + "short_name": "Wiki", + "description": "Lukas Huber's knowledge vault — mobile-first static wiki", + "start_url": "/__/index.html", + "display": "standalone", + "background_color": "#0a0a14", + "theme_color": "#0a0a14", + "icons": [ + { + "src": "data:image/svg+xml,📓", + "sizes": "any", + "type": "image/svg+xml" + } + ] +}""" + + +def write_assets(site_dir: Path) -> None: + """Write static assets (CSS, JS, manifest).""" + assets_dir = site_dir / "__" + assets_dir.mkdir(parents=True, exist_ok=True) + (assets_dir / "style.css").write_text(CSS_PLACEHOLDER, encoding="utf-8") + (assets_dir / "app.js").write_text(JS_PLACEHOLDER, encoding="utf-8") + (assets_dir / "manifest.json").write_text(PWA_MANIFEST, encoding="utf-8") + # Index page (redirect to first page or show all) + index_html = """ +Hermes Wiki + + +Open Wiki""" + first_slug = "index" # fall back to index.md + (site_dir / "__/index.html").write_text( + index_html.replace("{slug}", first_slug), encoding="utf-8" + ) + + +# ============================================================ +# Generator orchestration +# ============================================================ + +class WikiState: + """Holds in-memory state of generated pages and index.""" + def __init__(self): + self.lock = Lock() + self.slug_to_path = {} # slug → original .md path + self.slug_meta_list = [] # list of {slug, title, html, frontmatter, path} + self.index_data = {} # tree, graph, tags, backlinks + + def regenerate_all(self): + """Full rebuild — used on startup.""" + log.info(f"Full regenerate: scanning {WIKI_DIR}") + with self.lock: + self.slug_to_path.clear() + self.slug_meta_list.clear() + + md_files = sorted(WIKI_DIR.rglob("*.md")) + skipped_raw = 0 + for md_file in md_files: + rel = md_file.relative_to(WIKI_DIR) + if rel.parts[0] == "raw": + skipped_raw += 1 + continue + slug = slugify(md_file) + self.slug_to_path[slug] = slug + self.slug_to_path[md_file.stem] = slug # for [[agent-hermes]] lookups + # Also index by basename without .md + base = md_file.name[:-3] + if base not in self.slug_to_path: + self.slug_to_path[base] = slug + + log.info(f"Found {len(self.slug_to_path)} slugs ({skipped_raw} skipped in raw/)") + + # Render each page + for md_file in md_files: + rel = md_file.relative_to(WIKI_DIR) + if rel.parts[0] == "raw": + continue + meta = render_page(md_file, self.slug_to_path, self.slug_meta_list) + self.slug_meta_list.append(meta) + write_page(meta, SITE_DIR) + + log.info(f"Rendered {len(self.slug_meta_list)} HTML pages") + + # Build index data + self.index_data = build_index(self.slug_meta_list) + write_index_files(self.index_data, SITE_DIR) + write_data_js(self.slug_meta_list, self.index_data, SITE_DIR) + write_assets(SITE_DIR) + log.info("Index files written: tree.json, graph.json, tags.json, backlinks.json, data.js") + + def regenerate_one(self, md_path: Path): + """Re-render one page (and its reverse-link targets if needed).""" + rel = md_path.relative_to(WIKI_DIR) + if rel.parts[0] == "raw": + return + with self.lock: + slug = slugify(md_path) + meta = render_page(md_path, self.slug_to_path, self.slug_meta_list) + # Update or append + for i, existing in enumerate(self.slug_meta_list): + if existing["slug"] == slug: + self.slug_meta_list[i] = meta + break + else: + self.slug_meta_list.append(meta) + write_page(meta, SITE_DIR) + log.info(f"Re-rendered: {slug}") + # Rebuild index (cheap for small wikis, ensures backlinks/tags stay consistent) + self.index_data = build_index(self.slug_meta_list) + write_index_files(self.index_data, SITE_DIR) + write_data_js(self.slug_meta_list, self.index_data, SITE_DIR) + + +class WikiFileWatcher(FileSystemEventHandler): + def __init__(self, state: WikiState): + self.state = state + + def on_modified(self, event: FileSystemEvent): + if event.is_directory: + return + path = Path(event.src_path) + if path.suffix == ".md": + log.info(f"File modified: {path}") + self.state.regenerate_one(path) + + def on_created(self, event: FileSystemEvent): + if event.is_directory: + return + path = Path(event.src_path) + if path.suffix == ".md": + log.info(f"File created: {path}") + self.state.regenerate_one(path) + + def on_deleted(self, event: FileSystemEvent): + if event.is_directory: + return + path = Path(event.src_path) + if path.suffix == ".md": + log.info(f"File deleted: {path} (TODO: remove HTML)") + + +# ============================================================ +# HTTP server +# ============================================================ + +class WikiHandler(SimpleHTTPRequestHandler): + """Serves from SITE_DIR. Logs requests briefly.""" + + def log_message(self, format, *args): + log.debug(f"HTTP {self.address_string()} {format % args}") + + def end_headers(self): + # Cache headers for static assets + if self.path.startswith("/__/"): + self.send_header("Cache-Control", "public, max-age=300") + super().end_headers() + + +def start_server(): + """Run HTTP server on BIND:PORT serving from SITE_DIR.""" + os.chdir(SITE_DIR) + server = HTTPServer((BIND, PORT), WikiHandler) + log.info(f"HTTP server: http://{BIND}:{PORT} serving {SITE_DIR}") + server.serve_forever() + + +# ============================================================ +# Daemon mode (PID file, signals) +# ============================================================ + +def write_pid(): + PID_FILE.write_text(str(os.getpid())) + + +def is_running(): + if not PID_FILE.exists(): + return False + try: + pid = int(PID_FILE.read_text()) + os.kill(pid, 0) # check if alive + return True + except (ValueError, ProcessLookupError, PermissionError): + return False + + +def stop_daemon(): + if not PID_FILE.exists(): + log.info("No PID file — daemon not running") + return + pid = int(PID_FILE.read_text()) + try: + os.kill(pid, signal.SIGTERM) + log.info(f"Sent SIGTERM to PID {pid}") + except ProcessLookupError: + log.info(f"PID {pid} not found — already stopped?") + PID_FILE.unlink(missing_ok=True) + + +# ============================================================ +# Main entry point +# ============================================================ + +def main(): + parser = argparse.ArgumentParser(description="Hermes Wiki Static Generator") + parser.add_argument("--start", action="store_true", help="Run as daemon (watcher + HTTP server)") + parser.add_argument("--stop", action="store_true", help="Stop daemon") + parser.add_argument("--status", action="store_true", help="Show status") + parser.add_argument("--once", action="store_true", help="Generate once and exit (no watcher)") + parser.add_argument("--foreground", action="store_true", help="Run in foreground (default: --start)") + args = parser.parse_args() + + if args.stop: + stop_daemon() + return + + if args.status: + if is_running(): + pid = int(PID_FILE.read_text()) + print(f"Running: PID {pid}") + else: + print(f"Not running (no PID file: {PID_FILE})") + return + + if args.once: + state = WikiState() + state.regenerate_all() + print(f"Generated {len(state.slug_meta_list)} pages to {SITE_DIR}") + return + + # Daemon mode + if is_running(): + log.error(f"Already running (PID {int(PID_FILE.read_text())}). Use --stop first.") + sys.exit(1) + + # Initial full regen + state = WikiState() + state.regenerate_all() + + # Start file watcher + observer = Observer() + observer.schedule(WikiFileWatcher(state), str(WIKI_DIR), recursive=True) + observer.start() + log.info(f"Filesystem watcher started on {WIKI_DIR}") + + # Start HTTP server in background thread + server_thread = Thread(target=start_server, daemon=True) + server_thread.start() + + # Write PID + write_pid() + log.info(f"Daemon PID: {os.getpid()}") + + # Handle signals + def handle_term(signum, frame): + log.info(f"Received signal {signum}, shutting down") + observer.stop() + PID_FILE.unlink(missing_ok=True) + sys.exit(0) + + signal.signal(signal.SIGTERM, handle_term) + signal.signal(signal.SIGINT, handle_term) + + # Keep main thread alive + try: + while True: + time.sleep(60) + except KeyboardInterrupt: + handle_term(0, None) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/hermes-custom.css b/hermes-custom.css deleted file mode 100644 index 545eb34..0000000 --- a/hermes-custom.css +++ /dev/null @@ -1 +0,0 @@ -/* placeholder */ diff --git a/hermes-custom.css.lukas b/hermes-custom.css.lukas deleted file mode 100644 index b0d46ab..0000000 --- a/hermes-custom.css.lukas +++ /dev/null @@ -1,249 +0,0 @@ -/* HermesCustom CSS — Lukas' responsive-layout + Mobile-Toggle-Pattern. - * - * Loaded via in vault.html. - * Applied at first paint (no FOUC). - * - * LAYOUT PHILOSOPHY (v0.5): - * - Desktop ≥1025px: 3-panel layout, tree visible, graph visible - * - Tablet 600-1024px: 2-panel compact, tree visible OR graph visible - * via "Show Graph" button toggle (starts hidden) - * - Mobile <600px: single column, tree as hamburger, graph as FAB → modal - * - * The key UX change vs v0.4: Graph is NEVER in bottom panel on Mobile - * (waste of vertical space). It's a toggle button → full-screen modal. - */ - -/* === Always-on === */ -:root { - /* iOS safe-area inset variables for cross-browser usage */ - --sat: env(safe-area-inset-top, 0px); - --sab: env(safe-area-inset-bottom, 0px); - --sal: env(safe-area-inset-left, 0px); - --sar: env(safe-area-inset-right, 0px); -} - -html, body { - /* Prevent iOS from auto-shrinking — keep our viewport stable */ - height: 100%; - overflow: hidden; - box-sizing: border-box; - margin: 0; - padding: 0; -} - -.vault-app.hermes-rc, -.vault-app.hermes-rc * { - box-sizing: border-box !important; -} -.vault-app.hermes-rc { - /* Use dvh (dynamic viewport height) with vh fallback. dvh excludes iOS - URL-bar from the 100vh, so the app fills the visible area. - Padding-top pushes the grid below the iOS status bar / Dynamic Island. */ - width: 100% !important; - max-width: 100vw !important; - height: 100vh !important; /* fallback */ - height: 100dvh !important; /* modern browsers (incl. iOS 16+) */ - padding-top: var(--sat) !important; - padding-left: var(--sal) !important; - padding-right: var(--sar) !important; - padding-bottom: var(--sab) !important; - overflow: hidden !important; - overflow-x: hidden !important; -} -.vault-app.hermes-rc .vault-content { - overflow-x: hidden !important; - overflow-y: auto !important; - max-width: 100% !important; - word-wrap: break-word !important; - overflow-wrap: break-word !important; - hyphens: auto !important; - min-width: 0 !important; -} -/* Critical: prevent pre/code blocks from overflowing horizontally */ -.vault-app.hermes-rc .vault-content pre, -.vault-app.hermes-rc .vault-content code { - white-space: pre-wrap !important; - word-break: break-word !important; - max-width: 100% !important; - overflow-x: auto !important; -} -.vault-app.hermes-rc .vault-content table { - display: block !important; - overflow-x: auto !important; -} -.vault-app.hermes-rc .vault-content img { - max-width: 100% !important; - height: auto !important; -} -.vault-app.hermes-rc .vault-sidebar { - overflow-x: hidden !important; - overflow-y: auto !important; -} -.vault-app.hermes-rc .vault-graph { - position: relative !important; - overflow: hidden !important; -} - -/* === HermescCustom UI Elements === */ -.hermes-ui-btn { - background: var(--bg-secondary); - border: 1px solid var(--border); - color: var(--text-secondary); - padding: 6px 12px; - border-radius: 4px; - cursor: pointer; - font-size: 14px; - line-height: 1; - margin: 0 4px; - transition: color 0.15s, border-color 0.15s; -} -.hermes-ui-btn:hover { - color: var(--accent); - border-color: var(--accent); -} - -/* Hamburger button (hidden by default, shown when tree hidden) */ -.hermes-tree-toggle, -.hermes-graph-toggle { - display: none; -} -.hermes-tree-toggle svg, -.hermes-graph-toggle svg { - width: 18px; - height: 18px; - vertical-align: middle; -} - -/* Graph Modal (mobile graph fullscreen overlay) */ -.hermes-graph-modal { - display: none; - position: fixed; - top: 48px; - left: 0; - right: 0; - bottom: 0; - background: var(--bg-primary); - z-index: 2000; - flex-direction: column; -} -.hermes-graph-modal.hermes-modal-open { - display: flex; -} -.hermes-graph-modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 16px; - background: var(--bg-secondary); - border-bottom: 1px solid var(--border); - height: 44px; - flex-shrink: 0; -} -.hermes-graph-modal-title { - color: var(--accent); - font-size: 13px; - font-weight: 700; - letter-spacing: 0.5px; -} -.hermes-graph-modal-close { - background: none; - border: 1px solid var(--border); - color: var(--text-secondary); - padding: 4px 12px; - border-radius: 4px; - cursor: pointer; - font-size: 14px; -} -.hermes-graph-modal-close:hover { - color: var(--accent); - border-color: var(--accent); -} -.hermes-graph-modal-canvas { - flex: 1; - position: relative; - overflow: hidden; -} -.hermes-graph-modal-canvas canvas { - display: block; - width: 100% !important; - height: 100% !important; -} - -/* Sidebar backdrop (click-outside-to-close) */ -.hermes-sidebar-backdrop { - display: none; - position: fixed; - top: 48px; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.5); - z-index: 900; -} -.hermes-backdrop-visible { - display: block; -} - -/* === Compact Mode (≤1024px) — used for Tablet + Mobile === */ -@media (max-width: 1024px) { - .vault-app.hermes-rc.hermes-compact { - grid-template-columns: 220px 1fr !important; - grid-template-rows: 48px 1fr !important; - height: 100vh !important; - } - .vault-app.hermes-rc.hermes-compact .vault-sidebar { - width: 220px !important; - max-width: 220px !important; - } - .vault-app.hermes-rc.hermes-compact .vault-graph { - display: none !important; /* Hidden by default on compact */ - } - /* Show tree-toggle in compact (tree is shown by default; toggle if user wants to hide) */ - .vault-app.hermes-rc.hermes-compact .hermes-tree-toggle { - display: inline-block !important; - } - /* Show graph-toggle in compact */ - .vault-app.hermes-rc.hermes-compact .hermes-graph-toggle { - display: inline-block !important; - } -} - -/* === Mobile Mode (≤600px) — Full mobile UX === */ -@media (max-width: 600px) { - .vault-app.hermes-rc.hermes-mobile { - grid-template-columns: 1fr !important; - grid-template-rows: 48px 1fr !important; - } - .vault-app.hermes-rc.hermes-mobile .vault-sidebar { - position: fixed !important; - top: 48px !important; - left: 0 !important; - bottom: 0 !important; - width: 85vw !important; - max-width: 320px !important; - transform: translateX(-100%) !important; - transition: transform 0.25s ease-in-out !important; - z-index: 1000 !important; - background: var(--bg-secondary) !important; - border-right: 1px solid var(--border) !important; - display: block !important; - height: calc(100vh - 48px) !important; - } - .vault-app.hermes-rc.hermes-mobile .vault-sidebar.hermes-sidebar-open { - transform: translateX(0) !important; - } - .vault-app.hermes-rc.hermes-mobile .vault-content { - grid-column: 1 !important; - grid-row: 2 !important; - padding: 14px 16px !important; - max-width: 100% !important; - } -} - -/* === Desktop (>=1025px) — default obv layout, no overrides === */ -@media (min-width: 1025px) { - .vault-app.hermes-rc.hermes-desktop .hermes-tree-toggle, - .vault-app.hermes-rc.hermes-desktop .hermes-graph-toggle { - display: none !important; - } -} diff --git a/requirements.txt b/requirements.txt index a8bf39f..73e3be6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -# Obsidian Web Viewer — no required external dependencies -# Python 3.8+ standard library only -# Optional: PyYAML for frontmatter parsing -PyYAML>=6.0 +markdown>=3.10 +pyyaml>=6.0 +watchdog>=6.0 +python-frontmatter>=1.3 diff --git a/server.py b/server.py deleted file mode 100644 index a171ad5..0000000 --- a/server.py +++ /dev/null @@ -1,293 +0,0 @@ -""" -Obsidian Web Viewer — Browse Your Vault in the Browser -======================================================== -Web-based Obsidian vault browser with 3D graph, file tree, markdown -rendering, wikilink navigation, and full-text search. - -Usage: - python server.py --vault /path/to/your/vault - python server.py --vault ~/Documents/MyVault --port 8080 -""" -import sys -import os -import json -import re -import argparse -import time -import threading -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path -from datetime import datetime -from urllib.parse import unquote, quote - -if sys.platform == 'win32': - try: - sys.stdout.reconfigure(encoding='utf-8') - except Exception: - pass - -ROOT = Path(__file__).parent -VAULT_DIR = None # Set via --vault CLI arg - -# YAML parsing (optional) -_YAML_AVAILABLE = False -try: - import yaml - _YAML_AVAILABLE = True -except ImportError: - pass - -MIME = { - ".html": "text/html; charset=utf-8", - ".css": "text/css", - ".js": "application/javascript", - ".json": "application/json", - ".png": "image/png", - ".jpg": "image/jpeg", - ".svg": "image/svg+xml", -} - -# Cache for vault tree and graph -_cache = {"tree": None, "graph": None, "ts": 0} -_CACHE_TTL = 300 - - -def _build_tree(vault_dir: Path) -> dict: - """Build a JSON file tree of the vault directory.""" - now = time.time() - if _cache["tree"] and (now - _cache["ts"]) < _CACHE_TTL: - return _cache["tree"] - - def _walk(d: Path) -> dict: - node = {"type": "folder", "name": d.name, "children": []} - try: - entries = sorted(d.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())) - except PermissionError: - return node - for entry in entries: - if entry.name.startswith('.') or entry.name.startswith('_'): - continue - if entry.is_dir(): - child = _walk(entry) - if child["children"]: - node["children"].append(child) - elif entry.suffix.lower() == '.md': - node["children"].append({"type": "file", "name": entry.name}) - return node - - if not vault_dir.exists(): - return {"type": "folder", "name": "", "children": []} - tree = _walk(vault_dir) - tree["name"] = "" - _cache["tree"] = tree - _cache["ts"] = time.time() - return tree - - -def _parse_frontmatter(raw: str) -> tuple: - """Parse YAML frontmatter from markdown. Returns (dict, body_str).""" - if not raw.startswith('---'): - return {}, raw - end = raw.find('---', 3) - if end == -1: - return {}, raw - fm_text = raw[3:end].strip() - body = raw[end + 3:].strip() - fm = {} - if _YAML_AVAILABLE: - try: - fm = yaml.safe_load(fm_text) or {} - if not isinstance(fm, dict): - fm = {"value": fm} - except Exception: - fm = {} - else: - for line in fm_text.splitlines(): - if ':' in line: - k, _, v = line.partition(':') - v = v.strip().strip('"').strip("'") - if v.startswith('[') and v.endswith(']'): - v = [x.strip().strip('"').strip("'") for x in v[1:-1].split(',') if x.strip()] - fm[k.strip()] = v - return fm, body - - -def _search(vault_dir: Path, query: str, max_results: int = 30) -> list: - """Search vault files by name and content.""" - results = [] - query_lower = query.lower() - for md_file in vault_dir.rglob("*.md"): - if md_file.name.startswith('.') or md_file.name.startswith('_'): - continue - try: - rel = md_file.relative_to(vault_dir).as_posix() - except ValueError: - continue - name = md_file.stem - name_match = query_lower in name.lower() - snippet = "" - content_match = False - if not name_match: - try: - text = md_file.read_text(encoding="utf-8", errors="ignore")[:5000] - idx = text.lower().find(query_lower) - if idx >= 0: - content_match = True - start = max(0, idx - 40) - end = min(len(text), idx + len(query) + 60) - snippet = ("..." if start > 0 else "") + text[start:end].replace("\n", " ") + ("..." if end < len(text) else "") - except Exception: - continue - if name_match or content_match: - results.append({ - "path": rel, "name": name, - "folder": str(md_file.parent.relative_to(vault_dir)) if md_file.parent != vault_dir else "", - "snippet": snippet, "name_match": name_match, - }) - if len(results) >= max_results: - break - results.sort(key=lambda r: (not r["name_match"], r["name"].lower())) - return results - - -def _build_graph(vault_dir: Path) -> dict: - """Build a graph of wikilink connections between vault notes.""" - if _cache["graph"] and (time.time() - _cache["ts"]) < _CACHE_TTL: - return _cache["graph"] - - wikilink_re = re.compile(r'\[\[([^\]|]+?)(?:\|[^\]]+?)?\]\]') - nodes = [] - edges = [] - name_to_path = {} - all_paths = [] - - for md_file in vault_dir.rglob("*.md"): - if md_file.name.startswith('.') or md_file.name.startswith('_'): - continue - try: - rel = md_file.relative_to(vault_dir).as_posix() - except ValueError: - continue - all_paths.append((rel, md_file)) - name_to_path[md_file.stem.lower()] = rel - - for rel, md_file in all_paths: - try: - text = md_file.read_text(encoding="utf-8", errors="ignore") - except Exception: - text = "" - - # Classify by folder for coloring - rp = rel.lower() - node_type = "other" - for folder in ["wiki", "reference", "daily", "journal", "notes", "projects"]: - if rp.startswith(folder + "/"): - node_type = folder - break - - nodes.append({"id": rel, "label": md_file.stem, "type": node_type}) - links = wikilink_re.findall(text) - seen = set() - for target in links: - resolved = name_to_path.get(target.strip().lower()) - if resolved and resolved != rel and resolved not in seen: - edges.append({"source": rel, "target": resolved}) - seen.add(resolved) - - result = {"nodes": nodes, "edges": edges} - _cache["graph"] = result - return result - - -class VaultHandler(BaseHTTPRequestHandler): - def log_message(self, *a): pass - - def do_GET(self): - path = self.path.split("?")[0].rstrip("/") - query_string = self.path.split("?")[1] if "?" in self.path else "" - - if path == "" or path == "/" or path == "/vault": - self._serve_file(ROOT / "vault.html") - elif path == "/api/vault/tree": - self._json(_build_tree(VAULT_DIR)) - elif path == "/api/vault/graph": - self._json(_build_graph(VAULT_DIR)) - elif path == "/api/vault/search": - import urllib.parse - params = urllib.parse.parse_qs(query_string) - q = params.get("q", [""])[0] - self._json({"results": _search(VAULT_DIR, q) if q else []}) - elif path.startswith("/api/vault/file/"): - rel = unquote(path[len("/api/vault/file/"):]) - file_path = VAULT_DIR / rel - if not file_path.exists() or not str(file_path).startswith(str(VAULT_DIR)): - self._json({"error": "Not found"}, 404) - return - try: - raw = file_path.read_text(encoding="utf-8") - fm, body = _parse_frontmatter(raw) - self._json({"path": rel, "name": file_path.stem, "frontmatter": fm, "body": body}) - except Exception as e: - self._json({"error": str(e)}, 500) - elif path == "/config.json": - self._serve_file(ROOT / "config.json") - else: - f = ROOT / path.lstrip("/") - if f.exists() and f.is_file(): - self._serve_file(f) - else: - self.send_error(404) - - def _serve_file(self, path): - if not path.exists(): - self.send_error(404); return - data = path.read_bytes() - mime = MIME.get(path.suffix.lower(), "application/octet-stream") - self.send_response(200) - self.send_header("Content-Type", mime) - self.send_header("Content-Length", str(len(data))) - self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Cache-Control", "no-cache") - self.end_headers() - self.wfile.write(data) - - def _json(self, obj, code=200): - data = json.dumps(obj, indent=2, default=str).encode() - self.send_response(code) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(data))) - self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Cache-Control", "no-cache") - self.end_headers() - self.wfile.write(data) - - -def main(): - parser = argparse.ArgumentParser(description="Obsidian Web Viewer") - parser.add_argument("--vault", required=True, help="Path to your Obsidian vault folder") - parser.add_argument("--port", type=int, default=8765) - parser.add_argument("--host", default="0.0.0.0") - args = parser.parse_args() - - global VAULT_DIR - VAULT_DIR = Path(args.vault).resolve() - - if not VAULT_DIR.exists(): - print(f"ERROR: Vault path does not exist: {VAULT_DIR}") - sys.exit(1) - - md_count = sum(1 for _ in VAULT_DIR.rglob("*.md")) - print(f"Obsidian Web Viewer") - print(f" Vault: {VAULT_DIR}") - print(f" Notes: {md_count}") - print(f" URL: http://{args.host}:{args.port}") - - server = ThreadingHTTPServer((args.host, args.port), VaultHandler) - try: - server.serve_forever() - except KeyboardInterrupt: - print("\nShutting down.") - - -if __name__ == "__main__": - main() diff --git a/server.py.lukas b/server.py.lukas deleted file mode 100644 index d2f3c26..0000000 --- a/server.py.lukas +++ /dev/null @@ -1,302 +0,0 @@ -""" -Obsidian Web Viewer — Browse Your Vault in the Browser -======================================================== -Web-based Obsidian vault browser with 3D graph, file tree, markdown -rendering, wikilink navigation, and full-text search. - -Usage: - python server.py --vault /path/to/your/vault - python server.py --vault ~/Documents/MyVault --port 8080 -""" -import sys -import os -import json -import re -import argparse -import time -import threading -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path -from datetime import datetime -from urllib.parse import unquote, quote - -if sys.platform == 'win32': - try: - sys.stdout.reconfigure(encoding='utf-8') - except Exception: - pass - -ROOT = Path(__file__).parent -VAULT_DIR = None # Set via --vault CLI arg - -# YAML parsing (optional) -_YAML_AVAILABLE = False -try: - import yaml - _YAML_AVAILABLE = True -except ImportError: - pass - -MIME = { - ".html": "text/html; charset=utf-8", - ".css": "text/css", - ".js": "application/javascript", - ".json": "application/json", - ".png": "image/png", - ".jpg": "image/jpeg", - ".svg": "image/svg+xml", -} - -# Cache for vault tree and graph -_cache = {"tree": None, "graph": None, "ts": 0} -_CACHE_TTL = 300 - - -def _build_tree(vault_dir: Path) -> dict: - """Build a JSON file tree of the vault directory.""" - now = time.time() - if _cache["tree"] and (now - _cache["ts"]) < _CACHE_TTL: - return _cache["tree"] - - def _walk(d: Path) -> dict: - node = {"type": "folder", "name": d.name, "children": []} - try: - entries = sorted(d.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())) - except PermissionError: - return node - for entry in entries: - if entry.name.startswith('.') or entry.name.startswith('_'): - continue - if entry.is_dir(): - child = _walk(entry) - if child["children"]: - node["children"].append(child) - elif entry.suffix.lower() == '.md': - node["children"].append({"type": "file", "name": entry.name}) - return node - - if not vault_dir.exists(): - return {"type": "folder", "name": "", "children": []} - tree = _walk(vault_dir) - tree["name"] = "" - _cache["tree"] = tree - _cache["ts"] = time.time() - return tree - - -def _parse_frontmatter(raw: str) -> tuple: - """Parse YAML frontmatter from markdown. Returns (dict, body_str).""" - if not raw.startswith('---'): - return {}, raw - end = raw.find('---', 3) - if end == -1: - return {}, raw - fm_text = raw[3:end].strip() - body = raw[end + 3:].strip() - fm = {} - if _YAML_AVAILABLE: - try: - fm = yaml.safe_load(fm_text) or {} - if not isinstance(fm, dict): - fm = {"value": fm} - except Exception: - fm = {} - else: - for line in fm_text.splitlines(): - if ':' in line: - k, _, v = line.partition(':') - v = v.strip().strip('"').strip("'") - if v.startswith('[') and v.endswith(']'): - v = [x.strip().strip('"').strip("'") for x in v[1:-1].split(',') if x.strip()] - fm[k.strip()] = v - return fm, body - - -def _search(vault_dir: Path, query: str, max_results: int = 30) -> list: - """Search vault files by name and content.""" - results = [] - query_lower = query.lower() - for md_file in vault_dir.rglob("*.md"): - if md_file.name.startswith('.') or md_file.name.startswith('_'): - continue - try: - rel = md_file.relative_to(vault_dir).as_posix() - except ValueError: - continue - name = md_file.stem - name_match = query_lower in name.lower() - snippet = "" - content_match = False - if not name_match: - try: - text = md_file.read_text(encoding="utf-8", errors="ignore")[:5000] - idx = text.lower().find(query_lower) - if idx >= 0: - content_match = True - start = max(0, idx - 40) - end = min(len(text), idx + len(query) + 60) - snippet = ("..." if start > 0 else "") + text[start:end].replace("\n", " ") + ("..." if end < len(text) else "") - except Exception: - continue - if name_match or content_match: - results.append({ - "path": rel, "name": name, - "folder": str(md_file.parent.relative_to(vault_dir)) if md_file.parent != vault_dir else "", - "snippet": snippet, "name_match": name_match, - }) - if len(results) >= max_results: - break - results.sort(key=lambda r: (not r["name_match"], r["name"].lower())) - return results - - -def _build_graph(vault_dir: Path) -> dict: - """Build a graph of wikilink connections between vault notes.""" - if _cache["graph"] and (time.time() - _cache["ts"]) < _CACHE_TTL: - return _cache["graph"] - - wikilink_re = re.compile(r'\[\[([^\]|]+?)(?:\|[^\]]+?)?\]\]') - nodes = [] - edges = [] - name_to_path = {} - all_paths = [] - - for md_file in vault_dir.rglob("*.md"): - if md_file.name.startswith('.') or md_file.name.startswith('_'): - continue - try: - rel = md_file.relative_to(vault_dir).as_posix() - except ValueError: - continue - all_paths.append((rel, md_file)) - name_to_path[md_file.stem.lower()] = rel - - for rel, md_file in all_paths: - try: - text = md_file.read_text(encoding="utf-8", errors="ignore") - except Exception: - text = "" - - # Classify by folder for coloring - rp = rel.lower() - node_type = "other" - for folder in ["wiki", "reference", "daily", "journal", "notes", "projects"]: - if rp.startswith(folder + "/"): - node_type = folder - break - - nodes.append({"id": rel, "label": md_file.stem, "type": node_type}) - links = wikilink_re.findall(text) - seen = set() - for target in links: - resolved = name_to_path.get(target.strip().lower()) - if resolved and resolved != rel and resolved not in seen: - edges.append({"source": rel, "target": resolved}) - seen.add(resolved) - - result = {"nodes": nodes, "edges": edges} - _cache["graph"] = result - return result - - -class VaultHandler(BaseHTTPRequestHandler): - def log_message(self, *a): pass - - def do_GET(self): - path = self.path.split("?")[0].rstrip("/") - query_string = self.path.split("?")[1] if "?" in self.path else "" - - if path == "" or path == "/" or path == "/vault": - self._serve_file(ROOT / "vault.html") - elif path == "/api/vault/tree": - self._json(_build_tree(VAULT_DIR)) - elif path == "/api/vault/graph": - self._json(_build_graph(VAULT_DIR)) - elif path == "/api/vault/search": - import urllib.parse - params = urllib.parse.parse_qs(query_string) - q = params.get("q", [""])[0] - self._json({"results": _search(VAULT_DIR, q) if q else []}) - elif path == "/api/vault/file" or path.startswith("/api/vault/file/"): - if path == "/api/vault/file": - rel = "" - else: - rel = unquote(path[len("/api/vault/file/"):]) - # Lukas-add: also support ?file= query for clean URL navigation - if not rel and query_string: - from urllib.parse import parse_qs - file_param = parse_qs(query_string).get("file", [None])[0] - if file_param: - rel = file_param - file_path = VAULT_DIR / rel - if not file_path.exists() or not str(file_path).startswith(str(VAULT_DIR)): - self._json({"error": "Not found"}, 404) - return - try: - raw = file_path.read_text(encoding="utf-8") - fm, body = _parse_frontmatter(raw) - self._json({"path": rel, "name": file_path.stem, "frontmatter": fm, "body": body}) - except Exception as e: - self._json({"error": str(e)}, 500) - elif path == "/config.json": - self._serve_file(ROOT / "config.json") - else: - f = ROOT / path.lstrip("/") - if f.exists() and f.is_file(): - self._serve_file(f) - else: - self.send_error(404) - - def _serve_file(self, path): - if not path.exists(): - self.send_error(404); return - data = path.read_bytes() - mime = MIME.get(path.suffix.lower(), "application/octet-stream") - self.send_response(200) - self.send_header("Content-Type", mime) - self.send_header("Content-Length", str(len(data))) - self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Cache-Control", "no-cache") - self.end_headers() - self.wfile.write(data) - - def _json(self, obj, code=200): - data = json.dumps(obj, indent=2, default=str).encode() - self.send_response(code) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(data))) - self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Cache-Control", "no-cache") - self.end_headers() - self.wfile.write(data) - - -def main(): - parser = argparse.ArgumentParser(description="Obsidian Web Viewer") - parser.add_argument("--vault", required=True, help="Path to your Obsidian vault folder") - parser.add_argument("--port", type=int, default=8765) - parser.add_argument("--host", default="0.0.0.0") - args = parser.parse_args() - - global VAULT_DIR - VAULT_DIR = Path(args.vault).resolve() - - if not VAULT_DIR.exists(): - print(f"ERROR: Vault path does not exist: {VAULT_DIR}") - sys.exit(1) - - md_count = sum(1 for _ in VAULT_DIR.rglob("*.md")) - print(f"Obsidian Web Viewer") - print(f" Vault: {VAULT_DIR}") - print(f" Notes: {md_count}") - print(f" URL: http://{args.host}:{args.port}") - - server = ThreadingHTTPServer((args.host, args.port), VaultHandler) - try: - server.serve_forever() - except KeyboardInterrupt: - print("\nShutting down.") - - -if __name__ == "__main__": - main() diff --git a/vault-custom.js b/vault-custom.js deleted file mode 100644 index 491a83c..0000000 --- a/vault-custom.js +++ /dev/null @@ -1 +0,0 @@ -/* Hermes Customizations disabled — rollback to v0.1 (upstream obv) */ diff --git a/vault-custom.js.lukas b/vault-custom.js.lukas deleted file mode 100644 index 8ea4b9b..0000000 --- a/vault-custom.js.lukas +++ /dev/null @@ -1,453 +0,0 @@ -/* HermesCustom — Lukas' additions to obsidian-web-viewer - * - * Loaded by vault.html via - - - - - -
-
-

Vault

- - -
- - - -
-
-

Welcome to Vault Viewer

-

Select a note from the file tree or use search.

-
-
- -
- -
-
- -
- - - - diff --git a/vault.html.lukas b/vault.html.lukas deleted file mode 100644 index 9e2bc8f..0000000 --- a/vault.html.lukas +++ /dev/null @@ -1,355 +0,0 @@ - - - - - -Vault Viewer - - - - - - - -
-
-

Vault

- - -
- - - -
-
-

Welcome to Vault Viewer

-

Select a note from the file tree or use search.

-
-
- -
- -
-
- -
- - - - -