Initial commit: hermes-wiki-viewer v0.2.0
Fork of DanielCheer/obsidian-web-viewer (MIT, 2026-04) with Lukas' first additions for the Hermes Wiki workflow. Additive customization layer (vault-custom.js) — keeps upstream-merge trivial via a one-line script tag in vault.html. Features added: 1. Current-page highlighting in 3D Graph — current node gold+glow, connected nodes light-blue, others dim to 20% opacity 2. Click-to-navigate on graph — Three.js raycaster triggers file load 3. Responsive layout — graph collapses below 1024px, tree below 768px 4. URL-based deep-linking via ?file=<path> query param 5. Server-side ?file= support in /api/vault/file/ endpoint Modified files: - server.py: +7 lines (Lukas-add: ?file= query param parsing) - vault.html: +1 line (script tag for vault-custom.js) New files: - vault-custom.js: 11KB, all customizations in one place under HermesCustom namespace - README.md: fork intro, quick start, customization guide - CHANGELOG.md: Lukas-additions tracking - docs/ARCHITECTURE.md: design rationale - docs/CUSTOMIZATIONS.md: feature spec - .gitignore: standard 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
"""
|
||||
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/"):])
|
||||
# Lukas-add: also support ?file=<path> query for clean URL navigation
|
||||
if "?" in rel:
|
||||
rel, _, query = rel.partition("?")
|
||||
from urllib.parse import parse_qs
|
||||
file_param = parse_qs(query).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()
|
||||
Reference in New Issue
Block a user