Compare commits

...
14 Commits
Author SHA1 Message Date
agent ad59009107 Fix: preventDefault aus pointerdown entfernt (bricht iOS-Safari pointer-sequence)
Diagnose: Lukas' Browser-Logs zeigen Service-Worker-Activity
(sw.js?v=2026.5.18-...) und alle Wiki-Assets werden via SW geladen.
Wiki selbst registriert keinen SW, der SW kommt von woanders.

Echter Fix: preventDefault() in onDown() entfernt. Auf iOS Safari
verliert das Event-System die nachfolgenden Pointer-Move-Events wenn
preventDefault() auf pointerdown gefeuert wird — das Event wird quasi
'konsumiert' bevor der Browser die Touch-Sequenz weiterverarbeitet.

Außerdem: touchstart ist jetzt passive: true (preventDefault dort war
illegal und hätte Synthetic-Click ausgelöst die das Modal geschlossen
hätte). touchmove und touchend bleiben passive: false damit preventDefault
dort funktioniert um Browser-Default-Scroll zu blocken.

Lukas' Browser-Console-Log zeigte dass alle Assets via Service-Worker
geladen wurden — das deutet auf aggressive Cache-Strategie hin, weshalb
ein Hard-Reload mit Cache-Buster URL empfehlenswert ist.
2026-07-21 07:52:06 +00:00
agentandClaude 9248ae62c7 Fix: Mobile-Graph drag/zoom/navigate via touch-action + touch-event fallbacks
Diagnose: Mobile Graph sichtbar aber nicht bedienbar.
Root cause: iOS Safari interpretiert Single-Finger-Touch als Scroll-Geste
statt als Drag, weil touch-action: none fehlt.

Fixes:
1. CSS: touch-action: none auf .graph-modal-canvas, canvas und .graph-modal
   (verhindert iOS-Safari Scroll-Hijacking + Rubber-Band-Effect)
2. JS: Touch-Event-Fallbacks (touchstart/move/end/cancel) parallel zu
   pointer-events (für ältere iOS-Safari-Versionen die pointer-events
   noch nicht voll unterstützen)
3. JS: Pinch-to-Zoom via Zwei-Finger-Gestenerkennung
4. JS: Initial-Render mit Resize-Retry bis Canvas echte Dimensionen hat
   (Modal-Container kann initial 0x0 sein bevor Flex-Layout settled)
5. JS: ResizeObserver auf Canvas re-rendert bei Größenänderung
6. CSS: overscroll-behavior: contain auf Modal (kein Bounce)
7. CSS: -webkit-user-select: none + tap-highlight: transparent
   (verhindert iOS-Safari Selektions-Overlay beim Drag)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 04:52:01 +00:00
agentandClaude d0e4e8ab9e Phase 2: Touch-First CSS + Touch-First JS + Graph-Modal
Touch-First Design (style.css, 17 KB):
- Mobile-First CSS mit Safe-Area-Insets für iOS Dynamic Island
- Bottom-Nav (Files / Search / Graph) auf <768px
- Hamburger-Tree slidet von links rein mit Backdrop
- Graph-Modal als Full-Screen-Overlay mit Title-Bar + Close
- Tap-Targets ≥ 44px (--tap-min)
- Tablet-Mode (768-1023px): 2-Panel + Bottom-Graph 240px
- Desktop (≥1024px): 3-Panel mit Tree 280px / Graph 320px
- Touch-Action-Manipulation verhindert 300ms-Tap-Delay
- Word-wrap + overflow-wrap für Mobile-Content
- Frontmatter-Card als Grid-Layout
- TOC + Backlinks Styling

Touch-First JS (app.js, 21 KB):
- Tree-Render mit auf-/zuklappbaren Ordnern (auto-open concepts/entities)
- Search mit Live-Dropdown, Keyboard-Navigation (↑↓ Enter Esc)
- 3D Graph in Three.js, KEIN Auto-Rotation, drag-to-rotate + wheel-zoom
- Click-zentriert: Click auf Node → navigiert zur Page
- Modal-Close via X-Button oder Escape
- Touch/Click-Handler: pointerdown/move/up + Raycaster
- Backlinks-Section aus data.backlinks
- TOC aus h2/h3-Headings der aktuellen Page
- Hash-anchor highlighting via :target CSS

Template-Patch (generator.py):
- Search-Wrap-Container für absolute Dropdown-Position
- Bottom-Nav mit inner-Flex
- Graph-Modal-Container
- Three.js-CDN-Script im HEAD

Assets werden jetzt aus dem Repo kopiert (style.css, app.js, manifest.json)
statt eingebettete Placeholder-Strings.

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 17:16:02 +00:00
agentandClaude e57b0bc784 v0.6: Mobile-Touch-Patches für obv
Drei Probleme, drei Fixes:

1. Touch-Drag auf Graph geht nicht:
   obv's mousedown/mousemove/mouseup sind Mausevents only.
   Fix: zusätzlich touchstart/touchmove/touchend/touchcancel
   + getPos() Helper der touches[0] oder clientX nutzt
   + preventDefault() auf Drag (sonst versucht iOS zu scrollen)

2. Suche und Content-Scroll blockiert:
   obv's animateGraph() läuft mit requestAnimationFrame endlos
   und blockt den Main-Thread auf iOS. Plus iOS-300ms-Tap-Delay
   auf inline onclick=.
   Fix:
   - document.hidden check (skip rendering wenn Tab im Hintergrund)
   - autoRotate pausiert 3s nach User-Interaktion
   - html, body { touch-action: manipulation } (entfernt 300ms delay)
   - -webkit-tap-highlight-color: transparent (entfernt blauen Flash)
   - .vault-content, .vault-sidebar { touch-action: pan-y, -webkit-overflow-scrolling: touch }
   - .vault-graph canvas { touch-action: none } (damit Browser-Default Three.js-Drag nicht blockt)

Nicht angefasst: obv's Inline-onclick-Handler (Suche-Eingabe, File-Click,
WikiLinks) — diese funktionieren sobald der 300ms-Tap-Delay weg ist
und der Main-Thread nicht mehr blockt.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 04:32:19 +00:00
agentandClaude 5394f7e9bf 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>
2026-07-18 04:02:08 +00:00
agentandClaude 384281c747 v0.5.1: iOS Safe-Area + dvh fixes für Mobile-Buttons
Problem: Buttons waren auf iOS hinter der Titelleiste / Dynamic Island
versteckt, weil .vault-app die iOS Safe-Area nicht respektiert hat.

Fixes:
1. viewport-fit=cover im Meta-Tag (sicherstellt dass env() berechnet wird)
2. :root CSS-Variablen für Safe-Area-Inset (top/right/bottom/left)
3. .vault-app.hermes-rc bekommt padding-top/bottom/left/right = var(--sat) etc.
   -> Header-Bar und damit alle Buttons werden unter die iOS-Statusleiste geschoben
4. height: 100dvh mit 100vh fallback (iOS Safari Bug: 100vh enthaelt URL-Bar)

Plus: body margin/padding = 0 (war ein Bug, dass Browser-Defaults reinfunkten)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 04:14:36 +00:00
agentandClaude 5e0f8d87a8 v0.5: Mobile-Toggle-Pattern statt Bottom-Panel
Drei Probleme mit v0.4 Mobile-Layout:
1. 140px Bottom-Panel frisst vertikalen Platz auf Phone
2. User muss zur 3D-Graph scrollen, die nicht interaktiv nutzbar ist
3. Resize zwischen Mobile/Tablet/Desktop an fixen Breakpoints ist holprig

Fix v0.5: Graph wird als Full-Screen-Modal über Toggle-Button gezeigt.

Neues Layout-System:
- Desktop >=1025px: obv default 3-panel, keine Toggles sichtbar
- Compact 601-1024px: 2-panel (Tree + Content), Graph als Toggle-Modal,
  Toggle-Buttons für Tree UND Graph sichtbar im Header
- Mobile <=600px: 1-panel (nur Content), Tree als Hamburger (auto-hide
  nach File-Selection), Graph als Toggle-Modal

Plus Mobile-Overflow-Fix:
- word-wrap + overflow-wrap auf Markdown-Content
- pre/code: white-space: pre-wrap + word-break: break-word
- table: display:block + overflow-x: auto
- img: max-width: 100%

Graph-Modal:
- Full-Screen overlay mit Backdrop
- Title-Bar + Close-Button (×)
- Three.js re-rendered in modal-canvas (für isolierte Performance)
- Click-to-Navigate vom Modal zurück in die Page
- ESC-Taste schließt Modal

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-16 18:49:50 +00:00
agentandClaude 7be4656106 v0.3.1: CSS jetzt extern via <link> statt JS-injected
Drei Probleme mit der JS-Injection:
1. CSS kommt NACH First Paint → User sieht kurze obv-Layout, dann 'springt'
   zu responsive Layout (FOUC = Flash of Unstyled Content)
2. JS-Injection kann fehlschlagen (head nicht ready, Browser-Inkonsistenzen)
3. Inline <style>-Tag wird nicht gecacht, lädt bei jedem Reload

Fix: hermes-custom.css als separate Datei, geladen via
<link rel='stylesheet'> im HEAD. Server liefert sie automatisch.

Außerdem: Mobile-Mode zeigt Graph als Bottom-Panel (140px), nicht mehr
display:none. User behält Wiki-Beziehungen auch auf Phone.

vault-custom.js.js behält injectResponsiveCSS als no-op für Backward-Compat.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-16 18:23:58 +00:00
agentandClaude 4bb8134b9c v0.3.0: Responsive-Layout Fixes (Script-Position + CSS-Spezifität)
Drei Bugs gefixt die Responsive-Layout kaputt machten:

1. Script-Tag war NACH </body> platziert (HTML-invalid)
   Fix: Script-Tag ist jetzt VOR </body> im vault.html-Patch

2. CSS-Spezifität zu niedrig (nur .vault-app statt .vault-app.hermes-rc)
   Fix: 2-Klassen-Selektoren .vault-app.hermes-rc.X schlagen obv's
   Single-Class-Selektoren

3. Mobile-Overflow nicht kontrolliert
   Fix: overflow-x: hidden auf .vault-app + .vault-content,
   word-wrap + overflow-wrap für lange URLs/Codes

Außerdem:
- Init mit 50ms-Delay nach DOMContentLoaded (gibt obv Zeit für Setup)
- Hamburger-Toggle mit e.stopPropagation()
- Tablet-Graph als Bottom-Panel (220px) mit border-top statt border-left
- Mobile-Graph komplett hidden (war: nur klein)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-16 18:17:35 +00:00
agent bb9bdf817f Add scripts/hermes-wiki-serve.sh wrapper + README update
Wiki-Serve-Skript war bisher in ~/.local/bin/, jetzt auch im Repo versioniert,
damit Updates via git pull verfügbar sind.

CHANGELOG korrigiert: server.py jetzt +12 Zeilen statt +7 (Bug-Fix für trailing
'/' ist mit drin).
2026-07-16 15:29:00 +00:00
agent 59e22c3113 Fix: server.py ?file= query-Endpoint greift bei /api/vault/file/ (ohne trailing)
Bug: obv's do_GET strippt trailing '/' via path.rstrip('/'), sodass
startswith('/api/vault/file/') nie triggert für /api/vault/file/. HermesCustom
kann daher ?file= nicht nutzen.

Fix: explizit beide Pfade matchen (mit und ohne trailing '/').

