Generator-Skeleton (Phase 1): static HTML + watchdog + HTTP server

Replaces obv-Fork. New architecture:
- Python watchdog observes /home/admin/my-karpathy-wiki/
- On .md change → regenerate single HTML + update index
- On startup → full regen + tree.json/graph.json/tags.json/backlinks.json
- HTTP server on 127.0.0.1:8765 + tailscale proxy
- 265 static HTML pages with full Markdown rendering
- WikiLink resolution: [[entity]] → <a href='/path/entity.html'>
- Frontmatter as styled card (type, status, updated, sources)
- TOC auto-generated via markdown.extensions.toc
- PWA manifest for native-feeling install
- data.js with all metadata for offline use

Touch-first design (CSS+JS for tree/graph/search) follows in Phase 2.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-19 17:16:02 +00:00
co-authored by Claude
parent e57b0bc784
commit b5631986e6
11 changed files with 841 additions and 2148 deletions
+92 -92
View File
@@ -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 ## Why this exists
- **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
## 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) - **No touch support on iOS Safari** — click handlers don't work reliably
- Markdown rendering with code blocks, tables, blockquotes, images - **3D-Graph auto-rotates** — prevents node selection via tap
- Wikilink navigation (`[[links]]` click-to-traverse) - **Fixed 260px Graph column** — wastes horizontal space
- YAML frontmatter rendered as styled card
- Full-text search by note name and content with snippets This tool replaces obv with our own renderer:
- 3D graph visualization (Three.js) showing note connections
- Catppuccin-inspired dark theme - **Touch-first design** — bottom-nav on mobile, hamburger-tree, FAB+modal graph
- Zero client-side setup — anyone with the URL can browse - **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 ## 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 ```bash
# Clone # Install deps (one-time, system Python)
git clone https://github.com/LukasHuber/hermes-wiki-viewer.git pip install --break-system-packages markdown pyyaml watchdog python-frontmatter
cd hermes-wiki-viewer
# Optional: PyYAML for frontmatter # Start daemon
pip install -r requirements.txt python3 generator.py --start
# Point to your vault # Status / Stop
python3 server.py --vault /path/to/your/vault --host 127.0.0.1 --port 8765 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 site/
# Access via https://<hostname>.<tailnet>.ts.net/ ├── index.html (redirect to first note)
├── concepts/<slug>.html (one file per .md)
├── entities/<slug>.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
`<a class="wikilink" href="/path/to/entity.html">` if the target exists,
otherwise `<a class="wikilink-missing">` (greyed out).
```bash ## Frontmatter
# 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
<!-- Add before </body>: -->
<script src="vault-custom.js"></script>
```
## 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).
```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 ## Known limitations (Phase 1)
Hermes Wiki Viewer fork © 2026 Lukas Huber
- 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
+745
View File
@@ -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"(?<![\w/])#([a-zA-Z][a-z0-9-]{1,30})")
# Markdown extensions: TOC, tables, fenced code, footnotes, attr lists
MD_EXTENSIONS = [
"toc",
"tables",
"fenced_code",
"footnotes",
"attr_list",
"def_list",
"sane_lists",
]
def slugify(path: Path) -> 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 <a href="/path/entity.html">entity-name</a>."""
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'<a class="wikilink" href="/{quote(slug)}.html">{display}</a>'
# 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'<a class="wikilink" href="/{quote(slug)}.html">{display}</a>'
# Not found — render as broken link
return f'<a class="wikilink wikilink-missing" href="/__missing.html?target={quote(target)}">{display}</a>'
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"<h1[^>]*>(.*?)</h1>", 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 = """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0a0a14">
<title>{title} · Hermes Wiki</title>
<link rel="stylesheet" href="/__/style.css">
<link rel="manifest" href="/__/manifest.json">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📓</text></svg>">
</head>
<body class="page-{kind}">
<div class="app-shell">
<header class="app-header">
<button class="icon-btn menu-toggle" aria-label="Toggle tree" data-action="toggle-tree">☰</button>
<a href="/__/index.html" class="brand">📓 Hermes Wiki</a>
<input type="search" id="search" placeholder="Suchen…" aria-label="Search notes">
<button class="icon-btn graph-toggle" aria-label="Toggle graph" data-action="toggle-graph">⊕</button>
</header>
<aside class="app-tree" id="tree" aria-label="File tree"></aside>
<main class="app-content">
<article class="note">
{frontmatter_card}
<div class="note-body">{body}</div>
<nav class="note-toc" id="toc" aria-label="Table of contents"></nav>
<section class="note-backlinks" id="backlinks" aria-label="Backlinks"></section>
<footer class="note-footer">
<span class="note-path">{path}</span>
</footer>
</article>
</main>
<aside class="app-graph" id="graph" aria-label="3D graph"></aside>
<nav class="app-bottom-nav" aria-label="Bottom navigation">
<button data-action="toggle-tree">☰<br><small>Files</small></button>
<button data-action="focus-search">🔍<br><small>Search</small></button>
<button data-action="toggle-graph">⊕<br><small>Graph</small></button>
</nav>
</div>
<script src="/__/data.js"></script>
<script src="/__/app.js"></script>
</body>
</html>"""
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'<div class="meta-row"><span class="meta-key">{key}</span><span class="meta-value">{value}</span></div>')
if not rows:
return ""
return f'<aside class="meta-card">{"".join(rows)}</aside>'
def write_page(meta: dict, site_dir: Path) -> None:
"""Write a single HTML page to SITE_DIR/<slug>.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,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📓</text></svg>",
"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 = """<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>Hermes Wiki</title>
<link rel="stylesheet" href="/__/style.css">
<meta http-equiv="refresh" content="0; url=/{slug}.html"></head>
<body><a href="/{slug}.html">Open Wiki</a></body></html>"""
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()
-1
View File
@@ -1 +0,0 @@
/* placeholder */
-249
View File
@@ -1,249 +0,0 @@
/* HermesCustom CSS — Lukas' responsive-layout + Mobile-Toggle-Pattern.
*
* Loaded via <link rel="stylesheet"> 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;
}
}
+4 -4
View File
@@ -1,4 +1,4 @@
# Obsidian Web Viewer — no required external dependencies markdown>=3.10
# Python 3.8+ standard library only pyyaml>=6.0
# Optional: PyYAML for frontmatter parsing watchdog>=6.0
PyYAML>=6.0 python-frontmatter>=1.3
-293
View File
@@ -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()
-302
View File
@@ -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=<path> 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()
-1
View File
@@ -1 +0,0 @@
/* Hermes Customizations disabled — rollback to v0.1 (upstream obv) */
-453
View File
@@ -1,453 +0,0 @@
/* HermesCustom — Lukas' additions to obsidian-web-viewer
*
* Loaded by vault.html via <script> tag (one-line patch).
*
* v0.5 — Mobile/Responsive overhaul:
* - Graph is NEVER a permanent bottom panel (waste of vertical space on phones)
* - Graph shown via full-screen modal toggle on Mobile + Tablet
* - Tree as hamburger on Mobile, full sidebar on Tablet
* - Desktop unchanged
*
* Companion: hermes-custom.css
*
* Layout decisions per viewport:
* Desktop (>=1025px): 3-panel, obv default, both toggles hidden
* Compact (601-1024px): 2-panel (tree+content), graph hidden by default,
* "Show Graph" button visible, "Tree" toggle visible
* Mobile (<=600px): 1-panel (content only), tree hamburger, graph modal
*/
(function() {
'use strict';
window.HermesCustom = window.HermesCustom || {};
const HermesCustom = window.HermesCustom;
// ============================================================
// Config
// ============================================================
const BREAKPOINTS = {
mobile: 600,
compact: 1024,
};
// ============================================================
// 1. UI elements (injected into header)
// ============================================================
/**
* Inject toggle buttons into header.
* - Tree toggle: only visible when tree is hidden (Compact mode hides tree by default)
* - Graph toggle: only visible when graph is hidden (Compact + Mobile)
* - Search input is already in obv's header; we don't duplicate
*/
HermesCustom.injectUI = function() {
const header = document.querySelector('.vault-header');
if (!header) {
setTimeout(HermesCustom.injectUI, 100);
return;
}
// Graph toggle button (top-right of header, before search)
if (!document.querySelector('.hermes-graph-toggle')) {
const graphBtn = document.createElement('button');
graphBtn.className = 'hermes-graph-toggle hermes-ui-btn';
graphBtn.setAttribute('aria-label', 'Toggle 3D graph');
graphBtn.innerHTML = '⊕ Graph';
graphBtn.onclick = () => HermesCustom.toggleGraphModal();
header.insertBefore(graphBtn, header.querySelector('.search-box') || header.lastChild);
}
// Tree toggle button (left-most in header)
if (!document.querySelector('.hermes-tree-toggle')) {
const treeBtn = document.createElement('button');
treeBtn.className = 'hermes-tree-toggle hermes-ui-btn';
treeBtn.setAttribute('aria-label', 'Toggle file tree');
treeBtn.innerHTML = '☰';
treeBtn.onclick = () => HermesCustom.toggleTreeSidebar();
header.insertBefore(treeBtn, header.firstChild);
}
// Backdrop for tree sidebar
if (!document.querySelector('.hermes-sidebar-backdrop')) {
const bd = document.createElement('div');
bd.className = 'hermes-sidebar-backdrop';
bd.onclick = () => HermesCustom.closeTreeSidebar();
document.body.appendChild(bd);
}
// Graph modal (hidden by default)
if (!document.querySelector('.hermes-graph-modal')) {
HermesCustom.buildGraphModal();
}
};
/**
* Build the graph modal: full-screen overlay that re-uses the
* existing canvas from obv. Strategy: clone the canvas into our modal,
* or move the existing canvas in/out. Simpler approach: create a NEW
* canvas inside the modal, and re-run the graph render there.
*/
HermesCustom.buildGraphModal = function() {
const modal = document.createElement('div');
modal.className = 'hermes-graph-modal';
const modalHeader = document.createElement('div');
modalHeader.className = 'hermes-graph-modal-header';
modalHeader.innerHTML = `
<span class="hermes-graph-modal-title">GRAPH VIEW · Tap a node to navigate</span>
<button class="hermes-graph-modal-close">× Close</button>
`;
modal.appendChild(modalHeader);
const canvasContainer = document.createElement('div');
canvasContainer.className = 'hermes-graph-modal-canvas';
canvasContainer.id = 'hermes-graph-modal-canvas';
modal.appendChild(canvasContainer);
document.body.appendChild(modal);
modal.querySelector('.hermes-graph-modal-close').onclick = () => {
HermesCustom.closeGraphModal();
};
// ESC to close
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal.classList.contains('hermes-modal-open')) {
HermesCustom.closeGraphModal();
}
});
};
// ============================================================
// 2. Graph modal open/close
// ============================================================
HermesCustom.toggleGraphModal = function() {
const modal = document.querySelector('.hermes-graph-modal');
if (!modal) return;
if (modal.classList.contains('hermes-modal-open')) {
HermesCustom.closeGraphModal();
} else {
HermesCustom.openGraphModal();
}
};
HermesCustom.openGraphModal = function() {
const modal = document.querySelector('.hermes-graph-modal');
const canvasContainer = document.getElementById('hermes-graph-modal-canvas');
if (!modal || !canvasContainer) return;
// Move the existing obv canvas into our modal
const existingCanvas = document.querySelector('.vault-graph canvas');
if (existingCanvas && existingCanvas.parentElement !== canvasContainer) {
// Clone the canvas instead of moving it (keeps obv's state intact)
canvasContainer.innerHTML = '';
const clonedCanvas = document.createElement('canvas');
clonedCanvas.width = window.innerWidth;
clonedCanvas.height = window.innerHeight - 48 - 44; // viewport minus header+modal-header
canvasContainer.appendChild(clonedCanvas);
// Re-render the graph into our cloned canvas by re-running loadGraph logic
HermesCustom.renderGraphIntoCanvas(clonedCanvas);
}
modal.classList.add('hermes-modal-open');
document.body.style.overflow = 'hidden'; // prevent background scroll
console.log('[HermesCustom] Graph modal opened');
};
HermesCustom.closeGraphModal = function() {
const modal = document.querySelector('.hermes-graph-modal');
if (!modal) return;
modal.classList.remove('hermes-modal-open');
document.body.style.overflow = '';
console.log('[HermesCustom] Graph modal closed');
};
/**
* Re-render obv's graph into a different canvas.
* This is a hack: we re-fetch graph data and re-create a Three.js scene
* because obv's `loadGraph()` creates the scene with a specific canvas.
*
* Note: this duplicates Three.js scene. For mobile-only graph viewing,
* this is acceptable (small perf cost, but isolated to user action).
*/
HermesCustom.renderGraphIntoCanvas = function(canvas) {
if (!window.THREE || !HermesCustom._graphData) {
console.warn('[HermesCustom] Three.js or graph data not ready');
return;
}
const ctx = {
scene: new THREE.Scene(),
camera: null,
renderer: null,
nodeObjects: new Map(),
};
const w = canvas.clientWidth || canvas.width;
const h = canvas.clientHeight || canvas.height;
ctx.camera = new THREE.PerspectiveCamera(60, w / h, 1, 5000);
ctx.camera.position.set(0, 0, 800);
ctx.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
ctx.renderer.setSize(w, h);
ctx.renderer.setPixelRatio(window.devicePixelRatio || 1);
// Build nodes
const nodes = HermesCustom._graphData.nodes;
const nodeGeometry = new THREE.SphereGeometry(4, 16, 16);
const edges = HermesCustom._graphData.edges;
nodes.forEach(node => {
const mat = new THREE.MeshBasicMaterial({ color: 0x6c7086 });
const mesh = new THREE.Mesh(nodeGeometry, mat);
mesh.userData.nodeId = node.id;
// Random-ish positions (deterministic based on label hash)
const hash = node.label.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
mesh.position.set(
(hash % 100) * 6 - 300,
((hash * 7) % 100) * 4 - 200,
((hash * 13) % 100) * 4 - 200
);
ctx.scene.add(mesh);
ctx.nodeObjects.set(node.id, mesh);
});
// Build edges
edges.forEach(edge => {
const src = ctx.nodeObjects.get(edge.source);
const tgt = ctx.nodeObjects.get(edge.target);
if (!src || !tgt) return;
const lineGeo = new THREE.BufferGeometry().setFromPoints([
src.position, tgt.position
]);
const line = new THREE.Line(lineGeo, new THREE.LineBasicMaterial({
color: 0x45475a, transparent: true, opacity: 0.6
}));
ctx.scene.add(line);
});
// Slow rotation
function animate() {
requestAnimationFrame(animate);
ctx.scene.rotation.y += 0.001;
ctx.renderer.render(ctx.scene, ctx.camera);
}
animate();
// Click handler — re-use the same navigation logic
HermesCustom.setupClickForCanvas(canvas, ctx.camera, ctx.nodeObjects);
};
HermesCustom.setupClickForCanvas = function(canvas, camera, nodeMap) {
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
canvas.addEventListener('click', (event) => {
const rect = canvas.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(Array.from(nodeMap.values()));
if (intersects.length > 0) {
const nodeId = intersects[0].object.userData.nodeId;
if (typeof window.loadFile === 'function') {
HermesCustom.closeGraphModal();
window.loadFile(nodeId);
window.history.pushState({}, '', '?file=' + encodeURIComponent(nodeId));
}
}
});
};
// ============================================================
// 3. Tree sidebar toggle
// ============================================================
HermesCustom.toggleTreeSidebar = function() {
const sidebar = document.querySelector('.vault-sidebar');
const backdrop = document.querySelector('.hermes-sidebar-backdrop');
if (!sidebar) return;
const opening = !sidebar.classList.contains('hermes-sidebar-open');
sidebar.classList.toggle('hermes-sidebar-open', opening);
if (backdrop) backdrop.classList.toggle('hermes-backdrop-visible', opening);
};
HermesCustom.closeTreeSidebar = function() {
const sidebar = document.querySelector('.vault-sidebar');
const backdrop = document.querySelector('.hermes-sidebar-backdrop');
if (sidebar) sidebar.classList.remove('hermes-sidebar-open');
if (backdrop) backdrop.classList.remove('hermes-backdrop-visible');
};
// ============================================================
// 4. Layout decision
// ============================================================
HermesCustom.applyLayout = function() {
const app = document.querySelector('.vault-app');
if (!app) {
setTimeout(HermesCustom.applyLayout, 100);
return;
}
// Marker class
if (!app.classList.contains('hermes-rc')) {
app.classList.add('hermes-rc');
}
const width = window.innerWidth;
app.classList.remove('hermes-mobile', 'hermes-compact', 'hermes-desktop');
if (width <= BREAKPOINTS.mobile) {
app.classList.add('hermes-mobile');
} else if (width <= BREAKPOINTS.compact) {
app.classList.add('hermes-compact');
} else {
app.classList.add('hermes-desktop');
}
// Auto-close sidebar on resize to desktop
if (width >= BREAKPOINTS.compact) {
HermesCustom.closeTreeSidebar();
}
};
HermesCustom.setupResizeHandler = function() {
let resizeTimer;
let lastWidth = window.innerWidth;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (window.innerWidth !== lastWidth) {
lastWidth = window.innerWidth;
HermesCustom.applyLayout();
}
}, 150);
});
};
// ============================================================
// 5. Current-page highlighting (desktop)
// ============================================================
HermesCustom.getCurrentFile = function() {
const params = new URLSearchParams(window.location.search);
const fileParam = params.get('file');
if (fileParam) return fileParam;
return HermesCustom._lastLoadedFile || null;
};
HermesCustom.getConnectedFiles = function(currentFile) {
if (!currentFile) return [];
const graphData = HermesCustom._graphData;
if (!graphData) return [];
return graphData.edges.filter(e => e.source === currentFile).map(e => e.target);
};
HermesCustom.highlightCurrentNode = function() {
const currentFile = HermesCustom.getCurrentFile();
if (!currentFile) return;
if (typeof window.graphNodeObjects === 'undefined' || !window.graphNodeObjects) {
setTimeout(HermesCustom.highlightCurrentNode, 500);
return;
}
const connected = new Set(HermesCustom.getConnectedFiles(currentFile));
connected.add(currentFile);
window.graphNodeObjects.forEach((mesh, nodeId) => {
const isCurrent = (nodeId === currentFile);
const isConnected = connected.has(nodeId) && !isCurrent;
if (isCurrent) {
mesh.material.color.setHex(0xFFD700);
mesh.material.emissive.setHex(0xFFD700);
mesh.material.emissiveIntensity = 0.6;
mesh.material.opacity = 1.0;
mesh.scale.set(1.5, 1.5, 1.5);
} else if (isConnected) {
mesh.material.color.setHex(0x89b4fa);
mesh.material.emissive.setHex(0x89b4fa);
mesh.material.emissiveIntensity = 0.3;
mesh.material.opacity = 1.0;
mesh.scale.set(1.1, 1.1, 1.1);
} else {
mesh.material.color.setHex(0x6c7086);
mesh.material.emissiveIntensity = 0;
mesh.material.opacity = 0.25;
mesh.material.transparent = true;
}
});
};
// ============================================================
// 6. URL navigation
// ============================================================
HermesCustom.updateURL = function(path) {
const newURL = '?file=' + encodeURIComponent(path);
window.history.pushState({}, '', newURL);
};
// ============================================================
// 7. loadFile hook
// ============================================================
HermesCustom.hookLoadFile = function() {
if (typeof window.loadFile !== 'function') {
setTimeout(HermesCustom.hookLoadFile, 200);
return;
}
if (window.loadFile._hermesPatched) return;
const originalLoadFile = window.loadFile;
window.loadFile = function(path) {
HermesCustom._lastLoadedFile = path;
HermesCustom.updateURL(path);
// Close tree sidebar after file selection (mobile UX)
if (window.innerWidth <= BREAKPOINTS.mobile) {
HermesCustom.closeTreeSidebar();
}
const result = originalLoadFile.apply(this, arguments);
setTimeout(HermesCustom.highlightCurrentNode, 300);
return result;
};
window.loadFile._hermesPatched = true;
};
// ============================================================
// 8. Graph data fetch
// ============================================================
HermesCustom.loadGraphData = function() {
fetch('/api/vault/graph')
.then(r => r.json())
.then(data => {
HermesCustom._graphData = data;
console.log('[HermesCustom] Graph data: ' + data.nodes.length + ' nodes, ' + data.edges.length + ' edges');
HermesCustom.highlightCurrentNode();
})
.catch(err => console.warn('[HermesCustom] Graph fetch failed:', err));
};
// ============================================================
// Init
// ============================================================
HermesCustom.init = function() {
console.log('[HermesCustom] init v5 (mobile-toggle-pattern)');
HermesCustom.injectUI();
HermesCustom.hookLoadFile();
HermesCustom.applyLayout();
HermesCustom.setupResizeHandler();
HermesCustom.loadGraphData();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', HermesCustom.init);
} else {
setTimeout(HermesCustom.init, 50);
}
})();
-398
View File
@@ -1,398 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vault Viewer</title>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
: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;
--green: #a6e3a1; --red: #f38ba8; --blue: #89b4fa; --teal: #94e2d5;
}
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { touch-action: manipulation; }
body { background: var(--bg-primary); color: var(--text-primary); font-family: -apple-system, 'Segoe UI', Inter, sans-serif; height: 100vh; overflow: hidden; }
.vault-app {
display: grid; grid-template-columns: 280px 1fr 260px;
grid-template-rows: 48px 1fr; height: 100vh;
}
.vault-app.graph-hidden { grid-template-columns: 280px 1fr; }
/* Header */
.vault-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;
}
.vault-header h1 { color: var(--accent); font-size: 14px; font-weight: 700; letter-spacing: 1px; }
.vault-header .search-box {
flex: 1; max-width: 400px; position: relative;
}
.vault-header input {
width: 100%; padding: 6px 12px 6px 32px; background: rgba(30,30,50,0.6);
border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);
font-size: 13px; outline: none;
}
.vault-header input:focus { border-color: var(--accent); }
.vault-header .toggle-btn {
background: none; border: 1px solid var(--border); color: var(--text-muted);
padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px;
}
.vault-header .toggle-btn:hover { border-color: var(--accent); color: var(--accent); }
/* Sidebar file tree */
.vault-sidebar {
background: var(--bg-secondary); border-right: 1px solid var(--border);
overflow-y: auto; -webkit-overflow-scrolling: touch;
touch-action: pan-y; padding: 8px 0; font-size: 13px;
}
.tree-folder { cursor: pointer; user-select: none; }
.tree-folder-header {
display: flex; align-items: center; padding: 4px 12px; gap: 6px;
color: var(--text-secondary); border-radius: 4px; margin: 1px 4px;
}
.tree-folder-header:hover { background: rgba(255,215,0,0.05); }
.tree-folder-header .arrow { color: var(--text-muted); font-size: 10px; width: 12px; }
.tree-children { padding-left: 12px; display: none; }
.tree-children.open { display: block; }
.tree-file {
display: flex; align-items: center; padding: 4px 12px; gap: 6px;
color: var(--text-primary); cursor: pointer; border-radius: 4px; margin: 1px 4px;
font-size: 13px;
}
.tree-file:hover { background: rgba(255,215,0,0.05); }
.tree-file.active { background: rgba(255,215,0,0.1); color: var(--accent); }
/* Content area */
.vault-content {
overflow-y: auto; -webkit-overflow-scrolling: touch;
touch-action: pan-y; padding: 32px 48px; max-width: 900px;
line-height: 1.7; font-size: 15px;
}
.vault-content h1 { color: var(--accent); font-size: 28px; margin: 24px 0 12px; }
.vault-content h2 { color: var(--accent); font-size: 22px; margin: 20px 0 10px; border-bottom: 1px solid var(--border); padding-bottom: 4px; }
.vault-content h3 { color: var(--text-primary); font-size: 18px; margin: 16px 0 8px; }
.vault-content p { margin: 8px 0; }
.vault-content a { color: var(--link); text-decoration: none; }
.vault-content a:hover { text-decoration: underline; }
.vault-content code { background: var(--bg-surface); padding: 2px 6px; border-radius: 4px; font-size: 13px; color: var(--teal); }
.vault-content pre { background: var(--bg-surface); padding: 16px; border-radius: 8px; overflow-x: auto; margin: 12px 0; }
.vault-content pre code { padding: 0; background: none; }
.vault-content blockquote { border-left: 3px solid var(--accent); padding-left: 16px; color: var(--text-secondary); margin: 12px 0; }
.vault-content ul, .vault-content ol { padding-left: 24px; }
.vault-content li { margin: 4px 0; }
.vault-content table { border-collapse: collapse; width: 100%; margin: 12px 0; }
.vault-content th, .vault-content td { border: 1px solid var(--border); padding: 8px 12px; text-align: left; }
.vault-content th { background: var(--bg-surface); color: var(--accent); font-size: 13px; }
.vault-content img { max-width: 100%; border-radius: 8px; }
.frontmatter { background: var(--bg-surface); border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px; margin-bottom: 16px; font-size: 12px; }
.frontmatter .fm-key { color: var(--text-muted); }
.frontmatter .fm-val { color: var(--text-primary); margin-left: 8px; }
/* Graph panel */
.vault-graph {
background: var(--bg-secondary); border-left: 1px solid var(--border);
position: relative; overflow: hidden; touch-action: none;
}
.vault-graph canvas { width: 100% !important; height: 100% !important; touch-action: none; }
/* Search results overlay */
#search-results {
position: absolute; top: 48px; left: 50%; transform: translateX(-50%);
width: 420px; max-height: 400px; overflow-y: auto;
background: rgba(14,14,26,0.95); border: 1px solid var(--border);
border-radius: 8px; z-index: 100; display: none;
backdrop-filter: blur(12px);
}
.search-item {
padding: 8px 14px; cursor: pointer; border-bottom: 1px solid rgba(49,50,68,0.5);
}
.search-item:hover { background: rgba(255,215,0,0.05); }
.search-item .name { color: var(--accent); font-weight: 600; }
.search-item .folder { color: var(--text-muted); font-size: 11px; margin-left: 8px; }
.search-item .snippet { color: var(--text-secondary); font-size: 12px; margin-top: 2px; }
/* Welcome screen */
.welcome {
display: flex; flex-direction: column; align-items: center; justify-content: center;
height: 100%; color: var(--text-muted); text-align: center;
}
.welcome h2 { color: var(--accent); margin-bottom: 8px; }
</style>
</head>
<body>
<div class="vault-app" id="app">
<div class="vault-header">
<h1>Vault</h1>
<div class="search-box">
<input type="text" id="search-input" placeholder="Search notes..." oninput="onSearch(this.value)" onfocus="showSearch()" onblur="setTimeout(hideSearch, 200)">
</div>
<button class="toggle-btn" onclick="toggleGraph()">Graph</button>
</div>
<div class="vault-sidebar" id="sidebar"></div>
<div class="vault-content" id="content">
<div class="welcome">
<h2>Welcome to Vault Viewer</h2>
<p>Select a note from the file tree or use search.</p>
</div>
</div>
<div class="vault-graph" id="graph-panel">
<canvas id="graph-canvas"></canvas>
</div>
</div>
<div id="search-results"></div>
<script>
let currentFile = null;
let graphVisible = true;
let graphData = null;
// Load file tree
async function loadTree() {
const resp = await fetch('/api/vault/tree');
const tree = await resp.json();
document.getElementById('sidebar').innerHTML = renderTree(tree, '');
}
function renderTree(node, path) {
if (node.type === 'file') {
const fullPath = path ? path + '/' + node.name : node.name;
return `<div class="tree-file" onclick="loadFile('${fullPath.replace(/'/g, "\\'")}')" data-path="${fullPath}">${node.name.replace('.md', '')}</div>`;
}
const fullPath = path ? path + '/' + node.name : node.name;
let html = '';
if (node.name) {
html += `<div class="tree-folder">
<div class="tree-folder-header" onclick="this.nextElementSibling.classList.toggle('open');this.querySelector('.arrow').textContent=this.nextElementSibling.classList.contains('open')?'v':'>'">
<span class="arrow">></span> ${node.name}
</div>
<div class="tree-children">`;
}
(node.children || []).forEach(c => { html += renderTree(c, fullPath); });
if (node.name) html += '</div></div>';
return html;
}
// Load file content
async function loadFile(path) {
// Highlight active
document.querySelectorAll('.tree-file').forEach(el => el.classList.remove('active'));
const activeEl = document.querySelector(`.tree-file[data-path="${path}"]`);
if (activeEl) activeEl.classList.add('active');
const resp = await fetch('/api/vault/file/' + encodeURIComponent(path));
const data = await resp.json();
if (data.error) {
document.getElementById('content').innerHTML = `<p style="color:var(--red)">${data.error}</p>`;
return;
}
currentFile = path;
let html = '';
// Frontmatter
if (data.frontmatter && Object.keys(data.frontmatter).length > 0) {
html += '<div class="frontmatter">';
Object.entries(data.frontmatter).forEach(([k, v]) => {
const val = Array.isArray(v) ? v.join(', ') : String(v);
html += `<div><span class="fm-key">${k}:</span><span class="fm-val">${val}</span></div>`;
});
html += '</div>';
}
// Markdown body — convert wikilinks first
let body = data.body || '';
body = body.replace(/\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g, (match, target, alias) => {
const display = alias || target;
return `<a href="#" onclick="navigateWikilink('${target.replace(/'/g, "\\'")}');return false">${display}</a>`;
});
html += marked.parse(body);
document.getElementById('content').innerHTML = html;
document.getElementById('content').scrollTop = 0;
}
// Wikilink navigation
async function navigateWikilink(target) {
// Search for the target file
const resp = await fetch('/api/vault/search?q=' + encodeURIComponent(target));
const data = await resp.json();
if (data.results && data.results.length > 0) {
// Find exact name match first
const exact = data.results.find(r => r.name.toLowerCase() === target.toLowerCase());
loadFile((exact || data.results[0]).path);
}
}
// Search
let searchTimeout = null;
function onSearch(query) {
clearTimeout(searchTimeout);
if (!query.trim()) { hideSearch(); return; }
searchTimeout = setTimeout(async () => {
const resp = await fetch('/api/vault/search?q=' + encodeURIComponent(query));
const data = await resp.json();
const el = document.getElementById('search-results');
if (!data.results || data.results.length === 0) {
el.innerHTML = '<div class="search-item"><span style="color:var(--text-muted)">No results</span></div>';
} else {
el.innerHTML = data.results.map(r =>
`<div class="search-item" onmousedown="loadFile('${r.path.replace(/'/g, "\\'")}');hideSearch()">
<div><span class="name">${r.name}</span><span class="folder">${r.folder}</span></div>
${r.snippet ? '<div class="snippet">' + r.snippet + '</div>' : ''}
</div>`
).join('');
}
el.style.display = 'block';
}, 200);
}
function showSearch() { if (document.getElementById('search-input').value) onSearch(document.getElementById('search-input').value); }
function hideSearch() { document.getElementById('search-results').style.display = 'none'; }
// Graph toggle
function toggleGraph() {
graphVisible = !graphVisible;
document.getElementById('app').classList.toggle('graph-hidden', !graphVisible);
if (graphVisible && !graphData) loadGraph();
}
// 3D Graph
let graphScene, graphCamera, graphRenderer;
async function loadGraph() {
const resp = await fetch('/api/vault/graph');
graphData = await resp.json();
const container = document.getElementById('graph-panel');
const canvas = document.getElementById('graph-canvas');
const W = container.clientWidth;
const H = container.clientHeight;
graphScene = new THREE.Scene();
graphScene.background = new THREE.Color(0x0e0e1a);
graphCamera = new THREE.PerspectiveCamera(60, W / H, 1, 2000);
graphCamera.position.set(0, 0, 300);
graphRenderer = new THREE.WebGLRenderer({ canvas, antialias: true });
graphRenderer.setSize(W, H);
// Position nodes in a 3D force layout (simplified)
const nodePositions = {};
const nodes = graphData.nodes || [];
nodes.forEach((n, i) => {
const angle = (i / nodes.length) * Math.PI * 2;
const radius = 80 + Math.random() * 120;
nodePositions[n.id] = {
x: Math.cos(angle) * radius + (Math.random() - 0.5) * 40,
y: (Math.random() - 0.5) * 100,
z: Math.sin(angle) * radius + (Math.random() - 0.5) * 40,
};
});
// Node dots
const colors = { wiki: 0x89b4fa, daily: 0xf9e2af, journal: 0xa6e3a1, projects: 0xf5c2e7, other: 0x6c7086 };
nodes.forEach(n => {
const pos = nodePositions[n.id];
const color = colors[n.type] || colors.other;
const geo = new THREE.SphereGeometry(1.5, 8, 8);
const mat = new THREE.MeshBasicMaterial({ color });
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(pos.x, pos.y, pos.z);
graphScene.add(mesh);
});
// Edges
(graphData.edges || []).forEach(e => {
const s = nodePositions[e.source];
const t = nodePositions[e.target];
if (!s || !t) return;
const geo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(s.x, s.y, s.z),
new THREE.Vector3(t.x, t.y, t.z),
]);
const mat = new THREE.LineBasicMaterial({ color: 0x313244, transparent: true, opacity: 0.3 });
graphScene.add(new THREE.Line(geo, mat));
});
// Orbit (mouse + touch support)
let isDrag = false, lastX = 0, lastY = 0, rotX = 0, rotY = 0;
function getPos(e) {
if (e.touches && e.touches.length) {
return { x: e.touches[0].clientX, y: e.touches[0].clientY };
}
return { x: e.clientX, y: e.clientY };
}
function onDragStart(e) {
e.preventDefault();
isDrag = true;
const p = getPos(e);
lastX = p.x; lastY = p.y;
}
function onDragMove(e) {
if (!isDrag) return;
e.preventDefault();
const p = getPos(e);
rotY += (p.x - lastX) * 0.005;
rotX += (p.y - lastY) * 0.005;
lastX = p.x; lastY = p.y;
}
function onDragEnd(e) {
if (e && e.preventDefault) e.preventDefault();
isDrag = false;
}
function onWheel(e) {
graphCamera.position.z += e.deltaY * 0.5;
graphCamera.position.z = Math.max(50, Math.min(800, graphCamera.position.z));
}
canvas.addEventListener('mousedown', onDragStart);
canvas.addEventListener('mousemove', onDragMove);
window.addEventListener('mouseup', onDragEnd);
canvas.addEventListener('touchstart', onDragStart, { passive: false });
canvas.addEventListener('touchmove', onDragMove, { passive: false });
canvas.addEventListener('touchend', onDragEnd, { passive: false });
canvas.addEventListener('touchcancel', onDragEnd, { passive: false });
canvas.addEventListener('wheel', onWheel, { passive: true });
// Auto-rotate: when user is not interacting, slow rotation.
// Pauses when tab is hidden, when user is dragging, and after page visibility loss.
let autoRotate = true;
let lastInteractionTime = 0;
const AUTO_ROTATE_RESUME_MS = 3000; // resume auto-rotation 3s after user stops dragging
function animateGraph() {
requestAnimationFrame(animateGraph);
if (document.hidden) return; // skip rendering when tab is in background
const now = Date.now();
const userJustInteracted = (now - lastInteractionTime) < AUTO_ROTATE_RESUME_MS;
if (isDrag) {
lastInteractionTime = now;
autoRotate = false;
} else if (!userJustInteracted) {
autoRotate = true;
}
if (autoRotate && !isDrag) {
rotY += 0.002;
}
graphScene.rotation.y = rotY;
graphScene.rotation.x = rotX;
graphRenderer.render(graphScene, graphCamera);
}
animateGraph();
}
// Init
loadTree();
loadGraph();
</script>
</body>
</html>
-355
View File
@@ -1,355 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Vault Viewer</title>
<link rel="stylesheet" href="hermes-custom.css">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
: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;
--green: #a6e3a1; --red: #f38ba8; --blue: #89b4fa; --teal: #94e2d5;
}
* { 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; height: 100vh; overflow: hidden; }
.vault-app {
display: grid; grid-template-columns: 280px 1fr 260px;
grid-template-rows: 48px 1fr; height: 100vh;
}
.vault-app.graph-hidden { grid-template-columns: 280px 1fr; }
/* Header */
.vault-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;
}
.vault-header h1 { color: var(--accent); font-size: 14px; font-weight: 700; letter-spacing: 1px; }
.vault-header .search-box {
flex: 1; max-width: 400px; position: relative;
}
.vault-header input {
width: 100%; padding: 6px 12px 6px 32px; background: rgba(30,30,50,0.6);
border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);
font-size: 13px; outline: none;
}
.vault-header input:focus { border-color: var(--accent); }
.vault-header .toggle-btn {
background: none; border: 1px solid var(--border); color: var(--text-muted);
padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px;
}
.vault-header .toggle-btn:hover { border-color: var(--accent); color: var(--accent); }
/* Sidebar file tree */
.vault-sidebar {
background: var(--bg-secondary); border-right: 1px solid var(--border);
overflow-y: auto; padding: 8px 0; font-size: 13px;
}
.tree-folder { cursor: pointer; user-select: none; }
.tree-folder-header {
display: flex; align-items: center; padding: 4px 12px; gap: 6px;
color: var(--text-secondary); border-radius: 4px; margin: 1px 4px;
}
.tree-folder-header:hover { background: rgba(255,215,0,0.05); }
.tree-folder-header .arrow { color: var(--text-muted); font-size: 10px; width: 12px; }
.tree-children { padding-left: 12px; display: none; }
.tree-children.open { display: block; }
.tree-file {
display: flex; align-items: center; padding: 4px 12px; gap: 6px;
color: var(--text-primary); cursor: pointer; border-radius: 4px; margin: 1px 4px;
font-size: 13px;
}
.tree-file:hover { background: rgba(255,215,0,0.05); }
.tree-file.active { background: rgba(255,215,0,0.1); color: var(--accent); }
/* Content area */
.vault-content {
overflow-y: auto; padding: 32px 48px; max-width: 900px;
line-height: 1.7; font-size: 15px;
}
.vault-content h1 { color: var(--accent); font-size: 28px; margin: 24px 0 12px; }
.vault-content h2 { color: var(--accent); font-size: 22px; margin: 20px 0 10px; border-bottom: 1px solid var(--border); padding-bottom: 4px; }
.vault-content h3 { color: var(--text-primary); font-size: 18px; margin: 16px 0 8px; }
.vault-content p { margin: 8px 0; }
.vault-content a { color: var(--link); text-decoration: none; }
.vault-content a:hover { text-decoration: underline; }
.vault-content code { background: var(--bg-surface); padding: 2px 6px; border-radius: 4px; font-size: 13px; color: var(--teal); }
.vault-content pre { background: var(--bg-surface); padding: 16px; border-radius: 8px; overflow-x: auto; margin: 12px 0; }
.vault-content pre code { padding: 0; background: none; }
.vault-content blockquote { border-left: 3px solid var(--accent); padding-left: 16px; color: var(--text-secondary); margin: 12px 0; }
.vault-content ul, .vault-content ol { padding-left: 24px; }
.vault-content li { margin: 4px 0; }
.vault-content table { border-collapse: collapse; width: 100%; margin: 12px 0; }
.vault-content th, .vault-content td { border: 1px solid var(--border); padding: 8px 12px; text-align: left; }
.vault-content th { background: var(--bg-surface); color: var(--accent); font-size: 13px; }
.vault-content img { max-width: 100%; border-radius: 8px; }
.frontmatter { background: var(--bg-surface); border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px; margin-bottom: 16px; font-size: 12px; }
.frontmatter .fm-key { color: var(--text-muted); }
.frontmatter .fm-val { color: var(--text-primary); margin-left: 8px; }
/* Graph panel */
.vault-graph {
background: var(--bg-secondary); border-left: 1px solid var(--border);
position: relative; overflow: hidden;
}
.vault-graph canvas { width: 100% !important; height: 100% !important; }
/* Search results overlay */
#search-results {
position: absolute; top: 48px; left: 50%; transform: translateX(-50%);
width: 420px; max-height: 400px; overflow-y: auto;
background: rgba(14,14,26,0.95); border: 1px solid var(--border);
border-radius: 8px; z-index: 100; display: none;
backdrop-filter: blur(12px);
}
.search-item {
padding: 8px 14px; cursor: pointer; border-bottom: 1px solid rgba(49,50,68,0.5);
}
.search-item:hover { background: rgba(255,215,0,0.05); }
.search-item .name { color: var(--accent); font-weight: 600; }
.search-item .folder { color: var(--text-muted); font-size: 11px; margin-left: 8px; }
.search-item .snippet { color: var(--text-secondary); font-size: 12px; margin-top: 2px; }
/* Welcome screen */
.welcome {
display: flex; flex-direction: column; align-items: center; justify-content: center;
height: 100%; color: var(--text-muted); text-align: center;
}
.welcome h2 { color: var(--accent); margin-bottom: 8px; }
</style>
</head>
<body>
<div class="vault-app" id="app">
<div class="vault-header">
<h1>Vault</h1>
<div class="search-box">
<input type="text" id="search-input" placeholder="Search notes..." oninput="onSearch(this.value)" onfocus="showSearch()" onblur="setTimeout(hideSearch, 200)">
</div>
<button class="toggle-btn" onclick="toggleGraph()">Graph</button>
</div>
<div class="vault-sidebar" id="sidebar"></div>
<div class="vault-content" id="content">
<div class="welcome">
<h2>Welcome to Vault Viewer</h2>
<p>Select a note from the file tree or use search.</p>
</div>
</div>
<div class="vault-graph" id="graph-panel">
<canvas id="graph-canvas"></canvas>
</div>
</div>
<div id="search-results"></div>
<script>
let currentFile = null;
let graphVisible = true;
let graphData = null;
// Load file tree
async function loadTree() {
const resp = await fetch('/api/vault/tree');
const tree = await resp.json();
document.getElementById('sidebar').innerHTML = renderTree(tree, '');
}
function renderTree(node, path) {
if (node.type === 'file') {
const fullPath = path ? path + '/' + node.name : node.name;
return `<div class="tree-file" onclick="loadFile('${fullPath.replace(/'/g, "\\'")}')" data-path="${fullPath}">${node.name.replace('.md', '')}</div>`;
}
const fullPath = path ? path + '/' + node.name : node.name;
let html = '';
if (node.name) {
html += `<div class="tree-folder">
<div class="tree-folder-header" onclick="this.nextElementSibling.classList.toggle('open');this.querySelector('.arrow').textContent=this.nextElementSibling.classList.contains('open')?'v':'>'">
<span class="arrow">></span> ${node.name}
</div>
<div class="tree-children">`;
}
(node.children || []).forEach(c => { html += renderTree(c, fullPath); });
if (node.name) html += '</div></div>';
return html;
}
// Load file content
async function loadFile(path) {
// Highlight active
document.querySelectorAll('.tree-file').forEach(el => el.classList.remove('active'));
const activeEl = document.querySelector(`.tree-file[data-path="${path}"]`);
if (activeEl) activeEl.classList.add('active');
const resp = await fetch('/api/vault/file/' + encodeURIComponent(path));
const data = await resp.json();
if (data.error) {
document.getElementById('content').innerHTML = `<p style="color:var(--red)">${data.error}</p>`;
return;
}
currentFile = path;
let html = '';
// Frontmatter
if (data.frontmatter && Object.keys(data.frontmatter).length > 0) {
html += '<div class="frontmatter">';
Object.entries(data.frontmatter).forEach(([k, v]) => {
const val = Array.isArray(v) ? v.join(', ') : String(v);
html += `<div><span class="fm-key">${k}:</span><span class="fm-val">${val}</span></div>`;
});
html += '</div>';
}
// Markdown body — convert wikilinks first
let body = data.body || '';
body = body.replace(/\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g, (match, target, alias) => {
const display = alias || target;
return `<a href="#" onclick="navigateWikilink('${target.replace(/'/g, "\\'")}');return false">${display}</a>`;
});
html += marked.parse(body);
document.getElementById('content').innerHTML = html;
document.getElementById('content').scrollTop = 0;
}
// Wikilink navigation
async function navigateWikilink(target) {
// Search for the target file
const resp = await fetch('/api/vault/search?q=' + encodeURIComponent(target));
const data = await resp.json();
if (data.results && data.results.length > 0) {
// Find exact name match first
const exact = data.results.find(r => r.name.toLowerCase() === target.toLowerCase());
loadFile((exact || data.results[0]).path);
}
}
// Search
let searchTimeout = null;
function onSearch(query) {
clearTimeout(searchTimeout);
if (!query.trim()) { hideSearch(); return; }
searchTimeout = setTimeout(async () => {
const resp = await fetch('/api/vault/search?q=' + encodeURIComponent(query));
const data = await resp.json();
const el = document.getElementById('search-results');
if (!data.results || data.results.length === 0) {
el.innerHTML = '<div class="search-item"><span style="color:var(--text-muted)">No results</span></div>';
} else {
el.innerHTML = data.results.map(r =>
`<div class="search-item" onmousedown="loadFile('${r.path.replace(/'/g, "\\'")}');hideSearch()">
<div><span class="name">${r.name}</span><span class="folder">${r.folder}</span></div>
${r.snippet ? '<div class="snippet">' + r.snippet + '</div>' : ''}
</div>`
).join('');
}
el.style.display = 'block';
}, 200);
}
function showSearch() { if (document.getElementById('search-input').value) onSearch(document.getElementById('search-input').value); }
function hideSearch() { document.getElementById('search-results').style.display = 'none'; }
// Graph toggle
function toggleGraph() {
graphVisible = !graphVisible;
document.getElementById('app').classList.toggle('graph-hidden', !graphVisible);
if (graphVisible && !graphData) loadGraph();
}
// 3D Graph
let graphScene, graphCamera, graphRenderer;
async function loadGraph() {
const resp = await fetch('/api/vault/graph');
graphData = await resp.json();
const container = document.getElementById('graph-panel');
const canvas = document.getElementById('graph-canvas');
const W = container.clientWidth;
const H = container.clientHeight;
graphScene = new THREE.Scene();
graphScene.background = new THREE.Color(0x0e0e1a);
graphCamera = new THREE.PerspectiveCamera(60, W / H, 1, 2000);
graphCamera.position.set(0, 0, 300);
graphRenderer = new THREE.WebGLRenderer({ canvas, antialias: true });
graphRenderer.setSize(W, H);
// Position nodes in a 3D force layout (simplified)
const nodePositions = {};
const nodes = graphData.nodes || [];
nodes.forEach((n, i) => {
const angle = (i / nodes.length) * Math.PI * 2;
const radius = 80 + Math.random() * 120;
nodePositions[n.id] = {
x: Math.cos(angle) * radius + (Math.random() - 0.5) * 40,
y: (Math.random() - 0.5) * 100,
z: Math.sin(angle) * radius + (Math.random() - 0.5) * 40,
};
});
// Node dots
const colors = { wiki: 0x89b4fa, daily: 0xf9e2af, journal: 0xa6e3a1, projects: 0xf5c2e7, other: 0x6c7086 };
nodes.forEach(n => {
const pos = nodePositions[n.id];
const color = colors[n.type] || colors.other;
const geo = new THREE.SphereGeometry(1.5, 8, 8);
const mat = new THREE.MeshBasicMaterial({ color });
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(pos.x, pos.y, pos.z);
graphScene.add(mesh);
});
// Edges
(graphData.edges || []).forEach(e => {
const s = nodePositions[e.source];
const t = nodePositions[e.target];
if (!s || !t) return;
const geo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(s.x, s.y, s.z),
new THREE.Vector3(t.x, t.y, t.z),
]);
const mat = new THREE.LineBasicMaterial({ color: 0x313244, transparent: true, opacity: 0.3 });
graphScene.add(new THREE.Line(geo, mat));
});
// Orbit
let isDrag = false, lastX = 0, lastY = 0, rotX = 0, rotY = 0;
canvas.addEventListener('mousedown', e => { isDrag = true; lastX = e.clientX; lastY = e.clientY; });
canvas.addEventListener('mousemove', e => {
if (!isDrag) return;
rotY += (e.clientX - lastX) * 0.005;
rotX += (e.clientY - lastY) * 0.005;
lastX = e.clientX; lastY = e.clientY;
});
canvas.addEventListener('mouseup', () => isDrag = false);
canvas.addEventListener('wheel', e => {
graphCamera.position.z += e.deltaY * 0.5;
graphCamera.position.z = Math.max(50, Math.min(800, graphCamera.position.z));
});
function animateGraph() {
requestAnimationFrame(animateGraph);
graphScene.rotation.y = rotY;
graphScene.rotation.x = rotX;
graphRenderer.render(graphScene, graphCamera);
}
animateGraph();
}
// Init
loadTree();
loadGraph();
</script>
<script src="vault-custom.js"></script>
</body>
</html>