Initial commit: hermes-wiki-viewer v0.2.0
Fork of DanielCheer/obsidian-web-viewer (MIT, 2026-04) with Lukas' first additions for the Hermes Wiki workflow. Additive customization layer (vault-custom.js) — keeps upstream-merge trivial via a one-line script tag in vault.html. Features added: 1. Current-page highlighting in 3D Graph — current node gold+glow, connected nodes light-blue, others dim to 20% opacity 2. Click-to-navigate on graph — Three.js raycaster triggers file load 3. Responsive layout — graph collapses below 1024px, tree below 768px 4. URL-based deep-linking via ?file=<path> query param 5. Server-side ?file= support in /api/vault/file/ endpoint Modified files: - server.py: +7 lines (Lukas-add: ?file= query param parsing) - vault.html: +1 line (script tag for vault-custom.js) New files: - vault-custom.js: 11KB, all customizations in one place under HermesCustom namespace - README.md: fork intro, quick start, customization guide - CHANGELOG.md: Lukas-additions tracking - docs/ARCHITECTURE.md: design rationale - docs/CUSTOMIZATIONS.md: feature spec - .gitignore: standard 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+24
@@ -0,0 +1,24 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.so
|
||||
.Python
|
||||
*.egg-info/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
# Logs (skript-generated)
|
||||
*.log
|
||||
|
||||
# Build artifacts
|
||||
dist/
|
||||
build/
|
||||
@@ -0,0 +1,56 @@
|
||||
# Changelog — Lukas' Additions
|
||||
|
||||
All notable changes to this fork are documented here. Upstream-tracking via [DanielCheer/obsidian-web-viewer](https://github.com/DanielCheer/obsidian-web-viewer).
|
||||
|
||||
## v0.2.0 — 2026-07-16 — Lukas' first additions
|
||||
|
||||
### Added
|
||||
|
||||
- **`vault-custom.js`** — additive JavaScript layer with three features:
|
||||
- **Current-page highlighting in 3D Graph**: node representing the open file becomes gold with glow; connected nodes (via WikiLinks) become light-blue; unrelated nodes dim to 20% opacity
|
||||
- **Click-to-navigate on 3D Graph**: Three.js raycaster triggers `loadFile()` on node click; URL updates with `?file=<path>` query param
|
||||
- **Responsive layout**: at viewport <1024px the 3D graph collapses to a 200px bottom panel; at <768px the tree collapses to a hamburger menu and graph hides entirely
|
||||
- **One-line patch in `vault.html`**: `<script src="vault-custom.js"></script>` before `</body>` (single source of upstream-merge friction)
|
||||
- **7-line patch in `server.py`**: `?file=<path>` query parameter support on `/api/vault/file/` for clean URL navigation
|
||||
- **`docs/ARCHITECTURE.md`**: design rationale (why additive layer, why HermesCustom namespace, what tradeoffs we accepted)
|
||||
- **`docs/CUSTOMIZATIONS.md`**: feature spec with code pointers
|
||||
- **`README.md`**: Lukas-authored intro, Quick Start, customization guide, upstream-merge procedure
|
||||
|
||||
### Preserved from upstream
|
||||
|
||||
- Catppuccin-dark theme
|
||||
- Three.js 3D graph
|
||||
- File tree sidebar with collapsible folders
|
||||
- WikiLink resolution with search fallback
|
||||
- Full-text search
|
||||
- Frontmatter-as-card rendering
|
||||
- Python stdlib only (no Flask/FastAPI dependency)
|
||||
|
||||
### Migration notes
|
||||
|
||||
If you're coming from upstream obv:
|
||||
1. `python3 server.py --vault <your-vault> --host 127.0.0.1 --port 8765` works as before
|
||||
2. The Tailscale-URL gets `?file=` query params on navigation (deep-linkable)
|
||||
3. Customizations only kick in if `vault-custom.js` is reachable from the same origin (it is, served by the same server.py)
|
||||
|
||||
## v0.1.0 — 2026-04 — Upstream baseline
|
||||
|
||||
Forked from [DanielCheer/obsidian-web-viewer @ commit `master`](https://github.com/DanielCheer/obsidian-web-viewer) on 2026-07-16. No modifications yet.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
Next planned additions (in priority order):
|
||||
|
||||
- **Backlinks panel** — for each loaded file, show incoming WikiLinks (which other files link to this one). Adds a third collapsible panel on the right or merges into graph context menu.
|
||||
- **Keyboard shortcuts**:
|
||||
- `j` / `k` — next / previous file in current folder
|
||||
- `[` / `]` — back / forward in navigation history
|
||||
- `g` — focus 3D graph
|
||||
- `/` — focus search box
|
||||
- **Light theme variant** — Catppuccin-Light alongside Catppuccin-Dark, toggle in header
|
||||
- **Recent files section** — show last 10 visited files in tree sidebar
|
||||
- **Graph filters** — toggle to show only current-folder nodes, only connected nodes, etc.
|
||||
|
||||
Lukas' note: each new addition should remain in `vault-custom.js`. If it can't fit there cleanly, it's a sign that the addition deserves its own script tag (loaded after `vault-custom.js`).
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 DanielCheer
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Hermes Wiki Viewer
|
||||
|
||||
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.
|
||||
|
||||
**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`.
|
||||
|
||||
## Features (in addition to upstream)
|
||||
|
||||
- **Current-page highlighting in 3D Graph** — the node representing the open file gets a brighter material and a glow outline; connected nodes (via `[[wikilinks]]`) are also highlighted; unrelated nodes dim to 20% opacity
|
||||
- **Click-to-navigate on 3D Graph** — Three.js raycaster triggers file navigation on node click
|
||||
- **Responsive layout** — graph collapses to a bottom panel below 1024px viewport; tree collapses to hamburger menu below 768px
|
||||
- **Backlinks panel** (planned) — see CHANGELOG.md
|
||||
|
||||
## Features (from upstream, unchanged)
|
||||
|
||||
- File tree sidebar (collapsible folder tree)
|
||||
- Markdown rendering with code blocks, tables, blockquotes, images
|
||||
- Wikilink navigation (`[[links]]` click-to-traverse)
|
||||
- YAML frontmatter rendered as styled card
|
||||
- Full-text search by note name and content with snippets
|
||||
- 3D graph visualization (Three.js) showing note connections
|
||||
- Catppuccin-inspired dark theme
|
||||
- Zero client-side setup — anyone with the URL can browse
|
||||
|
||||
## 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).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Clone
|
||||
git clone https://github.com/LukasHuber/hermes-wiki-viewer.git
|
||||
cd hermes-wiki-viewer
|
||||
|
||||
# Optional: PyYAML for frontmatter
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Point to your vault
|
||||
python3 server.py --vault /path/to/your/vault --host 127.0.0.1 --port 8765
|
||||
|
||||
# Open http://localhost:8765
|
||||
```
|
||||
|
||||
For Tailscale access (Lukas' typical setup), pair with `tailscale serve`:
|
||||
|
||||
```bash
|
||||
tailscale serve --bg --https=443 http://127.0.0.1:8765
|
||||
# Access via https://<hostname>.<tailnet>.ts.net/
|
||||
```
|
||||
|
||||
## 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).
|
||||
|
||||
---
|
||||
|
||||
Upstream: [DanielCheer/obsidian-web-viewer](https://github.com/DanielCheer/obsidian-web-viewer) © 2026 Daniel Cheer
|
||||
Hermes Wiki Viewer fork © 2026 Lukas Huber
|
||||
@@ -0,0 +1,95 @@
|
||||
# Architecture
|
||||
|
||||
## Why this fork exists
|
||||
|
||||
[DanielCheer/obsidian-web-viewer](https://github.com/DanielCheer/obsidian-web-viewer) is a great single-file Obsidian-vault viewer (server.py + vault.html, ~10 KB + 14 KB, Python stdlib only). But for Lukas' Hermes Wiki workflow — 245 Markdown files across 18 top-level folders, frequent cross-referencing via WikiLinks, mobile reading — three specific UX gaps needed fixing:
|
||||
|
||||
1. **3D Graph shows all nodes uniformly** — no indication of which node the user is currently reading or which nodes are connected to it
|
||||
2. **Layout breaks on small screens** — the 3-panel grid (`280px 1fr 260px`) is too narrow on 13" laptops and unusable on phones
|
||||
3. **Click-to-navigate from graph** — Three.js canvas accepts clicks but doesn't trigger navigation
|
||||
|
||||
## Design principles
|
||||
|
||||
### 1. Additive layer, not fork-and-modify
|
||||
|
||||
We modify **exactly one line** in `vault.html` (a `<script src="vault-custom.js"></script>` tag before `</body>`). Everything else — including the customizations themselves — lives in `vault-custom.js`.
|
||||
|
||||
Why:
|
||||
- Upstream `git pull` conflicts are trivial to resolve (the one line is recognizable)
|
||||
- Customizations are clearly separated from upstream code
|
||||
- Reviewing customizations is reading one file, not diffing the whole repo
|
||||
- Easier to upstream-merge back if Lukas' features prove generally useful
|
||||
|
||||
### 2. HermesCustom namespace
|
||||
|
||||
All custom code lives under `window.HermesCustom`. Methods are namespaced and self-documenting:
|
||||
|
||||
```js
|
||||
window.HermesCustom = window.HermesCustom || {};
|
||||
HermesCustom.highlightCurrentNode = function() { ... };
|
||||
HermesCustom.setupGraphClickHandler = function() { ... };
|
||||
HermesCustom.responsiveLayout = function() { ... };
|
||||
```
|
||||
|
||||
This way the namespace is visible in DevTools and a future maintainer can see at a glance what's custom vs. upstream.
|
||||
|
||||
### 3. Server.py stays mostly upstream
|
||||
|
||||
We add **one** enhancement to `server.py`: a `?file=<path>` query parameter that returns the file directly (used by `HermesCustom.highlightCurrentNode` to know which file is open). This is a 5-line change that doesn't conflict with any upstream logic.
|
||||
|
||||
If upstream adds a similar feature, we drop our addition.
|
||||
|
||||
## File map
|
||||
|
||||
```
|
||||
hermes-wiki-viewer/
|
||||
├── server.py # obv-fork + 5 lines (file-query-param)
|
||||
├── vault.html # obv-fork + 1 line (script tag for custom.js)
|
||||
├── vault-custom.js # ALL Lukas-specific code
|
||||
├── requirements.txt # PyYAML only (same as upstream)
|
||||
├── README.md # this repo's entry point
|
||||
├── LICENSE # MIT (inherited)
|
||||
├── CHANGELOG.md # release notes for Lukas-additions
|
||||
├── docs/
|
||||
│ ├── ARCHITECTURE.md # you are here
|
||||
│ └── CUSTOMIZATIONS.md # spec for each Lukas-feature
|
||||
└── .gitignore # standard
|
||||
```
|
||||
|
||||
## Why we keep `vault-custom.js` separate from `vault.html`
|
||||
|
||||
Embedding customizations into `vault.html` would mean:
|
||||
- Every upstream update requires re-applying the entire diff
|
||||
- Reviewing customizations requires diffing two HTML files
|
||||
- Conflicts when upstream renames a function we override
|
||||
|
||||
A separate file means:
|
||||
- The customizations are a stable, reviewable unit
|
||||
- Upstream updates touch `vault.html` only, customizations are unchanged
|
||||
- The one-line patch in `vault.html` is a clear "anchor point" that any reviewer can understand
|
||||
|
||||
## Why Python stdlib only (no Flask/FastAPI)
|
||||
|
||||
Upstream uses `http.server` from stdlib. We keep this. Adding a framework would:
|
||||
- Inflate dependencies
|
||||
- Require version-pinning for reproducibility
|
||||
- Make the tool harder to deploy (anywhere with Python 3.8+ works)
|
||||
|
||||
The 5-line addition to `server.py` is plain stdlib `BaseHTTPRequestHandler` style.
|
||||
|
||||
## Tradeoffs we accepted
|
||||
|
||||
- **No build step.** Customizations are vanilla JS. No bundler, no TypeScript. Means no static type-checking, but means anyone can read and modify the code without toolchain setup.
|
||||
- **No tests in CI.** We rely on manual testing for now. CI tests would add complexity that doesn't match the project's "simple tool" character. (Future: a small Playwright test for the graph highlighting.)
|
||||
- **No auto-update from upstream.** When obv updates, Lukas has to `git pull upstream && git merge`. The one-line `vault.html` patch needs re-application if upstream modified it. This is acceptable for a personal side-project.
|
||||
|
||||
## Roadmap
|
||||
|
||||
See CHANGELOG.md for planned additions. Current priorities:
|
||||
|
||||
1. ✅ Current-page highlighting in 3D Graph
|
||||
2. ✅ Click-to-navigate on graph
|
||||
3. ✅ Responsive layout (1024px and 768px breakpoints)
|
||||
4. ⏳ Backlinks panel (per-page incoming WikiLinks)
|
||||
5. ⏳ Keyboard shortcuts (j/k for next/prev file, [/] for nav, g/G for graph focus)
|
||||
6. ⏳ Light theme variant (currently Catppuccin-dark only)
|
||||
@@ -0,0 +1,120 @@
|
||||
# Customizations — Feature Spec
|
||||
|
||||
Each section describes one Lukas-feature: what it does, how it works, where the code lives, and known limitations.
|
||||
|
||||
## 1. Current-page highlighting in 3D Graph
|
||||
|
||||
**What:** When you open a file, the corresponding node in the 3D Graph becomes brighter and larger. Nodes that the open file links to (via `[[wikilinks]]`) also brighten. All other nodes dim.
|
||||
|
||||
**How:**
|
||||
1. `HermesCustom.getCurrentFile()` reads the file path from `?file=` URL param (preferred) or tracks via the `loadFile()` hook
|
||||
2. `HermesCustom.getConnectedFiles(currentFile)` looks up outgoing edges in the global graph data
|
||||
3. `HermesCustom.highlightCurrentNode()` iterates `window.graphNodeObjects` (obv's Three.js node meshes) and adjusts `material.color`, `emissive`, `scale`, `opacity`
|
||||
|
||||
**Code:** `vault-custom.js`, methods under namespace `HermesCustom.*`.
|
||||
|
||||
**Materials used (Catppuccin palette, matches theme):**
|
||||
- Current: `#FFD700` (gold) + emissive glow + 1.5x scale
|
||||
- Connected: `#89b4fa` (light blue) + faint emissive + 1.1x scale
|
||||
- Other: `#313244` (muted) + 20% opacity (transparent)
|
||||
|
||||
**Known limitation:**
|
||||
- Uses `window.graphNodeObjects` global from obv — if obv renames this, we need to update. Mitigation: detection in `highlightCurrentNode` waits up to 500ms for obv's graph to be ready.
|
||||
- Highlight doesn't preserve selection across page reload — fresh fetch on every load.
|
||||
|
||||
## 2. Click-to-navigate on 3D Graph
|
||||
|
||||
**What:** Clicking a node in the 3D Graph navigates to that file's page.
|
||||
|
||||
**How:**
|
||||
1. `HermesCustom.setupGraphClickHandler()` sets up a Three.js Raycaster on the canvas DOM element
|
||||
2. On click, computes normalized mouse coords, raycasts into scene
|
||||
3. First intersection's `userData.nodeId` is the file path
|
||||
4. Calls `window.loadFile(nodeId)` (obv's existing loader)
|
||||
5. Updates URL with `window.history.pushState({}, '', '?file=<path>')`
|
||||
|
||||
**Code:** `vault-custom.js`, ~25 lines.
|
||||
|
||||
**Known limitation:**
|
||||
- Three.js raycasting requires nodes to have `userData.nodeId` set — verified in obv's `addNodeToScene` function (line ~310 in obv's graph render). If obv removes this, our handler silently does nothing.
|
||||
- No keyboard navigation in graph (yet). Roadmap item.
|
||||
|
||||
## 3. Responsive Layout
|
||||
|
||||
**What:** The 3-panel desktop layout collapses gracefully on smaller screens.
|
||||
|
||||
**Breakpoints:**
|
||||
- **Desktop (≥1024px):** Full 3-panel layout — Tree 240px | Content 1fr | Graph 240px
|
||||
- **Tablet (768px–1023px):** Tree 240px | Content 1fr | Graph 200px (bottom panel)
|
||||
- **Mobile (<768px):** Tree as hamburger menu (off-canvas, slides in from left) | Content full-width | Graph hidden
|
||||
|
||||
**How:**
|
||||
- `HermesCustom.injectResponsiveCSS()` adds `<style>` tag with media queries
|
||||
- `HermesCustom.responsiveLayout()` adds `.mobile-mode` / `.tablet-mode` classes to `.vault-app` based on `window.innerWidth`
|
||||
- Resize handler is throttled (150ms) to avoid jank
|
||||
- Hamburger toggle button is appended to header in mobile mode
|
||||
|
||||
**Code:** `vault-custom.js`, ~80 lines (including CSS string).
|
||||
|
||||
**Known limitation:**
|
||||
- The injected CSS uses `!important` to override obv's `grid-template-columns`. If obv restructures the layout, we may need to adjust selectors.
|
||||
- Hamburger menu doesn't auto-close on file selection — minor UX nit. Roadmap.
|
||||
|
||||
## 4. URL-based navigation (?file=)
|
||||
|
||||
**What:** The URL contains the currently-open file as a `?file=<path>` query param. This makes pages deep-linkable and back-button friendly.
|
||||
|
||||
**How:**
|
||||
- `HermesCustom.getCurrentFile()` reads `?file=` from `URLSearchParams`
|
||||
- `vault-custom.js` updates URL via `history.pushState()` on file load
|
||||
- Server.py patch accepts `?file=` in API calls
|
||||
|
||||
**Server-side patch (server.py):**
|
||||
```python
|
||||
# Lukas-add: also support ?file=<path> query for clean URL navigation
|
||||
if "?" in rel:
|
||||
rel, _, query = rel.partition("?")
|
||||
from urllib.parse import parse_qs
|
||||
file_param = parse_qs(query).get("file", [None])[0]
|
||||
if file_param:
|
||||
rel = file_param
|
||||
```
|
||||
|
||||
**Known limitation:**
|
||||
- Server-side patch assumes `?file=` is the only query parameter obv might add. If obv adds another (e.g. `?theme=`), the simple `partition("?")` still works but is naive. Acceptable for current state.
|
||||
|
||||
## 5. Tracking current file via loadFile hook
|
||||
|
||||
**What:** `vault-custom.js` patches obv's `window.loadFile` to remember the last loaded path. Used as a fallback when the URL doesn't have `?file=` (e.g., when obv opens a file from search without updating URL).
|
||||
|
||||
**How:**
|
||||
```js
|
||||
const originalLoadFile = window.loadFile;
|
||||
window.loadFile = function(path) {
|
||||
HermesCustom._lastLoadedFile = path;
|
||||
const result = originalLoadFile.apply(this, arguments);
|
||||
setTimeout(HermesCustom.highlightCurrentNode, 300);
|
||||
return result;
|
||||
};
|
||||
```
|
||||
|
||||
**Known limitation:**
|
||||
- The 300ms delay assumes the render finishes within that time. Slow devices may need a longer delay. Could be improved by hooking into a render-completion signal if obv provides one.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Manual testing only for now. Test cases:
|
||||
|
||||
| Scenario | Expected |
|
||||
|----------|----------|
|
||||
| Open `concepts/agent-reference-model.md` | Graph: that node gold + glow, connected nodes light blue, others dim |
|
||||
| Open file via search (not via tree click) | Same highlighting (URL gets `?file=`) |
|
||||
| Click graph node | File loads, URL updates |
|
||||
| Resize browser to <1024px | Graph moves to bottom panel |
|
||||
| Resize browser to <768px | Tree becomes hamburger, graph hidden |
|
||||
| Hard reload on file URL | File loads directly (server.py parses `?file=`) |
|
||||
| Upstream obv update (theoretical) | Only 1 line of conflict in vault.html, easy merge |
|
||||
|
||||
Planned: Playwright-based automated test for the 5 visual features.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Obsidian Web Viewer — no required external dependencies
|
||||
# Python 3.8+ standard library only
|
||||
# Optional: PyYAML for frontmatter parsing
|
||||
PyYAML>=6.0
|
||||
@@ -0,0 +1,300 @@
|
||||
"""
|
||||
Obsidian Web Viewer — Browse Your Vault in the Browser
|
||||
========================================================
|
||||
Web-based Obsidian vault browser with 3D graph, file tree, markdown
|
||||
rendering, wikilink navigation, and full-text search.
|
||||
|
||||
Usage:
|
||||
python server.py --vault /path/to/your/vault
|
||||
python server.py --vault ~/Documents/MyVault --port 8080
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import argparse
|
||||
import time
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from urllib.parse import unquote, quote
|
||||
|
||||
if sys.platform == 'win32':
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ROOT = Path(__file__).parent
|
||||
VAULT_DIR = None # Set via --vault CLI arg
|
||||
|
||||
# YAML parsing (optional)
|
||||
_YAML_AVAILABLE = False
|
||||
try:
|
||||
import yaml
|
||||
_YAML_AVAILABLE = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
MIME = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".css": "text/css",
|
||||
".js": "application/javascript",
|
||||
".json": "application/json",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".svg": "image/svg+xml",
|
||||
}
|
||||
|
||||
# Cache for vault tree and graph
|
||||
_cache = {"tree": None, "graph": None, "ts": 0}
|
||||
_CACHE_TTL = 300
|
||||
|
||||
|
||||
def _build_tree(vault_dir: Path) -> dict:
|
||||
"""Build a JSON file tree of the vault directory."""
|
||||
now = time.time()
|
||||
if _cache["tree"] and (now - _cache["ts"]) < _CACHE_TTL:
|
||||
return _cache["tree"]
|
||||
|
||||
def _walk(d: Path) -> dict:
|
||||
node = {"type": "folder", "name": d.name, "children": []}
|
||||
try:
|
||||
entries = sorted(d.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower()))
|
||||
except PermissionError:
|
||||
return node
|
||||
for entry in entries:
|
||||
if entry.name.startswith('.') or entry.name.startswith('_'):
|
||||
continue
|
||||
if entry.is_dir():
|
||||
child = _walk(entry)
|
||||
if child["children"]:
|
||||
node["children"].append(child)
|
||||
elif entry.suffix.lower() == '.md':
|
||||
node["children"].append({"type": "file", "name": entry.name})
|
||||
return node
|
||||
|
||||
if not vault_dir.exists():
|
||||
return {"type": "folder", "name": "", "children": []}
|
||||
tree = _walk(vault_dir)
|
||||
tree["name"] = ""
|
||||
_cache["tree"] = tree
|
||||
_cache["ts"] = time.time()
|
||||
return tree
|
||||
|
||||
|
||||
def _parse_frontmatter(raw: str) -> tuple:
|
||||
"""Parse YAML frontmatter from markdown. Returns (dict, body_str)."""
|
||||
if not raw.startswith('---'):
|
||||
return {}, raw
|
||||
end = raw.find('---', 3)
|
||||
if end == -1:
|
||||
return {}, raw
|
||||
fm_text = raw[3:end].strip()
|
||||
body = raw[end + 3:].strip()
|
||||
fm = {}
|
||||
if _YAML_AVAILABLE:
|
||||
try:
|
||||
fm = yaml.safe_load(fm_text) or {}
|
||||
if not isinstance(fm, dict):
|
||||
fm = {"value": fm}
|
||||
except Exception:
|
||||
fm = {}
|
||||
else:
|
||||
for line in fm_text.splitlines():
|
||||
if ':' in line:
|
||||
k, _, v = line.partition(':')
|
||||
v = v.strip().strip('"').strip("'")
|
||||
if v.startswith('[') and v.endswith(']'):
|
||||
v = [x.strip().strip('"').strip("'") for x in v[1:-1].split(',') if x.strip()]
|
||||
fm[k.strip()] = v
|
||||
return fm, body
|
||||
|
||||
|
||||
def _search(vault_dir: Path, query: str, max_results: int = 30) -> list:
|
||||
"""Search vault files by name and content."""
|
||||
results = []
|
||||
query_lower = query.lower()
|
||||
for md_file in vault_dir.rglob("*.md"):
|
||||
if md_file.name.startswith('.') or md_file.name.startswith('_'):
|
||||
continue
|
||||
try:
|
||||
rel = md_file.relative_to(vault_dir).as_posix()
|
||||
except ValueError:
|
||||
continue
|
||||
name = md_file.stem
|
||||
name_match = query_lower in name.lower()
|
||||
snippet = ""
|
||||
content_match = False
|
||||
if not name_match:
|
||||
try:
|
||||
text = md_file.read_text(encoding="utf-8", errors="ignore")[:5000]
|
||||
idx = text.lower().find(query_lower)
|
||||
if idx >= 0:
|
||||
content_match = True
|
||||
start = max(0, idx - 40)
|
||||
end = min(len(text), idx + len(query) + 60)
|
||||
snippet = ("..." if start > 0 else "") + text[start:end].replace("\n", " ") + ("..." if end < len(text) else "")
|
||||
except Exception:
|
||||
continue
|
||||
if name_match or content_match:
|
||||
results.append({
|
||||
"path": rel, "name": name,
|
||||
"folder": str(md_file.parent.relative_to(vault_dir)) if md_file.parent != vault_dir else "",
|
||||
"snippet": snippet, "name_match": name_match,
|
||||
})
|
||||
if len(results) >= max_results:
|
||||
break
|
||||
results.sort(key=lambda r: (not r["name_match"], r["name"].lower()))
|
||||
return results
|
||||
|
||||
|
||||
def _build_graph(vault_dir: Path) -> dict:
|
||||
"""Build a graph of wikilink connections between vault notes."""
|
||||
if _cache["graph"] and (time.time() - _cache["ts"]) < _CACHE_TTL:
|
||||
return _cache["graph"]
|
||||
|
||||
wikilink_re = re.compile(r'\[\[([^\]|]+?)(?:\|[^\]]+?)?\]\]')
|
||||
nodes = []
|
||||
edges = []
|
||||
name_to_path = {}
|
||||
all_paths = []
|
||||
|
||||
for md_file in vault_dir.rglob("*.md"):
|
||||
if md_file.name.startswith('.') or md_file.name.startswith('_'):
|
||||
continue
|
||||
try:
|
||||
rel = md_file.relative_to(vault_dir).as_posix()
|
||||
except ValueError:
|
||||
continue
|
||||
all_paths.append((rel, md_file))
|
||||
name_to_path[md_file.stem.lower()] = rel
|
||||
|
||||
for rel, md_file in all_paths:
|
||||
try:
|
||||
text = md_file.read_text(encoding="utf-8", errors="ignore")
|
||||
except Exception:
|
||||
text = ""
|
||||
|
||||
# Classify by folder for coloring
|
||||
rp = rel.lower()
|
||||
node_type = "other"
|
||||
for folder in ["wiki", "reference", "daily", "journal", "notes", "projects"]:
|
||||
if rp.startswith(folder + "/"):
|
||||
node_type = folder
|
||||
break
|
||||
|
||||
nodes.append({"id": rel, "label": md_file.stem, "type": node_type})
|
||||
links = wikilink_re.findall(text)
|
||||
seen = set()
|
||||
for target in links:
|
||||
resolved = name_to_path.get(target.strip().lower())
|
||||
if resolved and resolved != rel and resolved not in seen:
|
||||
edges.append({"source": rel, "target": resolved})
|
||||
seen.add(resolved)
|
||||
|
||||
result = {"nodes": nodes, "edges": edges}
|
||||
_cache["graph"] = result
|
||||
return result
|
||||
|
||||
|
||||
class VaultHandler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a): pass
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path.split("?")[0].rstrip("/")
|
||||
query_string = self.path.split("?")[1] if "?" in self.path else ""
|
||||
|
||||
if path == "" or path == "/" or path == "/vault":
|
||||
self._serve_file(ROOT / "vault.html")
|
||||
elif path == "/api/vault/tree":
|
||||
self._json(_build_tree(VAULT_DIR))
|
||||
elif path == "/api/vault/graph":
|
||||
self._json(_build_graph(VAULT_DIR))
|
||||
elif path == "/api/vault/search":
|
||||
import urllib.parse
|
||||
params = urllib.parse.parse_qs(query_string)
|
||||
q = params.get("q", [""])[0]
|
||||
self._json({"results": _search(VAULT_DIR, q) if q else []})
|
||||
elif path.startswith("/api/vault/file/"):
|
||||
rel = unquote(path[len("/api/vault/file/"):])
|
||||
# Lukas-add: also support ?file=<path> query for clean URL navigation
|
||||
if "?" in rel:
|
||||
rel, _, query = rel.partition("?")
|
||||
from urllib.parse import parse_qs
|
||||
file_param = parse_qs(query).get("file", [None])[0]
|
||||
if file_param:
|
||||
rel = file_param
|
||||
file_path = VAULT_DIR / rel
|
||||
if not file_path.exists() or not str(file_path).startswith(str(VAULT_DIR)):
|
||||
self._json({"error": "Not found"}, 404)
|
||||
return
|
||||
try:
|
||||
raw = file_path.read_text(encoding="utf-8")
|
||||
fm, body = _parse_frontmatter(raw)
|
||||
self._json({"path": rel, "name": file_path.stem, "frontmatter": fm, "body": body})
|
||||
except Exception as e:
|
||||
self._json({"error": str(e)}, 500)
|
||||
elif path == "/config.json":
|
||||
self._serve_file(ROOT / "config.json")
|
||||
else:
|
||||
f = ROOT / path.lstrip("/")
|
||||
if f.exists() and f.is_file():
|
||||
self._serve_file(f)
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def _serve_file(self, path):
|
||||
if not path.exists():
|
||||
self.send_error(404); return
|
||||
data = path.read_bytes()
|
||||
mime = MIME.get(path.suffix.lower(), "application/octet-stream")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", mime)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _json(self, obj, code=200):
|
||||
data = json.dumps(obj, indent=2, default=str).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Obsidian Web Viewer")
|
||||
parser.add_argument("--vault", required=True, help="Path to your Obsidian vault folder")
|
||||
parser.add_argument("--port", type=int, default=8765)
|
||||
parser.add_argument("--host", default="0.0.0.0")
|
||||
args = parser.parse_args()
|
||||
|
||||
global VAULT_DIR
|
||||
VAULT_DIR = Path(args.vault).resolve()
|
||||
|
||||
if not VAULT_DIR.exists():
|
||||
print(f"ERROR: Vault path does not exist: {VAULT_DIR}")
|
||||
sys.exit(1)
|
||||
|
||||
md_count = sum(1 for _ in VAULT_DIR.rglob("*.md"))
|
||||
print(f"Obsidian Web Viewer")
|
||||
print(f" Vault: {VAULT_DIR}")
|
||||
print(f" Notes: {md_count}")
|
||||
print(f" URL: http://{args.host}:{args.port}")
|
||||
|
||||
server = ThreadingHTTPServer((args.host, args.port), VaultHandler)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nShutting down.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
/* HermesCustom — Lukas' additions to obsidian-web-viewer
|
||||
*
|
||||
* Loaded by vault.html (one-line patch) AFTER the main script. Initializes
|
||||
* automatically via HermesCustom.init() at the bottom of this file.
|
||||
*
|
||||
* Features:
|
||||
* 1. Current-page highlighting in 3D Graph
|
||||
* 2. Click-to-navigate on 3D Graph nodes (Three.js raycasting)
|
||||
* 3. Responsive layout (graph collapses below 1024px, tree below 768px)
|
||||
*
|
||||
* Design notes: see docs/ARCHITECTURE.md
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
window.HermesCustom = window.HermesCustom || {};
|
||||
|
||||
const HermesCustom = window.HermesCustom;
|
||||
|
||||
// ============================================================
|
||||
// 1. Current-page highlighting
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Determine which file is currently being viewed.
|
||||
* Uses URL ?file=<path> parameter, or falls back to extracting from the
|
||||
* page content (the last file fetched via loadFile).
|
||||
*/
|
||||
HermesCustom.getCurrentFile = function() {
|
||||
// Method 1: ?file= query param (preferred)
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const fileParam = params.get('file');
|
||||
if (fileParam) return fileParam;
|
||||
|
||||
// Method 2: Parse from URL hash if obv uses hash-based routing
|
||||
if (window.location.hash) {
|
||||
const hashMatch = window.location.hash.match(/file=([^&]+)/);
|
||||
if (hashMatch) return decodeURIComponent(hashMatch[1]);
|
||||
}
|
||||
|
||||
// Method 3: Track via the last loadFile() call (set up in init)
|
||||
return HermesCustom._lastLoadedFile || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find all WikiLink targets in the currently loaded file's content.
|
||||
* We hook into the renderMarkdown flow by tracking what was last loaded.
|
||||
*/
|
||||
HermesCustom.getConnectedFiles = function(currentFile) {
|
||||
if (!currentFile) return [];
|
||||
// Use the global graph data loaded by obv's fetch('/api/vault/graph')
|
||||
const graphData = HermesCustom._graphData;
|
||||
if (!graphData) return [];
|
||||
|
||||
const connections = graphData.edges
|
||||
.filter(e => e.source === currentFile)
|
||||
.map(e => e.target);
|
||||
|
||||
// Also outgoing from current file (WikiLinks from current page)
|
||||
// And bidirectional awareness
|
||||
return connections;
|
||||
};
|
||||
|
||||
/**
|
||||
* Highlight the current node + connected nodes in the 3D Graph.
|
||||
* Uses obv's global graph variables (graphScene, graphNodeObjects).
|
||||
*/
|
||||
HermesCustom.highlightCurrentNode = function() {
|
||||
const currentFile = HermesCustom.getCurrentFile();
|
||||
if (!currentFile) return;
|
||||
|
||||
// Hook into obv's globals (set after graph render)
|
||||
if (typeof window.graphScene === 'undefined' || !window.graphNodeObjects) {
|
||||
// Graph not yet built — try again shortly
|
||||
setTimeout(HermesCustom.highlightCurrentNode, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
const connected = new Set(HermesCustom.getConnectedFiles(currentFile));
|
||||
connected.add(currentFile);
|
||||
|
||||
// Iterate node meshes and adjust material
|
||||
window.graphNodeObjects.forEach((mesh, nodeId) => {
|
||||
const isCurrent = (nodeId === currentFile);
|
||||
const isConnected = connected.has(nodeId) && !isCurrent;
|
||||
|
||||
if (isCurrent) {
|
||||
// Bright + slightly larger + emissive glow
|
||||
mesh.material.color.setHex(0xFFD700); // gold
|
||||
mesh.material.emissive.setHex(0xFFD700);
|
||||
mesh.material.emissiveIntensity = 0.6;
|
||||
mesh.scale.set(1.5, 1.5, 1.5);
|
||||
} else if (isConnected) {
|
||||
// Brighter than default, smaller than current
|
||||
mesh.material.color.setHex(0x89b4fa); // light blue
|
||||
mesh.material.emissive.setHex(0x89b4fa);
|
||||
mesh.material.emissiveIntensity = 0.3;
|
||||
mesh.scale.set(1.1, 1.1, 1.1);
|
||||
} else {
|
||||
// Dim everything else
|
||||
mesh.material.color.setHex(0x313244); // muted
|
||||
mesh.material.emissiveIntensity = 0;
|
||||
mesh.material.opacity = 0.2;
|
||||
mesh.material.transparent = true;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 2. Click-to-navigate on 3D Graph
|
||||
// ============================================================
|
||||
|
||||
HermesCustom.setupGraphClickHandler = function() {
|
||||
if (!window.graphRenderer || !window.graphScene || !window.graphCamera) {
|
||||
setTimeout(HermesCustom.setupGraphClickHandler, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const mouse = new THREE.Vector2();
|
||||
|
||||
window.graphRenderer.domElement.addEventListener('click', (event) => {
|
||||
const rect = window.graphRenderer.domElement.getBoundingClientRect();
|
||||
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
|
||||
raycaster.setFromCamera(mouse, window.graphCamera);
|
||||
const intersects = raycaster.intersectObjects(
|
||||
Object.values(window.graphNodeObjects || {})
|
||||
);
|
||||
|
||||
if (intersects.length > 0) {
|
||||
const nodeId = intersects[0].object.userData.nodeId;
|
||||
if (nodeId && typeof window.loadFile === 'function') {
|
||||
window.loadFile(nodeId);
|
||||
window.history.pushState({}, '', `?file=${encodeURIComponent(nodeId)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 3. Responsive layout
|
||||
// ============================================================
|
||||
|
||||
HermesCustom.responsiveLayout = function() {
|
||||
const applyLayout = () => {
|
||||
const app = document.querySelector('.vault-app');
|
||||
if (!app) return;
|
||||
|
||||
const width = window.innerWidth;
|
||||
if (width < 768) {
|
||||
// Mobile: tree collapses to hamburger, graph hidden by default
|
||||
app.classList.add('mobile-mode');
|
||||
app.classList.remove('graph-visible');
|
||||
} else if (width < 1024) {
|
||||
// Tablet: tree visible, graph collapses to bottom panel
|
||||
app.classList.add('tablet-mode');
|
||||
app.classList.remove('mobile-mode');
|
||||
} else {
|
||||
// Desktop: full 3-panel layout
|
||||
app.classList.remove('mobile-mode', 'tablet-mode');
|
||||
}
|
||||
};
|
||||
|
||||
// Throttled resize handler
|
||||
let resizeTimer;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(applyLayout, 150);
|
||||
});
|
||||
|
||||
applyLayout(); // Initial
|
||||
};
|
||||
|
||||
/**
|
||||
* Inject responsive CSS into <head> (since vault.html doesn't have these)
|
||||
* Must run early so it doesn't conflict with obv's layout.
|
||||
*/
|
||||
HermesCustom.injectResponsiveCSS = function() {
|
||||
if (document.getElementById('hermes-custom-css')) return;
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'hermes-custom-css';
|
||||
style.textContent = `
|
||||
/* === Tablet (768px - 1023px): graph → bottom panel === */
|
||||
@media (max-width: 1023px) {
|
||||
.vault-app.tablet-mode {
|
||||
grid-template-columns: 240px 1fr !important;
|
||||
grid-template-rows: 48px 1fr 200px !important;
|
||||
}
|
||||
.vault-app.tablet-mode .vault-graph {
|
||||
grid-column: 1 / -1 !important;
|
||||
grid-row: 3 !important;
|
||||
border-left: none !important;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.vault-app.tablet-mode .vault-content {
|
||||
grid-column: 2 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Mobile (<768px): tree → hamburger menu === */
|
||||
@media (max-width: 767px) {
|
||||
.vault-app.mobile-mode {
|
||||
grid-template-columns: 1fr !important;
|
||||
grid-template-rows: 48px 1fr !important;
|
||||
}
|
||||
.vault-app.mobile-mode .vault-sidebar {
|
||||
position: fixed;
|
||||
top: 48px;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 280px;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.2s ease;
|
||||
z-index: 100;
|
||||
}
|
||||
.vault-app.mobile-mode .vault-sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.vault-app.mobile-mode .vault-graph {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Hamburger toggle button (mobile only) === */
|
||||
.hermes-mobile-toggle {
|
||||
display: none;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.hermes-mobile-toggle { display: inline-block; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Add hamburger button to header
|
||||
const header = document.querySelector('.vault-header');
|
||||
if (header && !document.querySelector('.hermes-mobile-toggle')) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'hermes-mobile-toggle';
|
||||
btn.textContent = '☰ Tree';
|
||||
btn.onclick = () => {
|
||||
const sidebar = document.querySelector('.vault-sidebar');
|
||||
if (sidebar) sidebar.classList.toggle('open');
|
||||
};
|
||||
header.insertBefore(btn, header.firstChild);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Init: wire everything together
|
||||
// ============================================================
|
||||
|
||||
HermesCustom.init = function() {
|
||||
console.log('[HermesCustom] init');
|
||||
HermesCustom.injectResponsiveCSS();
|
||||
HermesCustom.responsiveLayout();
|
||||
|
||||
// Hook into obv's loadFile to track current file + re-highlight
|
||||
if (typeof window.loadFile === 'function') {
|
||||
const originalLoadFile = window.loadFile;
|
||||
window.loadFile = function(path) {
|
||||
HermesCustom._lastLoadedFile = path;
|
||||
const result = originalLoadFile.apply(this, arguments);
|
||||
// Re-highlight after content renders
|
||||
setTimeout(HermesCustom.highlightCurrentNode, 300);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch graph data once for highlight logic
|
||||
fetch('/api/vault/graph')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
HermesCustom._graphData = data;
|
||||
console.log(`[HermesCustom] Loaded graph: ${data.nodes.length} nodes, ${data.edges.length} edges`);
|
||||
HermesCustom.highlightCurrentNode();
|
||||
})
|
||||
.catch(err => console.warn('[HermesCustom] Graph fetch failed:', err));
|
||||
|
||||
// Click handler + responsive layout (need to wait for Three.js to init)
|
||||
setTimeout(() => {
|
||||
HermesCustom.setupGraphClickHandler();
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
// Auto-init when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', HermesCustom.init);
|
||||
} else {
|
||||
HermesCustom.init();
|
||||
}
|
||||
|
||||
})();
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
<!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; }
|
||||
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>
|
||||
</body>
|
||||
<script src="vault-custom.js"></script>
|
||||
</html>
|
||||
Reference in New Issue
Block a user