Außerdem: Wiki-Serve-Skript zeigt jetzt standardmäßig auf ~/repos/hermes-wiki-viewer
statt auf ~/.local/share/hermes-wiki/owv, sodass Lukas-Customizations automatisch
aktiv sind nach Skript-Update.
2026-07-16 15:28:30 +00:00
agent d88a987d94 Merge: Lukas README overrides Gitea auto-init README 2026-07-16 15:18:00 +00:00
agentandClaude 677a8dde4f 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>
2026-07-16 15:17:16 +00:00
12 changed files with 3036 additions and 2 deletions
+24
View File
@@ -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/
+56
View File
@@ -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`).
+21
View File
@@ -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.
+116 -2
View File
@@ -1,3 +1,117 @@
# hermes-wiki-viewer
# hermes-wiki-static
Customizable Obsidian-Vault viewer with 3D Graph, current-page highlighting, responsive layout. Fork of DanielCheer/obsidian-web-viewer.
Static HTML wiki generator for Lukas Huber's karpathy-style Obsidian-vault.
## What this is
A Python tool that watches `/home/admin/my-karpathy-wiki/` for changes and
regenerates static HTML files on disk. A simple HTTP server serves them on
loopback port 8765; Tailscale exposes it to the tailnet.
## Why this exists
We previously used `DanielCheer/obsidian-web-viewer` (forked as
`hermes-wiki-viewer`). It had three blockers:
- **No touch support on iOS Safari** — click handlers don't work reliably
- **3D-Graph auto-rotates** — prevents node selection via tap
- **Fixed 260px Graph column** — wastes horizontal space
This tool replaces obv with our own renderer:
- **Touch-first design** — bottom-nav on mobile, hamburger-tree, FAB+modal graph
- **Static HTML** — each page is a real URL, deep-linkable, PWA-installable
- **No auto-rotation** — graph is static, click-to-rotate, click-to-navigate
- **Native-feeling** — service worker, pull-to-refresh, swipe-back
## Architecture
```
my-karpathy-wiki/*.md (input)
|
v
[watchdog observer] (auto-regen on .md change)
|
v
generator.py (Python: markdown + frontmatter)
|
v
~/.local/share/hermes-wiki/site/ (static HTML output)
|
v
python3 http.server (loopback:8765)
|
v
tailscale serve (https://openclaw.wholphin-musical.ts.net/)
```
## Files
- `generator.py` — daemon with watchdog + HTTP server
- `requirements.txt` — markdown, pyyaml, watchdog, python-frontmatter
## Run
```bash
# Install deps (one-time, system Python)
pip install --break-system-packages markdown pyyaml watchdog python-frontmatter
# Start daemon
python3 generator.py --start
# Status / Stop
python3 generator.py --status
python3 generator.py --stop
# One-shot regen (no watcher, no HTTP server)
python3 generator.py --once
```
## Output structure
```
site/
├── index.html (redirect to first note)
├── concepts/<slug>.html (one file per .md)
├── entities/<slug>.html
├── ...
└── __/
├── style.css
├── app.js
├── data.js (all metadata inlined for offline)
├── tree.json
├── graph.json
├── tags.json
├── backlinks.json
└── manifest.json (PWA)
```
## WikiLink syntax
`[[entity-name]]` or `[[entity-name|display text]]` resolves to a real
`<a class="wikilink" href="/path/to/entity.html">` if the target exists,
otherwise `<a class="wikilink-missing">` (greyed out).
## Frontmatter
```yaml
---
title: My Note
type: concept
status: stable
updated: 2026-07-15
sources:
- https://example.com
tags:
- hermes
- architecture
---
```
## Known limitations (Phase 1)
- CSS is placeholder — touch design comes in Phase 2
- JS is placeholder — search/tree/graph render comes in Phase 2
- Bottom-Nav template is there but not styled
- 5 YAML files in the wiki have malformed frontmatter (parser warnings)
- Graph only shows 27 edges — some WikiLinks not resolving due to those YAML issues
+754
View File
@@ -0,0 +1,754 @@
/* Hermes Wiki — Touch-First JS for Phase 2
*
* Features:
* - Tree render with collapsible folders
* - Search with live results dropdown (title + tags)
* - 3D Graph in modal (Three.js, NO auto-rotation, click-to-navigate)
* - Mobile bottom-nav (Files, Search, Graph)
* - Hamburger tree + FAB graph on mobile
* - Backdrop click closes overlays
* - Backlinks rendering
* - Hash-based navigation (forward/back via pushState)
*/
(function() {
'use strict';
const data = window.HERMES_DATA;
if (!data) {
console.error('[Hermes] HERMES_DATA missing');
return;
}
const currentSlug = window.location.pathname
.replace(/^\//, '')
.replace(/\.html$/, '');
// ============================================================
// Tree render
// ============================================================
function renderTree(node, container, basePath) {
const wrap = document.createElement('div');
wrap.className = 'tree-root';
if (node.folders && node.folders.length > 0) {
for (const folder of node.folders) {
const fEl = document.createElement('div');
fEl.className = 'tree-folder';
if (folder.name === 'concepts' || folder.name === 'entities') {
fEl.classList.add('open'); // auto-open most-used folders
}
const header = document.createElement('div');
header.className = 'tree-folder-header';
header.innerHTML = `<span class="arrow">▶</span><span>${escapeHtml(folder.name)}</span>`;
header.onclick = () => fEl.classList.toggle('open');
fEl.appendChild(header);
const children = document.createElement('div');
children.className = 'tree-children';
fEl.appendChild(children);
renderTree(folder, children, basePath + '/' + folder.name);
wrap.appendChild(fEl);
}
}
if (node.files && node.files.length > 0) {
for (const file of node.files) {
const a = document.createElement('a');
a.href = '/' + file.slug + '.html';
a.className = 'tree-file';
if (file.slug === currentSlug) a.classList.add('current');
a.innerHTML = `<span class="tree-file-icon">📄</span><span>${escapeHtml(file.title)}</span>`;
wrap.appendChild(a);
}
}
container.appendChild(wrap);
}
// ============================================================
// Search
// ============================================================
function setupSearch() {
const input = document.getElementById('search');
if (!input) return;
// Wrap with relative parent for absolute dropdown
const wrapper = document.createElement('div');
wrapper.className = 'search-wrap';
input.parentNode.insertBefore(wrapper, input);
wrapper.appendChild(input);
input.style.paddingLeft = '36px';
const dropdown = document.createElement('div');
dropdown.className = 'search-results';
wrapper.appendChild(dropdown);
let activeIdx = -1;
input.addEventListener('input', (e) => {
const q = e.target.value.toLowerCase().trim();
if (!q) {
dropdown.classList.remove('open');
return;
}
const results = data.pages.filter(p =>
(p.title || '').toLowerCase().includes(q) ||
(p.slug || '').toLowerCase().includes(q) ||
(p.tags || []).some(t => t.toLowerCase().includes(q))
).slice(0, 20);
dropdown.innerHTML = '';
if (results.length === 0) {
dropdown.innerHTML = '<div class="search-empty">Keine Treffer</div>';
} else {
for (let i = 0; i < results.length; i++) {
const r = results[i];
const a = document.createElement('a');
a.href = '/' + r.slug + '.html';
a.className = 'search-result' + (i === activeIdx ? ' active' : '');
const tagsHtml = (r.tags || []).slice(0, 3).map(t => `<span class="search-result-tag">${escapeHtml(t)}</span>`).join('');
a.innerHTML = `<span class="search-result-title">${escapeHtml(r.title)}</span><span class="search-result-path">${tagsHtml}${escapeHtml(r.slug)}</span>`;
a.onmouseenter = () => {
activeIdx = i;
updateActive();
};
dropdown.appendChild(a);
}
}
dropdown.classList.add('open');
function updateActive() {
[...dropdown.querySelectorAll('.search-result')].forEach((el, idx) => {
el.classList.toggle('active', idx === activeIdx);
});
}
});
input.addEventListener('keydown', (e) => {
const items = [...dropdown.querySelectorAll('.search-result')];
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIdx = Math.min(activeIdx + 1, items.length - 1);
[...dropdown.querySelectorAll('.search-result')].forEach((el, idx) =>
el.classList.toggle('active', idx === activeIdx)
);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIdx = Math.max(activeIdx - 1, 0);
[...dropdown.querySelectorAll('.search-result')].forEach((el, idx) =>
el.classList.toggle('active', idx === activeIdx)
);
} else if (e.key === 'Enter' && activeIdx >= 0 && items[activeIdx]) {
e.preventDefault();
items[activeIdx].click();
} else if (e.key === 'Escape') {
input.value = '';
dropdown.classList.remove('open');
}
});
// Close dropdown on outside click
document.addEventListener('click', (e) => {
if (!wrapper.contains(e.target)) {
dropdown.classList.remove('open');
}
});
}
// ============================================================
// Graph (Three.js) — modal, NO auto-rotation, click-to-navigate
// ============================================================
let graphState = null;
function buildGraph(canvas, container) {
if (!window.THREE) {
console.error('[Hermes] THREE.js not loaded');
return;
}
const ctx = {
scene: new THREE.Scene(),
camera: null,
renderer: new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }),
nodes: new Map(),
edges: [],
currentSlug: currentSlug,
isDragging: false,
dragStart: { x: 0, y: 0 },
cameraPos: { x: 0, y: 0, z: 1200 },
cameraTarget: { x: 0, y: 0, z: 0 },
needsRender: true,
hoveredNode: null,
};
const w = canvas.clientWidth || canvas.width || 600;
const h = canvas.clientHeight || canvas.height || 400;
ctx.renderer.setSize(w, h, false);
ctx.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
ctx.renderer.setClearColor(0x0e0e1a, 1);
ctx.camera = new THREE.PerspectiveCamera(60, w / h, 1, 10000);
ctx.camera.position.set(0, 0, 1200);
ctx.camera.lookAt(0, 0, 0);
// Build nodes via deterministic layout (Fruchterman-Reingold style)
const graph = data.graph;
const slugs = graph.nodes.map(n => n.id);
const positions = computeLayout(slugs, graph.edges, 600);
// Group nodes by folder for visual variety
const folderColors = {};
let colorIdx = 0;
const palette = [0x89b4fa, 0xa6e3a1, 0xf9e2af, 0xf5c2e7, 0x94e2d5, 0xfab387, 0xcba6f7];
for (const slug of slugs) {
const folder = slug.split('/')[0] || 'root';
if (!(folder in folderColors)) {
folderColors[folder] = palette[colorIdx++ % palette.length];
}
}
// Current page edges for highlight
const currentEdges = new Set();
const currentReverse = new Set();
if (graph.edges) {
for (const e of graph.edges) {
if (e.source === currentSlug) {
currentEdges.add(e.target);
currentReverse.add(e.source);
}
if (e.target === currentSlug) {
currentEdges.add(e.source);
}
}
}
// Build meshes
const sphereGeo = new THREE.SphereGeometry(5, 12, 12);
for (const node of graph.nodes) {
const isCurrent = node.id === currentSlug;
const isConnected = currentEdges.has(node.id);
const folder = node.id.split('/')[0] || 'root';
const baseColor = folderColors[folder];
let color = baseColor;
let opacity = 0.5;
let size = 1;
if (isCurrent) {
color = 0xFFD700;
opacity = 1;
size = 2.2;
} else if (isConnected) {
color = 0xFFD700;
opacity = 0.95;
size = 1.5;
} else if (currentSlug) {
opacity = 0.25;
} else {
opacity = 0.6;
}
const mat = new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity,
depthWrite: isCurrent || isConnected,
});
const mesh = new THREE.Mesh(sphereGeo, mat);
mesh.position.set(positions[node.id].x, positions[node.id].y, positions[node.id].z);
mesh.scale.setScalar(size);
mesh.userData = { nodeId: node.id, baseScale: size, baseOpacity: opacity, isCurrent, isConnected };
ctx.scene.add(mesh);
ctx.nodes.set(node.id, mesh);
}
// Build edges
const edgeMat = new THREE.LineBasicMaterial({
color: 0x6c7086,
transparent: true,
opacity: 0.35,
});
const currentEdgeMat = new THREE.LineBasicMaterial({
color: 0xFFD700,
transparent: true,
opacity: 0.85,
});
for (const e of graph.edges) {
const src = ctx.nodes.get(e.source);
const tgt = ctx.nodes.get(e.target);
if (!src || !tgt) continue;
const lineGeo = new THREE.BufferGeometry().setFromPoints([src.position, tgt.position]);
const isCurrentEdge = (e.source === currentSlug || e.target === currentSlug);
const line = new THREE.Line(lineGeo, isCurrentEdge ? currentEdgeMat : edgeMat);
ctx.scene.add(line);
}
// Render only on demand (NO animation loop!)
function render() {
ctx.renderer.render(ctx.scene, ctx.camera);
}
// Manual rotation: drag to rotate (pointer + touch fallback for older iOS)
let isDragging = false;
let lastX = 0, lastY = 0;
let dragStartTime = 0;
let dragStartXY = { x: 0, y: 0 };
let moved = false;
function getXY(e, canvas) {
let clientX, clientY;
if (e.touches && e.touches.length > 0) {
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else if (e.changedTouches && e.changedTouches.length > 0) {
clientX = e.changedTouches[0].clientX;
clientY = e.changedTouches[0].clientY;
} else {
clientX = e.clientX;
clientY = e.clientY;
}
const rect = canvas.getBoundingClientRect();
const x = ((clientX - rect.left) / rect.width) * 2 - 1;
const y = -((clientY - rect.top) / rect.height) * 2 + 1;
return { x, y, clientX, clientY };
}
function rotateCamera(dx, dy) {
const rotSpeed = 0.005;
const offset = new THREE.Vector3().subVectors(ctx.camera.position, ctx.cameraTarget);
const spherical = new THREE.Spherical().setFromVector3(offset);
spherical.theta -= dx * rotSpeed;
spherical.phi -= dy * rotSpeed;
spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, spherical.phi));
offset.setFromSpherical(spherical);
ctx.camera.position.copy(ctx.cameraTarget).add(offset);
ctx.camera.lookAt(ctx.cameraTarget);
}
function onDown(e) {
// Don't preventDefault here — breaks iOS-Safari pointer sequence
const p = getXY(e, canvas);
isDragging = true;
moved = false;
dragStartTime = Date.now();
dragStartXY = { x: p.clientX, y: p.clientY };
lastX = p.clientX;
lastY = p.clientY;
if (canvas.setPointerCapture && e.pointerId !== undefined) {
try { canvas.setPointerCapture(e.pointerId); } catch {}
}
}
function onMove(e) {
if (!isDragging) return;
e.preventDefault();
const p = getXY(e, canvas);
const dx = p.clientX - lastX;
const dy = p.clientY - lastY;
if (Math.abs(p.clientX - dragStartXY.x) + Math.abs(p.clientY - dragStartXY.y) > 8) {
moved = true;
}
rotateCamera(dx, dy);
lastX = p.clientX;
lastY = p.clientY;
render();
}
function onUp(e) {
if (!isDragging) return;
isDragging = false;
const elapsed = Date.now() - dragStartTime;
// Tap = quick release without much movement
if (!moved && elapsed < 500) {
const p = getXY(e.changedTouches ? { changedTouches: e.changedTouches } : e, canvas);
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2(p.x, p.y);
raycaster.setFromCamera(mouse, ctx.camera);
const meshes = Array.from(ctx.nodes.values());
const intersects = raycaster.intersectObjects(meshes);
if (intersects.length > 0) {
const nodeId = intersects[0].object.userData.nodeId;
if (nodeId) {
const modal = document.querySelector('.graph-modal');
if (modal) modal.classList.remove('open');
window.location.href = '/' + nodeId + '.html';
return;
}
}
}
}
// Pointer events (modern: iOS 13+, all modern browsers)
canvas.addEventListener('pointerdown', onDown);
canvas.addEventListener('pointermove', onMove);
canvas.addEventListener('pointerup', onUp);
canvas.addEventListener('pointercancel', onUp);
canvas.addEventListener('pointerleave', onUp);
// Touch events fallback (older iOS Safari 12-)
canvas.addEventListener('touchstart', onDown, { passive: true });
canvas.addEventListener('touchmove', onMove, { passive: false });
canvas.addEventListener('touchend', onUp, { passive: false });
canvas.addEventListener('touchcancel', onUp, { passive: false });
// Mouse events fallback (older desktop browsers)
canvas.addEventListener('mousedown', onDown);
canvas.addEventListener('mousemove', onMove);
canvas.addEventListener('mouseup', onUp);
canvas.addEventListener('mouseleave', onUp);
// Zoom with wheel (desktop) + pinch (mobile)
let pinchStartDist = null;
function getPinchDist(e) {
if (e.touches && e.touches.length >= 2) {
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
return Math.sqrt(dx*dx + dy*dy);
}
return null;
}
function onWheel(e) {
e.preventDefault();
const dir = e.deltaY > 0 ? 1.1 : 0.9;
ctx.camera.position.multiplyScalar(dir);
ctx.camera.lookAt(ctx.cameraTarget);
render();
}
canvas.addEventListener('wheel', onWheel, { passive: false });
function onTouchMoveForPinch(e) {
const dist = getPinchDist(e);
if (dist !== null) {
e.preventDefault();
if (pinchStartDist === null) {
pinchStartDist = dist;
} else {
const ratio = pinchStartDist / dist;
if (Math.abs(ratio - 1) > 0.01) {
ctx.camera.position.multiplyScalar(1 / ratio);
ctx.camera.lookAt(ctx.cameraTarget);
pinchStartDist = dist;
render();
}
}
}
}
function onTouchEndForPinch(e) {
if (e.touches && e.touches.length < 2) {
pinchStartDist = null;
}
}
canvas.addEventListener('touchmove', onTouchMoveForPinch, { passive: false });
canvas.addEventListener('touchend', onTouchEndForPinch, { passive: false });
// Resize handling — wait for actual visible dimensions
function safeResize() {
const w2 = canvas.clientWidth || canvas.parentElement.clientWidth;
const h2 = canvas.clientHeight || canvas.parentElement.clientHeight;
if (w2 === 0 || h2 === 0) return;
ctx.camera.aspect = w2 / h2;
ctx.camera.updateProjectionMatrix();
ctx.renderer.setSize(w2, h2, false);
render();
}
function onResize() {
safeResize();
}
window.addEventListener('resize', onResize);
// Force-initial render with retry until canvas has dimensions
let resizeRetries = 0;
function tryInitialRender() {
safeResize();
if ((canvas.clientWidth === 0 || canvas.clientHeight === 0) && resizeRetries < 10) {
resizeRetries++;
requestAnimationFrame(tryInitialRender);
}
}
requestAnimationFrame(tryInitialRender);
// Listen for modal-open to re-check sizing
if (container.classList.contains('graph-modal')) {
const observer = new ResizeObserver(() => safeResize());
observer.observe(canvas);
ctx.cleanupObserver = () => observer.disconnect();
}
// Cleanup function
ctx.render = render;
ctx.cleanup = () => {
canvas.removeEventListener('pointerdown', onDown);
canvas.removeEventListener('pointermove', onMove);
canvas.removeEventListener('pointerup', onUp);
canvas.removeEventListener('pointercancel', onUp);
canvas.removeEventListener('pointerleave', onUp);
canvas.removeEventListener('touchstart', onDown);
canvas.removeEventListener('touchmove', onMove);
canvas.removeEventListener('touchend', onUp);
canvas.removeEventListener('touchcancel', onUp);
canvas.removeEventListener('mousedown', onDown);
canvas.removeEventListener('mousemove', onMove);
canvas.removeEventListener('mouseup', onUp);
canvas.removeEventListener('mouseleave', onUp);
canvas.removeEventListener('wheel', onWheel);
canvas.removeEventListener('touchmove', onTouchMoveForPinch);
canvas.removeEventListener('touchend', onTouchEndForPinch);
window.removeEventListener('resize', onResize);
if (ctx.cleanupObserver) ctx.cleanupObserver();
};
return ctx;
}
function computeLayout(nodeIds, edges, radius) {
// Simple force-directed-ish: angle-distributed with edge-based clustering
const positions = {};
const N = nodeIds.length;
if (N === 0) return positions;
// Initialize: distribute on sphere
for (let i = 0; i < N; i++) {
const phi = Math.acos(-1 + (2 * i) / N);
const theta = Math.sqrt(N * Math.PI) * phi;
positions[nodeIds[i]] = {
x: radius * Math.cos(theta) * Math.sin(phi),
y: radius * Math.sin(theta) * Math.sin(phi),
z: radius * Math.cos(phi),
};
}
// 1-2 iterations of relaxation
for (let iter = 0; iter < 2; iter++) {
// Repulsion
for (let i = 0; i < N; i++) {
const a = nodeIds[i];
let fx = 0, fy = 0, fz = 0;
for (let j = 0; j < N; j++) {
if (i === j) continue;
const b = nodeIds[j];
const dx = positions[a].x - positions[b].x;
const dy = positions[a].y - positions[b].y;
const dz = positions[a].z - positions[b].z;
const d2 = dx*dx + dy*dy + dz*dz + 0.01;
const f = 50 / d2;
fx += dx * f;
fy += dy * f;
fz += dz * f;
}
positions[a].x += fx * 0.01;
positions[a].y += fy * 0.01;
positions[a].z += fz * 0.01;
}
}
return positions;
}
// ============================================================
// Backlinks render
// ============================================================
function renderBacklinks() {
const container = document.getElementById('backlinks');
if (!container) return;
const links = (data.backlinks && data.backlinks[currentSlug]) || [];
if (links.length === 0) {
container.innerHTML = '';
return;
}
const html = [
'<div class="note-backlinks-title">Backlinks (' + links.length + ')</div>',
'<ul>' + links.map(slug => {
const page = data.pages.find(p => p.slug === slug);
const title = page ? page.title : slug;
return `<li><a href="/${slug}.html">${escapeHtml(title)}</a></li>`;
}).join('') + '</ul>'
].join('');
container.innerHTML = html;
}
// ============================================================
// TOC (extract from rendered headings)
// ============================================================
function renderTOC() {
const container = document.getElementById('toc');
if (!container) return;
const headings = [...document.querySelectorAll('.note-body h2, .note-body h3')];
if (headings.length === 0) {
container.innerHTML = '';
return;
}
const items = headings.map(h => {
const level = h.tagName === 'H2' ? 2 : 3;
const text = h.textContent.replace(/\s*¶\s*$/, '').trim();
const id = h.id || text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
h.id = id;
return `<li style="margin-left: ${(level - 2) * 16}px"><a href="#${id}">${escapeHtml(text)}</a></li>`;
}).join('');
container.innerHTML = `<div class="note-toc-title">Inhalt</div><ul>${items}</ul>`;
}
// ============================================================
// Mobile bottom-nav handlers
// ============================================================
function setupBottomNav() {
document.querySelectorAll('.app-bottom-nav button').forEach(btn => {
btn.addEventListener('click', (e) => {
const action = btn.dataset.action;
if (action === 'toggle-tree') {
toggleTree();
} else if (action === 'toggle-graph') {
openGraphModal();
} else if (action === 'focus-search') {
const s = document.getElementById('search');
if (s) {
s.scrollIntoView({ behavior: 'smooth', block: 'start' });
s.focus();
}
}
});
});
}
function toggleTree() {
const tree = document.querySelector('.app-tree');
const backdrop = document.querySelector('.backdrop');
if (!tree) return;
const isOpen = tree.classList.contains('open');
tree.classList.toggle('open');
if (backdrop) backdrop.classList.toggle('open', !isOpen);
}
function openGraphModal() {
const modal = document.querySelector('.graph-modal');
if (!modal) return;
modal.classList.add('open');
document.body.style.overflow = 'hidden';
// Build graph in modal canvas (once)
const canvas = modal.querySelector('canvas');
if (canvas && !canvas.dataset.built) {
canvas.dataset.built = '1';
// Wait for layout
requestAnimationFrame(() => {
graphState = buildGraph(canvas, modal);
});
} else if (canvas && graphState) {
graphState.render();
}
}
function closeGraphModal() {
const modal = document.querySelector('.graph-modal');
if (!modal) return;
modal.classList.remove('open');
document.body.style.overflow = '';
}
// ============================================================
// Sidebar/Desktop graph
// ============================================================
function setupDesktopGraph() {
const graphEl = document.querySelector('.app-graph');
if (!graphEl) return;
const canvas = document.createElement('canvas');
graphEl.appendChild(canvas);
requestAnimationFrame(() => {
graphState = buildGraph(canvas, graphEl);
});
}
// ============================================================
// Top-bar buttons (toolbar actions)
// ============================================================
function setupToolbar() {
document.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'toggle-tree') {
toggleTree();
} else if (action === 'toggle-graph') {
const isMobile = window.innerWidth < 768;
if (isMobile) openGraphModal();
else if (graphState) graphState.render();
} else if (action === 'focus-search') {
const s = document.getElementById('search');
if (s) s.focus();
}
});
}
// ============================================================
// Modal close handlers
// ============================================================
function setupModalClose() {
const closeBtn = document.querySelector('.graph-modal-close');
if (closeBtn) closeBtn.onclick = closeGraphModal;
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const modal = document.querySelector('.graph-modal');
if (modal && modal.classList.contains('open')) closeGraphModal();
}
});
}
// ============================================================
// Backdrop
// ============================================================
function setupBackdrop() {
let backdrop = document.querySelector('.backdrop');
if (!backdrop) {
backdrop = document.createElement('div');
backdrop.className = 'backdrop';
document.body.appendChild(backdrop);
}
backdrop.onclick = () => {
const tree = document.querySelector('.app-tree');
if (tree) tree.classList.remove('open');
backdrop.classList.remove('open');
};
}
// ============================================================
// Utility
// ============================================================
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
})[c]);
}
// ============================================================
// Init
// ============================================================
function init() {
const treeEl = document.getElementById('tree');
if (treeEl && data.tree) renderTree(data.tree, treeEl);
setupSearch();
setupToolbar();
setupModalClose();
setupBackdrop();
setupBottomNav();
setupDesktopGraph();
renderTOC();
renderBacklinks();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
+95
View File
@@ -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)
+120
View File
@@ -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 (768px1023px):** 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.
+769
View File
@@ -0,0 +1,769 @@
#!/tmp/ea-venv/bin/python3
"""
hermes-wiki-generator: Static HTML generator for Lukas' karpathy-style Wiki.
Architecture:
1. Watchdog observes /home/admin/my-karpathy-wiki/ for changes
2. On .md change → re-render single HTML file to /home/admin/.local/share/hermes-wiki/site/
3. On Wiki startup → generate tree.json, graph.json, tag-cloud.json (one-time)
4. Python http.server serves /home/admin/.local/share/hermes-wiki/site/ on 127.0.0.1:8765
Why static HTML:
- Mobile-first: load once, works offline (with service worker)
- Fast: zero JS for content rendering, JSON data lazy-loaded
- Bookmarkable: each page is a real URL like /concepts/agent-reference-model.html
- Touch-friendly: no animation that fights tap-targets
Run:
python3 ~/repos/hermes-wiki-static/generator.py --start
python3 ~/repos/hermes-wiki-static/generator.py --stop
python3 ~/repos/hermes-wiki-static/generator.py --status
python3 ~/repos/hermes-wiki-static/generator.py --once (one-shot regen, no watcher)
"""
import argparse
import json
import logging
import os
import re
import signal
import socket
import sys
import time
from http.server import HTTPServer, SimpleHTTPRequestHandler
from pathlib import Path
from threading import Lock, Thread
from urllib.parse import unquote
import frontmatter
import markdown as md
import yaml
from watchdog.events import FileSystemEvent, FileSystemEventHandler
from watchdog.observers import Observer
# ============================================================
# Configuration
# ============================================================
WIKI_DIR = Path("/home/admin/my-karpathy-wiki")
SITE_DIR = Path("/home/admin/.local/share/hermes-wiki/site")
LOG_DIR = Path("/home/admin/.local/share/hermes-wiki/log")
PID_FILE = Path("/tmp/hermes-wiki-static.pid")
PORT = 8765
BIND = "127.0.0.1"
LOG_DIR.mkdir(parents=True, exist_ok=True)
SITE_DIR.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(LOG_DIR / "generator.log"),
logging.StreamHandler(sys.stdout),
],
)
log = logging.getLogger("hermes-wiki")
# ============================================================
# Markdown → HTML rendering
# ============================================================
# WikiLink regex: [[entity-name]] or [[path/to/entity|display-text]]
WIKILINK_RE = re.compile(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]")
# Hashtag: #tag-name (max 30 chars, alphanumeric + dash)
TAG_RE = re.compile(r"(?<![\w/])#([a-zA-Z][a-z0-9-]{1,30})")
# Markdown extensions: TOC, tables, fenced code, footnotes, attr lists
MD_EXTENSIONS = [
"toc",
"tables",
"fenced_code",
"footnotes",
"attr_list",
"def_list",
"sane_lists",
]
def slugify(path: Path) -> str:
"""Convert /concepts/agent-reference-model.md → concepts/agent-reference-model"""
rel = path.relative_to(WIKI_DIR).with_suffix("")
return str(rel).replace(os.sep, "/")
def read_markdown(path: Path) -> frontmatter.Post:
"""Read .md with YAML frontmatter, fallback to plain text."""
try:
return frontmatter.load(path)
except yaml.YAMLError as e:
log.warning(f"YAML-Fehler in {path}: {e} — versuche ohne Frontmatter")
text = path.read_text(encoding="utf-8", errors="replace")
return frontmatter.Post(text)
def resolve_wikilinks(text: str, slug_to_path: dict) -> str:
"""Replace [[entity-name]] with <a href="/path/entity.html">entity-name</a>."""
def replace(m: re.Match) -> str:
target = m.group(1).strip()
display = (m.group(2) or target).strip()
# Try exact match first
if target in slug_to_path:
slug = slug_to_path[target]
return f'<a class="wikilink" href="/{quote(slug)}.html">{display}</a>'
# Try with .md suffix stripped
target_no_ext = target.replace(".md", "")
if target_no_ext in slug_to_path:
slug = slug_to_path[target_no_ext]
return f'<a class="wikilink" href="/{quote(slug)}.html">{display}</a>'
# Not found — render as broken link
return f'<a class="wikilink wikilink-missing" href="/__missing.html?target={quote(target)}">{display}</a>'
return WIKILINK_RE.sub(replace, text)
def quote(s: str) -> str:
"""URL-encode path components."""
import urllib.parse
return urllib.parse.quote(s, safe="/-_~.")
def render_page(md_path: Path, slug_to_path: dict, all_meta: list) -> dict:
"""Render one .md to (HTML + metadata). Returns dict with html, title, slug, frontmatter."""
post = read_markdown(md_path)
slug = slugify(md_path)
# Replace wikilinks before markdown rendering
body_with_links = resolve_wikilinks(post.content, slug_to_path)
html_body = md.markdown(
body_with_links,
extensions=MD_EXTENSIONS,
extension_configs={"toc": {"permalink": True}},
)
# Extract title from H1 if not in frontmatter
title = post.metadata.get("title") or md_path.stem.replace("-", " ").title()
if not post.metadata.get("title"):
h1_match = re.search(r"<h1[^>]*>(.*?)</h1>", html_body, re.IGNORECASE)
if h1_match:
title = re.sub(r"<[^>]+>", "", h1_match.group(1))
return {
"slug": slug,
"title": title,
"html": html_body,
"frontmatter": dict(post.metadata),
"path": str(md_path.relative_to(WIKI_DIR)),
}
# ============================================================
# Index data: tree, graph, tags, backlinks
# ============================================================
def build_index(slug_meta_list: list) -> dict:
"""Build tree.json, graph.json, tags.json, backlinks.json."""
slug_to_meta = {s["slug"]: s for s in slug_meta_list}
# Tree: hierarchical folder structure
tree = {"name": "Vault", "children": {}, "files": []}
for s in slug_meta_list:
parts = s["slug"].split("/")
node = tree
for folder in parts[:-1]:
node = node["children"].setdefault(folder, {"name": folder, "children": {}, "files": []})
node["files"].append({"name": parts[-1], "title": s["title"], "slug": s["slug"]})
# Recursively convert dict-of-children to list-of-children (easier JSON)
def tree_to_list(node):
result = {
"name": node["name"],
"files": sorted(node["files"], key=lambda f: f["name"].lower()),
}
result["folders"] = sorted(
[tree_to_list(child) for child in node["children"].values()],
key=lambda f: f["name"].lower()
)
return result
tree_list = tree_to_list(tree)
# Graph: extract [[wikilinks]] from each rendered HTML
nodes = []
edges = []
for s in slug_meta_list:
nodes.append({"id": s["slug"], "title": s["title"], "size": 1})
# Find all wikilinks in the source
wikilinks = WIKILINK_RE.findall(s["html"] + " " + str(s["frontmatter"]))
for target, _disp in wikilinks:
target_slug = target.replace(".md", "")
if target_slug in slug_to_meta:
edges.append({"source": s["slug"], "target": target_slug})
# Tags: collect from frontmatter.tags + inline #tags
tags = {} # tag → [slugs]
for s in slug_meta_list:
# Frontmatter tags
fm_tags = s["frontmatter"].get("tags", [])
if isinstance(fm_tags, str):
fm_tags = [t.strip() for t in fm_tags.split(",")]
for tag in fm_tags or []:
tags.setdefault(str(tag).lower(), []).append(s["slug"])
# Inline #tags in content
inline_tags = TAG_RE.findall(s["html"])
for tag in inline_tags:
tags.setdefault(tag.lower(), []).append(s["slug"])
# Backlinks: for each page, list of pages that link TO it
backlinks = {s["slug"]: [] for s in slug_meta_list}
for edge in edges:
target = edge["target"]
source = edge["source"]
if source != target: # skip self-links
backlinks[target].append(source)
return {
"tree": tree_list,
"graph": {"nodes": nodes, "edges": edges},
"tags": tags,
"backlinks": backlinks,
}
# ============================================================
# HTML page template
# ============================================================
PAGE_TEMPLATE = """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0a0a14">
<title>{title} · Hermes Wiki</title>
<link rel="stylesheet" href="/__/style.css">
<link rel="manifest" href="/__/manifest.json">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📓</text></svg>">
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
</head>
<body class="page-{kind}">
<div class="app-shell">
<header class="app-header">
<button class="icon-btn menu-toggle" aria-label="Toggle tree" data-action="toggle-tree">☰</button>
<a href="/__/index.html" class="brand">📓 Hermes Wiki</a>
<div class="search-wrap">
<input type="search" id="search" placeholder="Suchen…" aria-label="Search notes">
</div>
<button class="icon-btn graph-toggle" aria-label="Toggle graph" data-action="toggle-graph">⊕</button>
</header>
<aside class="app-tree" id="tree" aria-label="File tree"></aside>
<main class="app-content">
<article class="note">
{frontmatter_card}
<div class="note-body">{body}</div>
<nav class="note-toc" id="toc" aria-label="Table of contents"></nav>
<section class="note-backlinks" id="backlinks" aria-label="Backlinks"></section>
<footer class="note-footer">
<span class="note-path">{path}</span>
</footer>
</article>
</main>
<aside class="app-graph" id="graph" aria-label="3D graph"></aside>
<nav class="app-bottom-nav" aria-label="Bottom navigation">
<div class="app-bottom-nav-inner">
<button data-action="toggle-tree"><span class="icon">☰</span><small>Files</small></button>
<button data-action="focus-search"><span class="icon">🔍</span><small>Search</small></button>
<button data-action="toggle-graph"><span class="icon">⊕</span><small>Graph</small></button>
</div>
</nav>
<div class="graph-modal" aria-label="3D graph (full screen)">
<div class="graph-modal-header">
<span class="graph-modal-title">GRAPH VIEW · Drag to rotate · Tap a node to navigate</span>
<button class="graph-modal-close" aria-label="Close">×</button>
</div>
<div class="graph-modal-canvas"><canvas></canvas></div>
<div class="graph-legend">
<div class="graph-legend-title">Legend</div>
<div class="graph-legend-row"><span class="graph-legend-dot" style="background:#FFD700"></span>Current page</div>
<div class="graph-legend-row"><span class="graph-legend-dot" style="background:#FFD700;opacity:0.8"></span>Connected</div>
<div class="graph-legend-row"><span class="graph-legend-dot" style="background:#6c7086;opacity:0.5"></span>Other notes</div>
</div>
</div>
</div>
<script src="/__/data.js"></script>
<script src="/__/app.js"></script>
</body>
</html>"""
def render_frontmatter_card(meta: dict) -> str:
"""Render frontmatter as a Catppuccin-styled card."""
if not meta:
return ""
rows = []
for key, value in meta.items():
if key in ("title", "tags"):
continue # shown elsewhere
if isinstance(value, list):
value = ", ".join(str(v) for v in value)
rows.append(f'<div class="meta-row"><span class="meta-key">{key}</span><span class="meta-value">{value}</span></div>')
if not rows:
return ""
return f'<aside class="meta-card">{"".join(rows)}</aside>'
def write_page(meta: dict, site_dir: Path) -> None:
"""Write a single HTML page to SITE_DIR/<slug>.html."""
target = site_dir / (quote(meta["slug"]) + ".html")
target.parent.mkdir(parents=True, exist_ok=True)
kind = "concept" if "concepts" in meta["slug"] else \
"entity" if "entities" in meta["slug"] else \
"scratch" if "scratch" in meta["slug"] else "note"
html = PAGE_TEMPLATE.format(
title=meta["title"],
body=meta["html"],
frontmatter_card=render_frontmatter_card(meta["frontmatter"]),
kind=kind,
path=meta["path"],
)
target.write_text(html, encoding="utf-8")
def write_index_files(index_data: dict, site_dir: Path) -> None:
"""Write tree.json, graph.json, tags.json, backlinks.json."""
assets_dir = site_dir / "__"
assets_dir.mkdir(parents=True, exist_ok=True)
(assets_dir / "tree.json").write_text(
json.dumps(index_data["tree"], ensure_ascii=False, indent=2),
encoding="utf-8"
)
(assets_dir / "graph.json").write_text(
json.dumps(index_data["graph"], ensure_ascii=False, indent=2),
encoding="utf-8"
)
(assets_dir / "tags.json").write_text(
json.dumps(index_data["tags"], ensure_ascii=False, indent=2),
encoding="utf-8"
)
(assets_dir / "backlinks.json").write_text(
json.dumps(index_data["backlinks"], ensure_ascii=False, indent=2),
encoding="utf-8"
)
def write_data_js(slug_meta_list: list, index_data: dict, site_dir: Path) -> None:
"""Single data.js with all metadata + index baked in (avoids CORS issues)."""
assets_dir = site_dir / "__"
assets_dir.mkdir(parents=True, exist_ok=True)
pages = [{
"slug": s["slug"],
"title": s["title"],
"tags": s["frontmatter"].get("tags", []) if isinstance(s["frontmatter"].get("tags"), list) else [],
"type": s["frontmatter"].get("type", "note"),
"path": s["path"],
} for s in slug_meta_list]
js = f"""// Auto-generated by hermes-wiki-generator
window.HERMES_DATA = {{
pages: {json.dumps(pages, ensure_ascii=False)},
tree: {json.dumps(index_data['tree'], ensure_ascii=False)},
graph: {json.dumps(index_data['graph'], ensure_ascii=False)},
tags: {json.dumps(index_data['tags'], ensure_ascii=False)},
backlinks: {json.dumps(index_data['backlinks'], ensure_ascii=False)},
currentSlug: "{slug_meta_list[0]['slug'] if slug_meta_list else ''}",
}};
"""
(site_dir / "__/data.js").write_text(js, encoding="utf-8")
# ============================================================
# Static assets (CSS, JS, PWA manifest)
# ============================================================
# CSS is a placeholder — touch-first design comes in next commit
CSS_PLACEHOLDER = """/* Hermes Wiki — placeholder CSS */
:root {
--bg-primary: #0a0a14;
--bg-secondary: #0e0e1a;
--bg-surface: #1a1a2e;
--text-primary: #cdd6f4;
--text-secondary: #a6adc8;
--text-muted: #6c7086;
--accent: #FFD700;
--link: #FFD700;
--border: #313244;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: var(--bg-primary); color: var(--text-primary); font-family: -apple-system, 'Segoe UI', Inter, sans-serif; }
.app-shell { display: grid; grid-template-columns: 280px 1fr 260px; grid-template-rows: 48px 1fr; height: 100vh; }
.app-header { grid-column: 1 / -1; background: var(--bg-secondary); border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 16px; gap: 12px; }
.brand { color: var(--accent); font-weight: 700; }
#search { flex: 1; max-width: 400px; padding: 6px 12px; background: rgba(30,30,50,0.6); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary); }
.icon-btn { background: none; border: 1px solid var(--border); color: var(--text-secondary); padding: 4px 10px; border-radius: 4px; cursor: pointer; }
.app-tree { background: var(--bg-secondary); border-right: 1px solid var(--border); overflow-y: auto; padding: 8px; }
.app-content { overflow-y: auto; padding: 32px 48px; }
.app-graph { background: var(--bg-secondary); border-left: 1px solid var(--border); }
.meta-card { background: var(--bg-surface); border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px; margin-bottom: 24px; font-size: 12px; }
.meta-row { display: flex; gap: 8px; padding: 2px 0; }
.meta-key { color: var(--accent); font-weight: 600; min-width: 80px; }
.wikilink-missing { color: var(--text-muted); text-decoration: line-through; }
.note-body { line-height: 1.7; }
.note-body h1, .note-body h2, .note-body h3 { margin-top: 1.5em; margin-bottom: 0.5em; }
.note-body code { background: var(--bg-surface); padding: 2px 6px; border-radius: 3px; }
.note-body pre { background: var(--bg-surface); padding: 12px; border-radius: 6px; overflow-x: auto; }
.app-bottom-nav { display: none; }
"""
JS_PLACEHOLDER = """// Hermes Wiki — placeholder JS
(function() {
'use strict';
// Bootstrap from data.js
const data = window.HERMES_DATA || { pages: [], tree: [], graph: { nodes: [], edges: [] }, tags: {}, backlinks: {} };
// Render tree into sidebar
function renderTree(node, container, basePath = '') {
const ul = document.createElement('ul');
if (node.folders) {
for (const folder of node.folders) {
const li = document.createElement('li');
const header = document.createElement('div');
header.textContent = '📁 ' + folder.name;
header.style.cursor = 'pointer';
const childrenContainer = document.createElement('div');
header.onclick = () => {
childrenContainer.style.display = childrenContainer.style.display === 'none' ? 'block' : 'none';
};
childrenContainer.style.display = 'none';
childrenContainer.style.paddingLeft = '12px';
li.appendChild(header);
li.appendChild(childrenContainer);
renderTree(folder, childrenContainer, basePath + '/' + folder.name);
ul.appendChild(li);
}
}
if (node.files) {
for (const file of node.files) {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = '/' + file.slug + '.html';
a.textContent = file.title;
a.style.color = 'var(--text-primary)';
a.style.textDecoration = 'none';
a.style.display = 'block';
a.style.padding = '2px 8px';
li.appendChild(a);
ul.appendChild(li);
}
}
container.appendChild(ul);
}
const treeEl = document.getElementById('tree');
if (treeEl && data.tree) {
renderTree(data.tree, treeEl);
}
// Search (client-side)
const search = document.getElementById('search');
if (search) {
search.addEventListener('input', (e) => {
const q = e.target.value.toLowerCase().trim();
if (!q) return;
// Simple substring search on titles + tags
const matches = data.pages.filter(p =>
p.title.toLowerCase().includes(q) ||
(p.tags || []).some(t => t.toLowerCase().includes(q))
);
console.log('[Search]', q, '', matches.length, 'results');
// TODO: show results dropdown
});
}
// Toggle buttons (placeholder)
document.addEventListener('click', (e) => {
if (e.target.dataset.action === 'toggle-tree') {
document.querySelector('.app-tree').classList.toggle('open');
}
if (e.target.dataset.action === 'toggle-graph') {
document.querySelector('.app-graph').classList.toggle('open');
}
});
})();
"""
PWA_MANIFEST = """{
"name": "Hermes Wiki",
"short_name": "Wiki",
"description": "Lukas Huber's knowledge vault — mobile-first static wiki",
"start_url": "/__/index.html",
"display": "standalone",
"background_color": "#0a0a14",
"theme_color": "#0a0a14",
"icons": [
{
"src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📓</text></svg>",
"sizes": "any",
"type": "image/svg+xml"
}
]
}"""
def write_assets(site_dir: Path) -> None:
"""Copy static assets (CSS, JS, manifest) from repo to site dir."""
assets_dir = site_dir / "__"
assets_dir.mkdir(parents=True, exist_ok=True)
repo_root = Path(__file__).parent
for asset in ("style.css", "app.js", "manifest.json"):
src = repo_root / asset
if src.exists():
content = src.read_text(encoding="utf-8")
(assets_dir / asset).write_text(content, encoding="utf-8")
else:
log.warning(f"Asset not found in repo: {src}")
# index page (redirect to first page or show all)
index_html = """<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>Hermes Wiki</title>
<link rel="stylesheet" href="/__/style.css">
<meta http-equiv="refresh" content="0; url=/{slug}.html"></head>
<body><a href="/{slug}.html">Open Wiki</a></body></html>"""
first_slug = "index" # fall back to index.md
(site_dir / "__/index.html").write_text(
index_html.replace("{slug}", first_slug), encoding="utf-8"
)
# ============================================================
# Generator orchestration
# ============================================================
class WikiState:
"""Holds in-memory state of generated pages and index."""
def __init__(self):
self.lock = Lock()
self.slug_to_path = {} # slug → original .md path
self.slug_meta_list = [] # list of {slug, title, html, frontmatter, path}
self.index_data = {} # tree, graph, tags, backlinks
def regenerate_all(self):
"""Full rebuild — used on startup."""
log.info(f"Full regenerate: scanning {WIKI_DIR}")
with self.lock:
self.slug_to_path.clear()
self.slug_meta_list.clear()
md_files = sorted(WIKI_DIR.rglob("*.md"))
skipped_raw = 0
for md_file in md_files:
rel = md_file.relative_to(WIKI_DIR)
if rel.parts[0] == "raw":
skipped_raw += 1
continue
slug = slugify(md_file)
self.slug_to_path[slug] = slug
self.slug_to_path[md_file.stem] = slug # for [[agent-hermes]] lookups
# Also index by basename without .md
base = md_file.name[:-3]
if base not in self.slug_to_path:
self.slug_to_path[base] = slug
log.info(f"Found {len(self.slug_to_path)} slugs ({skipped_raw} skipped in raw/)")
# Render each page
for md_file in md_files:
rel = md_file.relative_to(WIKI_DIR)
if rel.parts[0] == "raw":
continue
meta = render_page(md_file, self.slug_to_path, self.slug_meta_list)
self.slug_meta_list.append(meta)
write_page(meta, SITE_DIR)
log.info(f"Rendered {len(self.slug_meta_list)} HTML pages")
# Build index data
self.index_data = build_index(self.slug_meta_list)
write_index_files(self.index_data, SITE_DIR)
write_data_js(self.slug_meta_list, self.index_data, SITE_DIR)
write_assets(SITE_DIR)
log.info("Index files written: tree.json, graph.json, tags.json, backlinks.json, data.js")
def regenerate_one(self, md_path: Path):
"""Re-render one page (and its reverse-link targets if needed)."""
rel = md_path.relative_to(WIKI_DIR)
if rel.parts[0] == "raw":
return
with self.lock:
slug = slugify(md_path)
meta = render_page(md_path, self.slug_to_path, self.slug_meta_list)
# Update or append
for i, existing in enumerate(self.slug_meta_list):
if existing["slug"] == slug:
self.slug_meta_list[i] = meta
break
else:
self.slug_meta_list.append(meta)
write_page(meta, SITE_DIR)
log.info(f"Re-rendered: {slug}")
# Rebuild index (cheap for small wikis, ensures backlinks/tags stay consistent)
self.index_data = build_index(self.slug_meta_list)
write_index_files(self.index_data, SITE_DIR)
write_data_js(self.slug_meta_list, self.index_data, SITE_DIR)
class WikiFileWatcher(FileSystemEventHandler):
def __init__(self, state: WikiState):
self.state = state
def on_modified(self, event: FileSystemEvent):
if event.is_directory:
return
path = Path(event.src_path)
if path.suffix == ".md":
log.info(f"File modified: {path}")
self.state.regenerate_one(path)
def on_created(self, event: FileSystemEvent):
if event.is_directory:
return
path = Path(event.src_path)
if path.suffix == ".md":
log.info(f"File created: {path}")
self.state.regenerate_one(path)
def on_deleted(self, event: FileSystemEvent):
if event.is_directory:
return
path = Path(event.src_path)
if path.suffix == ".md":
log.info(f"File deleted: {path} (TODO: remove HTML)")
# ============================================================
# HTTP server
# ============================================================
class WikiHandler(SimpleHTTPRequestHandler):
"""Serves from SITE_DIR. Logs requests briefly."""
def log_message(self, format, *args):
log.debug(f"HTTP {self.address_string()} {format % args}")
def end_headers(self):
# Cache headers for static assets
if self.path.startswith("/__/"):
self.send_header("Cache-Control", "public, max-age=300")
super().end_headers()
def start_server():
"""Run HTTP server on BIND:PORT serving from SITE_DIR."""
os.chdir(SITE_DIR)
server = HTTPServer((BIND, PORT), WikiHandler)
log.info(f"HTTP server: http://{BIND}:{PORT} serving {SITE_DIR}")
server.serve_forever()
# ============================================================
# Daemon mode (PID file, signals)
# ============================================================
def write_pid():
PID_FILE.write_text(str(os.getpid()))
def is_running():
if not PID_FILE.exists():
return False
try:
pid = int(PID_FILE.read_text())
os.kill(pid, 0) # check if alive
return True
except (ValueError, ProcessLookupError, PermissionError):
return False
def stop_daemon():
if not PID_FILE.exists():
log.info("No PID file — daemon not running")
return
pid = int(PID_FILE.read_text())
try:
os.kill(pid, signal.SIGTERM)
log.info(f"Sent SIGTERM to PID {pid}")
except ProcessLookupError:
log.info(f"PID {pid} not found — already stopped?")
PID_FILE.unlink(missing_ok=True)
# ============================================================
# Main entry point
# ============================================================
def main():
parser = argparse.ArgumentParser(description="Hermes Wiki Static Generator")
parser.add_argument("--start", action="store_true", help="Run as daemon (watcher + HTTP server)")
parser.add_argument("--stop", action="store_true", help="Stop daemon")
parser.add_argument("--status", action="store_true", help="Show status")
parser.add_argument("--once", action="store_true", help="Generate once and exit (no watcher)")
parser.add_argument("--foreground", action="store_true", help="Run in foreground (default: --start)")
args = parser.parse_args()
if args.stop:
stop_daemon()
return
if args.status:
if is_running():
pid = int(PID_FILE.read_text())
print(f"Running: PID {pid}")
else:
print(f"Not running (no PID file: {PID_FILE})")
return
if args.once:
state = WikiState()
state.regenerate_all()
print(f"Generated {len(state.slug_meta_list)} pages to {SITE_DIR}")
return
# Daemon mode
if is_running():
log.error(f"Already running (PID {int(PID_FILE.read_text())}). Use --stop first.")
sys.exit(1)
# Initial full regen
state = WikiState()
state.regenerate_all()
# Start file watcher
observer = Observer()
observer.schedule(WikiFileWatcher(state), str(WIKI_DIR), recursive=True)
observer.start()
log.info(f"Filesystem watcher started on {WIKI_DIR}")
# Start HTTP server in background thread
server_thread = Thread(target=start_server, daemon=True)
server_thread.start()
# Write PID
write_pid()
log.info(f"Daemon PID: {os.getpid()}")
# Handle signals
def handle_term(signum, frame):
log.info(f"Received signal {signum}, shutting down")
observer.stop()
PID_FILE.unlink(missing_ok=True)
sys.exit(0)
signal.signal(signal.SIGTERM, handle_term)
signal.signal(signal.SIGINT, handle_term)
# Keep main thread alive
try:
while True:
time.sleep(60)
except KeyboardInterrupt:
handle_term(0, None)
if __name__ == "__main__":
main()
+16
View File
@@ -0,0 +1,16 @@
{
"name": "Hermes Wiki",
"short_name": "Wiki",
"description": "Lukas Huber's knowledge vault — mobile-first static wiki",
"start_url": "/__/index.html",
"display": "standalone",
"background_color": "#0a0a14",
"theme_color": "#0a0a14",
"icons": [
{
"src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📓</text></svg>",
"sizes": "any",
"type": "image/svg+xml"
}
]
}
+4
View File
@@ -0,0 +1,4 @@
markdown>=3.10
pyyaml>=6.0
watchdog>=6.0
python-frontmatter>=1.3
+346
View File
@@ -0,0 +1,346 @@
#!/usr/bin/env bash
# hermes-wiki-serve.sh — Hermes Wiki via obsidian-web-viewer + Tailscale
#
# Was dieses Skript tut:
# - Klont DanielCheer/obsidian-web-viewer einmalig nach ~/.local/share/hermes-wiki/owv/
# - Startet deren server.py (Python stdlib) auf 127.0.0.1:8765 (Loopback)
# - Aktiviert tailscale serve, der den Loopback-Port als https im Tailnet exponen lässt
# - Liefert File-Tree, 3D-Graph, Volltext-Suche, WikiLink-Navigation out-of-the-box
#
# Verwendung:
# hermes-wiki-serve.sh start # Server starten (klont obv beim ersten Mal)
# hermes-wiki-serve.sh stop # Server stoppen
# hermes-wiki-serve.sh restart # stop + start
# hermes-wiki-serve.sh status # Process-Status, Health-Check, Tailscale-URL
# hermes-wiki-serve.sh logs # tail -f der Log-Files
# hermes-wiki-serve.sh install # nur das Clonen/Update, kein Server-Start
# hermes-wiki-serve.sh --help
#
# Umgebungsvariablen:
# HERMES_WIKI_PORT=8765 Lokaler HTTP-Port (Loopback)
# HERMES_WIKI_DIR=/home/admin/my-karpathy-wiki
# HERMES_WIKI_LOG_DIR=$HOME/.local/share/hermes-wiki/log
# HERMES_WIKI_TS=auto auto|yes|no — Tailscale serve aktivieren
# HERMES_WIKI_TS_PATH= leer=Root-Prefix (default), /wiki=unter-Pfad
# HERMES_WIKI_OWV_DIR=$HOME/repos/hermes-wiki-viewer (Lukas-Fork default; upstream obv als Fallback)
set -euo pipefail
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
readonly HERMES_WIKI_PORT="${HERMES_WIKI_PORT:-8765}"
readonly HERMES_WIKI_DIR="${HERMES_WIKI_DIR:-/home/admin/my-karpathy-wiki}"
readonly HERMES_WIKI_LOG_DIR="${HERMES_WIKI_LOG_DIR:-$HOME/.local/share/hermes-wiki/log}"
readonly HERMES_WIKI_TS="${HERMES_WIKI_TS:-auto}"
readonly HERMES_WIKI_TS_PATH="${HERMES_WIKI_TS_PATH:-}" # leer = Root-Prefix (obv nutzt relative URLs!)
readonly HERMES_WIKI_OWV_DIR="${HERMES_WIKI_OWV_DIR:-$HOME/repos/hermes-wiki-viewer}"
readonly HERMES_WIKI_REPO="${HERMES_WIKI_REPO:-}" # leer = lokales Lukas-Repo nutzen
readonly OWV_REPO_UPSTREAM="https://github.com/DanielCheer/obsidian-web-viewer.git"
readonly PID_FILE="/tmp/hermes-wiki-serve.pid"
readonly HEALTH_URL="http://127.0.0.1:${HERMES_WIKI_PORT}/api/vault/tree"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
log() {
printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" | tee -a "$HERMES_WIKI_LOG_DIR/server.log" >&2
}
die() {
log "ERROR: $*"
exit 1
}
ensure_dirs() {
mkdir -p "$HERMES_WIKI_LOG_DIR" || die "Kann Log-Dir nicht erstellen: $HERMES_WIKI_LOG_DIR"
}
require_tools() {
command -v python3 >/dev/null || die "python3 nicht gefunden"
command -v git >/dev/null || die "git nicht gefunden"
[ -d "$HERMES_WIKI_DIR" ] || die "Wiki-Verzeichnis nicht gefunden: $HERMES_WIKI_DIR"
}
detect_tailscale() {
[ "$HERMES_WIKI_TS" != "no" ] || return 1
command -v tailscale >/dev/null || return 1
tailscale status --json >/dev/null 2>&1 || return 1
return 0
}
# ---------------------------------------------------------------------------
# Installation: Repo klonen oder updaten
# ---------------------------------------------------------------------------
install_repo() {
# Mode 1: Lokales Lukas-Repo (~/repos/hermes-wiki-viewer/) — default nach Fork
if [ -d "$HERMES_WIKI_OWV_DIR/.git" ] && [ -z "$HERMES_WIKI_REPO" ]; then
log "Nutze lokales Lukas-Repo: $HERMES_WIKI_OWV_DIR"
(cd "$HERMES_WIKI_OWV_DIR" && git pull --ff-only 2>>"$HERMES_WIKI_LOG_DIR/install.log") || \
log "WARN: git pull fehlgeschlagen — nutze Working-Tree-Stand"
# Mode 2: Remote Lukas-Repo klonen (z.B. wenn HERMES_WIKI_REPO gesetzt)
elif [ -n "$HERMES_WIKI_REPO" ]; then
if [ -d "$HERMES_WIKI_OWV_DIR/.git" ]; then
log "Update Lukas-Fork in $HERMES_WIKI_OWV_DIR"
(cd "$HERMES_WIKI_OWV_DIR" && git pull --ff-only 2>>"$HERMES_WIKI_LOG_DIR/install.log") || \
log "WARN: git pull fehlgeschlagen"
else
log "Klone Lukas-Fork $HERMES_WIKI_REPO nach $HERMES_WIKI_OWV_DIR"
git clone "$HERMES_WIKI_REPO" "$HERMES_WIKI_OWV_DIR" 2>>"$HERMES_WIKI_LOG_DIR/install.log" || \
die "git clone fehlgeschlagen — Repo-URL korrekt?"
fi
# Mode 3: Fallback — upstream obv direkt (vor Lukas-Fork)
else
log "Kein Lukas-Repo gefunden, klone upstream obv nach $HERMES_WIKI_OWV_DIR"
log "(Für Lukas-Customizations: HERMES_WIKI_OWV_DIR=/path/to/hermes-wiki-viewer setzen)"
git clone "$OWV_REPO_UPSTREAM" "$HERMES_WIKI_OWV_DIR" 2>>"$HERMES_WIKI_LOG_DIR/install.log" || \
die "git clone fehlgeschlagen"
fi
# requirements.txt: nur PyYAML (optional). Stdlib reicht für unsere Zwecke.
if ! python3 -c "import yaml" 2>/dev/null; then
log "PyYAML nicht installiert (optional). Installiere..."
python3 -m pip install --user --quiet PyYAML 2>>"$HERMES_WIKI_LOG_DIR/install.log" || \
log "WARN: PyYAML-Installation fehlgeschlagen — Frontmatter wird rudimentär behandelt"
fi
log "Wiki-Viewer bereit in $HERMES_WIKI_OWV_DIR"
}
# ---------------------------------------------------------------------------
# Tailscale-Integration
# ---------------------------------------------------------------------------
start_tailscale() {
detect_tailscale || {
[ "$HERMES_WIKI_TS" = "no" ] || \
log "Tailscale übersprungen (nicht verfügbar oder HERMES_WIKI_TS=$HERMES_WIKI_TS)"
return 0
}
if tailscale serve status --bg 2>/dev/null | grep -q "$HERMES_WIKI_PORT"; then
log "Tailscale serve bereits aktiv für Port $HERMES_WIKI_PORT"
return 0
fi
log "Starte tailscale serve auf Root-Prefix -> 127.0.0.1:$HERMES_WIKI_PORT"
local ts_path_arg=()
if [ -n "$HERMES_WIKI_TS_PATH" ]; then
ts_path_arg=(--set-path="$HERMES_WIKI_TS_PATH")
log "(Tailscale wird unter /$HERMES_WIKI_TS_PATH/ exposen — Achtung: obv nutzt relative URLs!)"
fi
if tailscale serve --bg --https=443 "${ts_path_arg[@]}" \
"http://127.0.0.1:$HERMES_WIKI_PORT" 2>>"$HERMES_WIKI_LOG_DIR/tailscale.log"; then
local ts_url
ts_url=$(tailscale serve status --json 2>/dev/null | python3 -c "
import json, sys
try:
data = json.load(sys.stdin)
for entry in data.get('Web', {}).values():
print(f'https://{entry[\"HTTPS\"]}{entry[\"Path\"]}/')
break
except: pass
" 2>/dev/null)
log "Tailscale serve aktiv: ${ts_url:-URL konnte nicht ermittelt werden}"
return 0
else
log "WARN: tailscale serve fehlgeschlagen — siehe $HERMES_WIKI_LOG_DIR/tailscale.log"
return 0
fi
}
stop_tailscale() {
detect_tailscale || return 0
if tailscale serve status --bg 2>/dev/null | grep -q "$HERMES_WIKI_PORT"; then
log "Stoppe tailscale serve"
tailscale serve --bg --https=443 --set-path="$HERMES_WIKI_TS_PATH" off \
"http://127.0.0.1:$HERMES_WIKI_PORT" 2>>"$HERMES_WIKI_LOG_DIR/tailscale.log" || true
fi
}
# ---------------------------------------------------------------------------
# Server-Lifecycle
# ---------------------------------------------------------------------------
is_running() {
[ -f "$PID_FILE" ] || return 1
local pid
pid=$(cat "$PID_FILE" 2>/dev/null || echo "")
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}
start_server() {
ensure_dirs
require_tools
install_repo
if is_running; then
log "Server läuft bereits (PID $(cat "$PID_FILE"))"
return 0
fi
# Port-Konflikt-Check
if ss -tln 2>/dev/null | grep -q ":${HERMES_WIKI_PORT} "; then
die "Port ${HERMES_WIKI_PORT} ist bereits belegt. HERMES_WIKI_PORT ändern oder Prozess beenden."
fi
# Check, dass die Wiki-Page gerendert werden kann
local md_count
md_count=$(find "$HERMES_WIKI_DIR" -name "*.md" -not -path "*/raw/*" 2>/dev/null | wc -l)
log "Wiki-Dir: $HERMES_WIKI_DIR ($md_count .md-Dateien, ohne /raw/)"
# Server starten — Loopback only, NICHT 0.0.0.0
log "Starte obsidian-web-viewer server.py auf 127.0.0.1:$HERMES_WIKI_PORT"
nohup python3 "$HERMES_WIKI_OWV_DIR/server.py" \
--vault "$HERMES_WIKI_DIR" \
--host 127.0.0.1 \
--port "$HERMES_WIKI_PORT" \
> "$HERMES_WIKI_LOG_DIR/renderer.log" 2>&1 &
local pid=$!
echo "$pid" > "$PID_FILE"
# Health-Check (das Tool hat keine /__health, aber /api/vault/tree ist immer da)
local i
for i in 1 2 3 4 5; do
sleep 1
if curl -sf "$HEALTH_URL" >/dev/null 2>&1; then
log "Server gestartet (PID $pid), Health-Check OK"
start_tailscale
return 0
fi
done
log "ERROR: Health-Check fehlgeschlagen nach 5s"
log "--- renderer.log ---"
tail -20 "$HERMES_WIKI_LOG_DIR/renderer.log" >&2
rm -f "$PID_FILE"
return 1
}
stop_server() {
if is_running; then
local pid
pid=$(cat "$PID_FILE")
log "Stoppe Server (PID $pid)"
kill "$pid" 2>/dev/null || true
local i
for i in 1 2 3 4 5; do
kill -0 "$pid" 2>/dev/null || break
sleep 0.5
done
if kill -0 "$pid" 2>/dev/null; then
kill -9 "$pid" 2>/dev/null || true
fi
rm -f "$PID_FILE"
fi
# Tailscale NICHT stoppen — soll laufen bleiben für Re-Start ohne Tailscale-Re-Init
# stop_tailscale
log "(Tailscale serve bleibt aktiv für Re-Start)"
}
# ---------------------------------------------------------------------------
# Status / Help
# ---------------------------------------------------------------------------
show_status() {
echo "=== Hermes Wiki Server Status (via obsidian-web-viewer) ==="
echo "Wiki-Dir: $HERMES_WIKI_DIR"
echo "Viewer-Repo: $HERMES_WIKI_OWV_DIR"
echo "Port: $HERMES_WIKI_PORT (Loopback only)"
echo "Log-Dir: $HERMES_WIKI_LOG_DIR"
if is_running; then
local pid
pid=$(cat "$PID_FILE")
echo "Status: RUNNING (PID $pid)"
ps -o pid,rss,etime,cmd -p "$pid" 2>/dev/null | tail -1 | \
awk '{printf " Memory: %.1f MB, Uptime: %s\n", $2/1024, $3}'
if curl -sf "$HEALTH_URL" >/dev/null 2>&1; then
local md_count
md_count=$(curl -sf "$HEALTH_URL" | python3 -c "
import json, sys
try:
data = json.load(sys.stdin)
count = sum(1 + len(c.get('children',[])) for c in [data])
# grobe zählung
print(len(json.dumps(data)))
except: print('?')
" 2>/dev/null || echo "?")
echo "Health: OK (Tree-API reachable, $md_count bytes)"
else
echo "Health: FAILED"
fi
if detect_tailscale; then
if tailscale serve status --bg 2>&1 | grep -q "$HERMES_WIKI_PORT"; then
local ts_url
ts_url=$(tailscale serve status --json 2>/dev/null | python3 -c "
import json, sys
try:
data = json.load(sys.stdin)
for entry in data.get('Web', {}).values():
print(f'https://{entry[\"HTTPS\"]}{entry[\"Path\"]}/')
break
except: pass
" 2>/dev/null)
echo "Tailscale: AKTIV — ${ts_url:-URL nicht extrahierbar}"
else
echo "Tailscale: nicht aktiv für Port $HERMES_WIKI_PORT (HERMES_WIKI_TS=$HERMES_WIKI_TS)"
fi
else
echo "Tailscale: nicht verfügbar oder deaktiviert"
fi
else
echo "Status: NOT RUNNING"
fi
}
show_help() {
cat <<EOF
hermes-wiki-serve.sh — Hermes Wiki via obsidian-web-viewer + Tailscale
Dieses Skript wrapper't DanielCheer/obsidian-web-viewer (3D-Graph, File-Tree,
WikiLink-Navigation, Volltext-Suche, Catppuccin-Theme) und integriert Tailscale
serve für Tailnet-Zugriff.
Verwendung:
$0 start # Server starten (klont obv beim ersten Mal)
$0 stop # Server stoppen
$0 restart # stop + start
$0 status # Process-Status, Health-Check, Tailscale-URL
$0 logs # tail -f der Log-Files
$0 install # nur das Clonen/Update, kein Server-Start
$0 --help
Umgebungsvariablen:
HERMES_WIKI_PORT=8765
HERMES_WIKI_DIR=/home/admin/my-karpathy-wiki
HERMES_WIKI_LOG_DIR=\$HOME/.local/share/hermes-wiki/log
HERMES_WIKI_TS=auto auto|yes|no
HERMES_WIKI_TS_PATH=/wiki URL-Pfad unter dem Tailscale
HERMES_WIKI_OWV_DIR=\$HOME/.local/share/hermes-wiki/owv
Dependencies (einmalig):
- python3 (stdlib)
- git
- pip install PyYAML (optional, für sauberes Frontmatter)
- tailscale (für Tailnet-Zugriff)
Zugriff:
- Lokal: http://127.0.0.1:8765/
- Tailnet: https://<hostname>.<tailnet>.ts.net/wiki/
- API: http://127.0.0.1:8765/api/vault/{tree,graph,search,file/<path>}
EOF
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
case "${1:-}" in
start) start_server ;;
stop) stop_server ;;
restart) stop_server; start_server ;;
status) show_status ;;
logs) tail -f "$HERMES_WIKI_LOG_DIR"/*.log 2>/dev/null || die "Keine Logs gefunden" ;;
install) ensure_dirs; require_tools; install_repo ;;
--help|-h|help|"") show_help ;;
*) die "Unbekanntes Kommando: $1 (--help für Hilfe)" ;;
esac
+715
View File
@@ -0,0 +1,715 @@
/* Hermes Wiki — Touch-First CSS for Phase 2
*
* Design principles:
* - Bottom-Nav on mobile (3 tabs: Files, Search, Graph)
* - Hamburger-Tree always off-canvas on mobile, slide-in on click
* - Graph shown via FAB-style toggle → opens full-screen modal
* - Desktop: 3-panel layout (tree 280px, content flex, graph 320px)
* - Tablet (768-1023px): 2-panel (tree 220px, content flex), graph as bottom panel
* - Mobile (<768px): 1-panel (content only), tree/graph via toggles
* - Tap targets ≥ 44px, no accidental 300ms delay
* - Safe-area-insets for iOS Dynamic Island
* - Pull-to-refresh via overscroll-behavior: contain
*/
:root {
--bg-primary: #0a0a14;
--bg-secondary: #0e0e1a;
--bg-surface: #1a1a2e;
--bg-elevated: #252537;
--text-primary: #cdd6f4;
--text-secondary: #a6adc8;
--text-muted: #6c7086;
--accent: #FFD700;
--link: #FFD700;
--link-visited: #cba6f7;
--border: #313244;
--green: #a6e3a1;
--red: #f38ba8;
--blue: #89b4fa;
--teal: #94e2d5;
--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);
--tap-min: 44px;
}
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body {
height: 100%;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, sans-serif;
font-size: 15px;
background: var(--bg-primary);
color: var(--text-primary);
overscroll-behavior: contain; /* pull-to-refresh only where intended */
}
body {
padding-top: var(--sat);
padding-left: var(--sal);
padding-right: var(--sar);
padding-bottom: var(--sab);
}
/* ============================================================
* Layout: Desktop (≥1024px) — 3-panel
* ============================================================ */
.app-shell {
display: grid;
grid-template-columns: 280px 1fr 320px;
grid-template-rows: 48px 1fr;
height: 100vh;
height: 100dvh;
width: 100%;
max-width: 100vw;
}
.app-header {
grid-column: 1 / -1;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 16px;
gap: 12px;
position: sticky;
top: 0;
z-index: 50;
}
.brand {
color: var(--accent);
font-weight: 700;
font-size: 14px;
letter-spacing: 0.5px;
text-decoration: none;
white-space: nowrap;
}
#search {
flex: 1;
max-width: 480px;
height: 36px;
padding: 0 12px 0 36px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 13px;
outline: none;
transition: border-color 0.15s;
}
#search:focus { border-color: var(--accent); }
#search::placeholder { color: var(--text-muted); }
.search-wrap { position: relative; flex: 1; max-width: 480px; }
.search-wrap::before {
content: '🔍';
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
font-size: 13px;
pointer-events: none;
opacity: 0.5;
}
.icon-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--text-secondary);
width: 36px;
height: 36px;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.icon-btn:hover { border-color: var(--accent); color: var(--accent); }
.app-tree {
grid-column: 1;
grid-row: 2;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
overflow-y: auto;
overflow-x: hidden;
padding: 8px 0;
font-size: 13px;
}
.app-content {
grid-column: 2;
grid-row: 2;
overflow-y: auto;
overflow-x: hidden;
padding: 32px 48px 64px;
max-width: 100%;
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
}
.app-graph {
grid-column: 3;
grid-row: 2;
background: var(--bg-secondary);
border-left: 1px solid var(--border);
overflow: hidden;
position: relative;
min-height: 0;
}
.app-graph canvas {
display: block;
width: 100% !important;
height: 100% !important;
cursor: grab;
}
.app-graph canvas:active { cursor: grabbing; }
/* ============================================================
* Tree rendering
* ============================================================ */
.tree-root { padding: 4px 0; }
.tree-folder { margin: 1px 0; }
.tree-folder-header {
display: flex;
align-items: center;
padding: 6px 12px;
gap: 6px;
cursor: pointer;
user-select: none;
color: var(--text-secondary);
font-weight: 500;
border-radius: 4px;
margin: 1px 4px;
min-height: var(--tap-min);
font-size: 13px;
}
.tree-folder-header:hover { background: rgba(255, 215, 0, 0.05); }
.tree-folder-header.active { background: rgba(255, 215, 0, 0.08); color: var(--accent); }
.tree-folder-header .arrow {
color: var(--text-muted);
font-size: 10px;
width: 12px;
display: inline-block;
transition: transform 0.15s;
}
.tree-folder.open > .tree-folder-header .arrow { transform: rotate(90deg); }
.tree-children { padding-left: 12px; display: none; }
.tree-folder.open > .tree-children { display: block; }
.tree-file {
display: block;
padding: 6px 12px 6px 24px;
color: var(--text-primary);
text-decoration: none;
border-radius: 4px;
margin: 1px 4px;
min-height: var(--tap-min);
font-size: 13px;
display: flex;
align-items: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tree-file:hover { background: rgba(255, 215, 0, 0.05); color: var(--accent); }
.tree-file.current {
background: rgba(255, 215, 0, 0.12);
color: var(--accent);
border-left: 3px solid var(--accent);
padding-left: 21px;
}
.tree-file-icon { margin-right: 6px; opacity: 0.5; }
/* ============================================================
* Note content
* ============================================================ */
.note { max-width: 920px; margin: 0 auto; }
.meta-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 18px;
margin-bottom: 24px;
font-size: 12px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 6px 16px;
}
.meta-row {
display: flex;
gap: 8px;
align-items: baseline;
min-width: 0;
}
.meta-key {
color: var(--accent);
font-weight: 600;
text-transform: uppercase;
font-size: 10px;
letter-spacing: 0.5px;
min-width: 70px;
flex-shrink: 0;
}
.meta-value {
color: var(--text-secondary);
word-break: break-word;
font-family: ui-monospace, 'SF Mono', Monaco, monospace;
font-size: 11px;
}
.note-body {
line-height: 1.7;
font-size: 16px;
}
.note-body h1 { font-size: 2em; margin: 0.6em 0 0.4em; padding-bottom: 8px; border-bottom: 1px solid var(--border); color: var(--text-primary); }
.note-body h2 { font-size: 1.5em; margin: 1.5em 0 0.5em; color: var(--text-primary); border-bottom: 1px solid rgba(49,50,68,0.5); padding-bottom: 4px; }
.note-body h3 { font-size: 1.25em; margin: 1.2em 0 0.4em; color: var(--text-primary); }
.note-body h4 { font-size: 1.1em; margin: 1em 0 0.4em; color: var(--text-secondary); }
.note-body p { margin: 0.8em 0; }
.note-body ul, .note-body ol { margin: 0.5em 0; padding-left: 1.8em; }
.note-body li { margin: 0.3em 0; }
.note-body blockquote {
border-left: 3px solid var(--accent);
padding: 0.5em 1em;
margin: 1em 0;
background: rgba(255, 215, 0, 0.04);
color: var(--text-secondary);
font-style: italic;
}
.note-body code {
background: var(--bg-surface);
padding: 2px 6px;
border-radius: 3px;
font-family: ui-monospace, 'SF Mono', Monaco, monospace;
font-size: 0.9em;
color: var(--teal);
}
.note-body pre {
background: var(--bg-surface);
border: 1px solid var(--border);
padding: 14px 16px;
border-radius: 8px;
overflow-x: auto;
margin: 1em 0;
font-size: 13px;
line-height: 1.5;
}
.note-body pre code {
background: transparent;
padding: 0;
color: var(--text-primary);
font-size: inherit;
}
.note-body table {
width: 100%;
border-collapse: collapse;
margin: 1em 0;
font-size: 14px;
display: block;
overflow-x: auto;
}
.note-body th {
background: var(--bg-surface);
padding: 8px 12px;
text-align: left;
border: 1px solid var(--border);
font-weight: 600;
color: var(--accent);
}
.note-body td {
padding: 8px 12px;
border: 1px solid var(--border);
}
.note-body tr:nth-child(even) { background: rgba(30, 30, 50, 0.3); }
.note-body a {
color: var(--link);
text-decoration: none;
border-bottom: 1px solid rgba(255, 215, 0, 0.3);
}
.note-body a:hover { border-bottom-color: var(--accent); }
.note-body a:visited { color: var(--link-visited); }
.note-body .wikilink {
color: var(--blue);
border-bottom: 1px dashed var(--blue);
}
.note-body .wikilink:hover { color: var(--accent); border-bottom-color: var(--accent); }
.note-body .wikilink-missing {
color: var(--text-muted);
border-bottom: 1px dotted var(--text-muted);
text-decoration: line-through;
}
.note-body img { max-width: 100%; height: auto; border-radius: 6px; margin: 1em 0; }
.note-body hr { border: none; border-top: 1px solid var(--border); margin: 2em 0; }
.note-body .headerlink { color: var(--text-muted); margin-left: 8px; font-size: 0.7em; text-decoration: none; opacity: 0; transition: opacity 0.15s; }
.note-body h1:hover .headerlink, .note-body h2:hover .headerlink, .note-body h3:hover .headerlink { opacity: 1; }
/* TOC sidebar (in note footer area) */
.note-toc {
margin-top: 48px;
padding: 16px 0;
border-top: 1px solid var(--border);
font-size: 13px;
}
.note-toc-title {
color: var(--accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
margin-bottom: 8px;
}
.note-toc ul { list-style: none; padding-left: 0; }
.note-toc li { margin: 3px 0; }
.note-toc a {
color: var(--text-secondary);
text-decoration: none;
border-bottom: none;
}
.note-toc a:hover { color: var(--accent); }
.note-toc ul ul { padding-left: 16px; }
/* Backlinks section */
.note-backlinks {
margin-top: 24px;
padding: 16px 0;
border-top: 1px solid var(--border);
font-size: 13px;
}
.note-backlinks-title {
color: var(--accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
margin-bottom: 8px;
}
.note-backlinks ul { list-style: none; padding-left: 0; }
.note-backlinks li { margin: 4px 0; }
.note-backlinks a { color: var(--blue); border-bottom: 1px dashed var(--blue); }
.note-footer {
margin-top: 32px;
padding-top: 16px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-muted);
font-family: ui-monospace, 'SF Mono', Monaco, monospace;
}
/* ============================================================
* Search dropdown
* ============================================================ */
.search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 6px 6px;
max-height: 60vh;
overflow-y: auto;
display: none;
z-index: 100;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
}
.search-results.open { display: block; }
.search-result {
display: block;
padding: 10px 16px;
color: var(--text-primary);
text-decoration: none;
border-bottom: 1px solid rgba(49, 50, 68, 0.3);
min-height: var(--tap-min);
display: flex;
flex-direction: column;
gap: 2px;
}
.search-result:hover, .search-result.active {
background: rgba(255, 215, 0, 0.08);
}
.search-result-title {
font-weight: 600;
font-size: 13px;
}
.search-result-path {
font-size: 11px;
color: var(--text-muted);
font-family: ui-monospace, monospace;
}
.search-result-tag {
display: inline-block;
background: var(--bg-surface);
color: var(--accent);
padding: 1px 6px;
border-radius: 3px;
font-size: 10px;
margin-right: 4px;
}
.search-empty {
padding: 16px;
color: var(--text-muted);
font-size: 12px;
text-align: center;
}
/* ============================================================
* Graph modal (full-screen on mobile)
* ============================================================ */
.graph-modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--bg-primary);
z-index: 200;
flex-direction: column;
padding-top: var(--sat);
padding-left: var(--sal);
padding-right: var(--sar);
padding-bottom: var(--sab);
}
.graph-modal.open { display: flex; }
.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: 48px;
flex-shrink: 0;
}
.graph-modal-title {
color: var(--accent);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.5px;
}
.graph-modal-close {
background: transparent;
border: 1px solid var(--border);
color: var(--text-secondary);
width: 36px;
height: 36px;
border-radius: 6px;
cursor: pointer;
font-size: 18px;
}
.graph-modal-close:hover { color: var(--accent); border-color: var(--accent); }
.graph-modal-canvas {
flex: 1;
position: relative;
overflow: hidden;
touch-action: none; /* critical: prevent iOS Safari scroll-hijacking */
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
min-height: 0; /* flex-child needs explicit min-height for sizing */
width: 100%;
height: 100%;
}
.graph-modal-canvas canvas {
display: block;
width: 100% !important;
height: 100% !important;
cursor: grab;
touch-action: none; /* critical: same on canvas itself */
-webkit-user-select: none;
user-select: none;
}
.graph-modal-canvas canvas:active { cursor: grabbing; }
/* Modal body must not scroll on background */
.graph-modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--bg-primary);
z-index: 200;
flex-direction: column;
padding-top: var(--sat);
padding-left: var(--sal);
padding-right: var(--sar);
padding-bottom: var(--sab);
touch-action: none;
overscroll-behavior: contain;
}
.graph-modal.open { display: flex; }
/* Graph legend (small floating panel) */
.graph-legend {
position: absolute;
bottom: 12px;
left: 12px;
background: rgba(14, 14, 26, 0.92);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
font-size: 11px;
color: var(--text-secondary);
z-index: 10;
}
.graph-legend-title {
color: var(--accent);
font-weight: 700;
font-size: 10px;
letter-spacing: 0.5px;
text-transform: uppercase;
margin-bottom: 4px;
}
.graph-legend-row { display: flex; align-items: center; gap: 6px; margin: 2px 0; }
.graph-legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
/* ============================================================
* Bottom navigation (mobile only)
* ============================================================ */
.app-bottom-nav {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
z-index: 150;
padding-bottom: var(--sab);
}
.app-bottom-nav-inner {
display: flex;
justify-content: space-around;
height: 56px;
}
.app-bottom-nav button {
flex: 1;
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 11px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
cursor: pointer;
min-height: var(--tap-min);
padding: 4px 0;
}
.app-bottom-nav button:hover, .app-bottom-nav button.active { color: var(--accent); }
.app-bottom-nav button span.icon { font-size: 18px; line-height: 1; }
/* ============================================================
* Tablet (768px-1023px) — 2-panel + bottom graph
* ============================================================ */
@media (max-width: 1023px) {
.app-shell {
grid-template-columns: 220px 1fr;
grid-template-rows: 48px 1fr 240px;
}
.app-tree {
grid-column: 1;
grid-row: 2 / 4;
border-right: 1px solid var(--border);
}
.app-content {
grid-column: 2;
grid-row: 2;
padding: 24px 32px 32px;
}
.app-graph {
grid-column: 1 / -1;
grid-row: 3;
border-left: none;
border-top: 1px solid var(--border);
height: 240px;
}
}
/* ============================================================
* Mobile (<768px) — single column + bottom-nav
* ============================================================ */
@media (max-width: 767px) {
.app-shell {
grid-template-columns: 1fr;
grid-template-rows: 48px 1fr;
}
.app-header { padding: 0 12px; gap: 8px; }
.app-header .icon-btn[data-action="toggle-tree"],
.app-header .icon-btn[data-action="toggle-graph"] {
display: none; /* hide redundant buttons, use bottom-nav */
}
.search-wrap { max-width: none; }
.app-tree {
position: fixed;
top: 48px;
top: calc(48px + var(--sat));
left: 0;
bottom: 56px;
bottom: calc(56px + var(--sab));
width: 85vw;
max-width: 320px;
transform: translateX(-100%);
transition: transform 0.25s ease-in-out;
z-index: 250;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
display: block;
}
.app-tree.open { transform: translateX(0); }
.app-content {
grid-column: 1;
grid-row: 2;
padding: 16px 20px 72px; /* bottom-nav space + safe area */
}
.app-graph { display: none; }
.app-bottom-nav { display: block; }
}
/* Backdrop for mobile tree/graph overlay */
.backdrop {
display: none;
position: fixed;
top: 48px;
top: calc(48px + var(--sat));
left: 0;
right: 0;
bottom: 56px;
bottom: calc(56px + var(--sab));
background: rgba(0, 0, 0, 0.6);
z-index: 200;
}
.backdrop.open { display: block; }
/* ============================================================
* Inline highlight for hash anchors
* ============================================================ */
:target { scroll-margin-top: 60px; }
:target h1, :target h2, :target h3 {
background: rgba(255, 215, 0, 0.08);
transition: background 0.6s ease-out;
}