Rollback to upstream-obv (v0.1 effective)

Vier Customization-Iterationen (v0.2 bis v0.5.1) haben auf Mobile (iOS Safari)
und Chrome Desktop jeweils neue Probleme verursacht statt sie zu lösen.

Rollback entfernt:
- vault.html: Script-Tag für vault-custom.js entfernt (upstream-original)
- server.py: ?file= query-Patch entfernt (upstream-original)
- vault-custom.js: leerer Stub mit Comment (Customizations deaktiviert)
- hermes-custom.css: leerer Stub (CSS deaktiviert)

Was bleibt:
- Repo-Struktur (README, CHANGELOG, ARCHITECTURE, CUSTOMIZATIONS, scripts/)
- Documented history der Customization-Iterationen für späteres Re-Audit
- Backup-Dateien: *.lukas (alle Original-Patches vor diesem Commit)

Begründung: Lukas braucht ein verlässlich funktionierendes Wiki, nicht
endlose Problemlösungs-Schleifen. Basis muss stimmen, bevor neue Features
ausprobiert werden.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-18 04:02:08 +00:00
co-authored by Claude
parent 384281c747
commit 5394f7e9bf
8 changed files with 1364 additions and 716 deletions
+1 -249
View File
@@ -1,249 +1 @@
/* 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;
}
}
/* placeholder */
+249
View File
@@ -0,0 +1,249 @@
/* 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;
}
}
+2 -11
View File
@@ -217,17 +217,8 @@ class VaultHandler(BaseHTTPRequestHandler):
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
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)
+302
View File
@@ -0,0 +1,302 @@
"""
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 -453
View File
@@ -1,453 +1 @@
/* 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);
}
})();
/* Hermes Customizations disabled — rollback to v0.1 (upstream obv) */
+453
View File
@@ -0,0 +1,453 @@
/* 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);
}
})();
+1 -3
View File
@@ -2,9 +2,8 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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>
@@ -350,6 +349,5 @@ async function loadGraph() {
loadTree();
loadGraph();
</script>
<script src="vault-custom.js"></script>
</body>
</html>
+355
View File
@@ -0,0 +1,355 @@
<!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>