Compare commits

..
12 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
10 changed files with 2696 additions and 1033 deletions
+92 -73
View File
@@ -1,98 +1,117 @@
# Hermes Wiki Viewer
# hermes-wiki-static
A customizable Obsidian-vault viewer with 3D Graph visualization, current-page highlighting, and responsive layout. Designed specifically for Lukas Huber's [Hermes Wiki](https://github.com/NousResearch/hermes-agent) workflow.
Static HTML wiki generator for Lukas Huber's karpathy-style Obsidian-vault.
**This is a side-project fork of [DanielCheer/obsidian-web-viewer](https://github.com/DanielCheer/obsidian-web-viewer)** (MIT, 2026-04) with customizations layered on top via an additive `vault-custom.js`. Upstream patches merge cleanly because we touch exactly one line of `vault.html`.
## What this is
## Features (in addition to upstream)
A Python tool that watches `/home/admin/my-karpathy-wiki/` for changes and
regenerates static HTML files on disk. A simple HTTP server serves them on
loopback port 8765; Tailscale exposes it to the tailnet.
- **Current-page highlighting in 3D Graph** — the node representing the open file gets a brighter material and a glow outline; connected nodes (via `[[wikilinks]]`) are also highlighted; unrelated nodes dim to 20% opacity
- **Click-to-navigate on 3D Graph** — Three.js raycaster triggers file navigation on node click
- **Responsive layout** — graph collapses to a bottom panel below 1024px viewport; tree collapses to hamburger menu below 768px
- **Backlinks panel** (planned) — see CHANGELOG.md
## Why this exists
## Features (from upstream, unchanged)
We previously used `DanielCheer/obsidian-web-viewer` (forked as
`hermes-wiki-viewer`). It had three blockers:
- File tree sidebar (collapsible folder tree)
- Markdown rendering with code blocks, tables, blockquotes, images
- Wikilink navigation (`[[links]]` click-to-traverse)
- YAML frontmatter rendered as styled card
- Full-text search by note name and content with snippets
- 3D graph visualization (Three.js) showing note connections
- Catppuccin-inspired dark theme
- Zero client-side setup — anyone with the URL can browse
- **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
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the design rationale (why `vault-custom.js`, why the minimal `vault.html` patch, why we don't fork aggressively).
```
my-karpathy-wiki/*.md (input)
|
v
[watchdog observer] (auto-regen on .md change)
|
v
generator.py (Python: markdown + frontmatter)
|
v
~/.local/share/hermes-wiki/site/ (static HTML output)
|
v
python3 http.server (loopback:8765)
|
v
tailscale serve (https://openclaw.wholphin-musical.ts.net/)
```
## Quick Start
## Files
- `generator.py` — daemon with watchdog + HTTP server
- `requirements.txt` — markdown, pyyaml, watchdog, python-frontmatter
## Run
```bash
# Clone
git clone https://github.com/LukasHuber/hermes-wiki-viewer.git
cd hermes-wiki-viewer
# Install deps (one-time, system Python)
pip install --break-system-packages markdown pyyaml watchdog python-frontmatter
# Optional: PyYAML for frontmatter
pip install -r requirements.txt
# Start daemon
python3 generator.py --start
# Point to your vault
python3 server.py --vault /path/to/your/vault --host 127.0.0.1 --port 8765
# Status / Stop
python3 generator.py --status
python3 generator.py --stop
# Open http://localhost:8765
# One-shot regen (no watcher, no HTTP server)
python3 generator.py --once
```
For Tailscale access (Lukas' typical setup), pair with `tailscale serve`:
## Output structure
```bash
tailscale serve --bg --https=443 http://127.0.0.1:8765
# Access via https://<hostname>.<tailnet>.ts.net/
```
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)
```
## Customization
## WikiLink syntax
`vault-custom.js` is Lukas' own code, organized into clear sections:
`[[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).
```js
// Current-page highlighting
window.HermesCustom = window.HermesCustom || {};
HermesCustom.highlightCurrentNode = function() { ... };
// Graph click-to-navigate
HermesCustom.setupGraphClickHandler = function() { ... };
// Responsive layout
HermesCustom.responsiveLayout = function() { ... };
```
To add a new customization, add a method to `HermesCustom` and call it from `HermesCustom.init()` at the bottom of the file.
## Updating from upstream
```bash
git remote add upstream https://github.com/DanielCheer/obsidian-web-viewer.git
git fetch upstream
git merge upstream/master
# Conflicts should only occur in vault.html — the one-line patch
```
If `vault.html` has been heavily modified upstream, manually re-apply the single patch:
```html
<!-- Add before </body>: -->
<script src="vault-custom.js"></script>
```
## License
MIT — same as upstream. See [LICENSE](LICENSE).
## Author
Lukas Huber — see [Personal-Profile](https://github.com/LukasHuber) for context.
This fork exists to make the agent-wiki viewing experience fit Lukas' specific needs (3D graph focus, responsive layout for mobile reading, current-page highlighting for fast cross-page navigation).
## Frontmatter
```yaml
---
title: My Note
type: concept
status: stable
updated: 2026-07-15
sources:
- https://example.com
tags:
- hermes
- architecture
---
```
Upstream: [DanielCheer/obsidian-web-viewer](https://github.com/DanielCheer/obsidian-web-viewer) © 2026 Daniel Cheer
Hermes Wiki Viewer fork © 2026 Lukas Huber
## 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();
}
})();
+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 -4
View File
@@ -1,4 +1,4 @@
# Obsidian Web Viewer — no required external dependencies
# Python 3.8+ standard library only
# Optional: PyYAML for frontmatter parsing
PyYAML>=6.0
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
-300
View File
@@ -1,300 +0,0 @@
"""
Obsidian Web Viewer — Browse Your Vault in the Browser
========================================================
Web-based Obsidian vault browser with 3D graph, file tree, markdown
rendering, wikilink navigation, and full-text search.
Usage:
python server.py --vault /path/to/your/vault
python server.py --vault ~/Documents/MyVault --port 8080
"""
import sys
import os
import json
import re
import argparse
import time
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from datetime import datetime
from urllib.parse import unquote, quote
if sys.platform == 'win32':
try:
sys.stdout.reconfigure(encoding='utf-8')
except Exception:
pass
ROOT = Path(__file__).parent
VAULT_DIR = None # Set via --vault CLI arg
# YAML parsing (optional)
_YAML_AVAILABLE = False
try:
import yaml
_YAML_AVAILABLE = True
except ImportError:
pass
MIME = {
".html": "text/html; charset=utf-8",
".css": "text/css",
".js": "application/javascript",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".svg": "image/svg+xml",
}
# Cache for vault tree and graph
_cache = {"tree": None, "graph": None, "ts": 0}
_CACHE_TTL = 300
def _build_tree(vault_dir: Path) -> dict:
"""Build a JSON file tree of the vault directory."""
now = time.time()
if _cache["tree"] and (now - _cache["ts"]) < _CACHE_TTL:
return _cache["tree"]
def _walk(d: Path) -> dict:
node = {"type": "folder", "name": d.name, "children": []}
try:
entries = sorted(d.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower()))
except PermissionError:
return node
for entry in entries:
if entry.name.startswith('.') or entry.name.startswith('_'):
continue
if entry.is_dir():
child = _walk(entry)
if child["children"]:
node["children"].append(child)
elif entry.suffix.lower() == '.md':
node["children"].append({"type": "file", "name": entry.name})
return node
if not vault_dir.exists():
return {"type": "folder", "name": "", "children": []}
tree = _walk(vault_dir)
tree["name"] = ""
_cache["tree"] = tree
_cache["ts"] = time.time()
return tree
def _parse_frontmatter(raw: str) -> tuple:
"""Parse YAML frontmatter from markdown. Returns (dict, body_str)."""
if not raw.startswith('---'):
return {}, raw
end = raw.find('---', 3)
if end == -1:
return {}, raw
fm_text = raw[3:end].strip()
body = raw[end + 3:].strip()
fm = {}
if _YAML_AVAILABLE:
try:
fm = yaml.safe_load(fm_text) or {}
if not isinstance(fm, dict):
fm = {"value": fm}
except Exception:
fm = {}
else:
for line in fm_text.splitlines():
if ':' in line:
k, _, v = line.partition(':')
v = v.strip().strip('"').strip("'")
if v.startswith('[') and v.endswith(']'):
v = [x.strip().strip('"').strip("'") for x in v[1:-1].split(',') if x.strip()]
fm[k.strip()] = v
return fm, body
def _search(vault_dir: Path, query: str, max_results: int = 30) -> list:
"""Search vault files by name and content."""
results = []
query_lower = query.lower()
for md_file in vault_dir.rglob("*.md"):
if md_file.name.startswith('.') or md_file.name.startswith('_'):
continue
try:
rel = md_file.relative_to(vault_dir).as_posix()
except ValueError:
continue
name = md_file.stem
name_match = query_lower in name.lower()
snippet = ""
content_match = False
if not name_match:
try:
text = md_file.read_text(encoding="utf-8", errors="ignore")[:5000]
idx = text.lower().find(query_lower)
if idx >= 0:
content_match = True
start = max(0, idx - 40)
end = min(len(text), idx + len(query) + 60)
snippet = ("..." if start > 0 else "") + text[start:end].replace("\n", " ") + ("..." if end < len(text) else "")
except Exception:
continue
if name_match or content_match:
results.append({
"path": rel, "name": name,
"folder": str(md_file.parent.relative_to(vault_dir)) if md_file.parent != vault_dir else "",
"snippet": snippet, "name_match": name_match,
})
if len(results) >= max_results:
break
results.sort(key=lambda r: (not r["name_match"], r["name"].lower()))
return results
def _build_graph(vault_dir: Path) -> dict:
"""Build a graph of wikilink connections between vault notes."""
if _cache["graph"] and (time.time() - _cache["ts"]) < _CACHE_TTL:
return _cache["graph"]
wikilink_re = re.compile(r'\[\[([^\]|]+?)(?:\|[^\]]+?)?\]\]')
nodes = []
edges = []
name_to_path = {}
all_paths = []
for md_file in vault_dir.rglob("*.md"):
if md_file.name.startswith('.') or md_file.name.startswith('_'):
continue
try:
rel = md_file.relative_to(vault_dir).as_posix()
except ValueError:
continue
all_paths.append((rel, md_file))
name_to_path[md_file.stem.lower()] = rel
for rel, md_file in all_paths:
try:
text = md_file.read_text(encoding="utf-8", errors="ignore")
except Exception:
text = ""
# Classify by folder for coloring
rp = rel.lower()
node_type = "other"
for folder in ["wiki", "reference", "daily", "journal", "notes", "projects"]:
if rp.startswith(folder + "/"):
node_type = folder
break
nodes.append({"id": rel, "label": md_file.stem, "type": node_type})
links = wikilink_re.findall(text)
seen = set()
for target in links:
resolved = name_to_path.get(target.strip().lower())
if resolved and resolved != rel and resolved not in seen:
edges.append({"source": rel, "target": resolved})
seen.add(resolved)
result = {"nodes": nodes, "edges": edges}
_cache["graph"] = result
return result
class VaultHandler(BaseHTTPRequestHandler):
def log_message(self, *a): pass
def do_GET(self):
path = self.path.split("?")[0].rstrip("/")
query_string = self.path.split("?")[1] if "?" in self.path else ""
if path == "" or path == "/" or path == "/vault":
self._serve_file(ROOT / "vault.html")
elif path == "/api/vault/tree":
self._json(_build_tree(VAULT_DIR))
elif path == "/api/vault/graph":
self._json(_build_graph(VAULT_DIR))
elif path == "/api/vault/search":
import urllib.parse
params = urllib.parse.parse_qs(query_string)
q = params.get("q", [""])[0]
self._json({"results": _search(VAULT_DIR, q) if q else []})
elif path.startswith("/api/vault/file/"):
rel = unquote(path[len("/api/vault/file/"):])
# Lukas-add: also support ?file=<path> query for clean URL navigation
if "?" in rel:
rel, _, query = rel.partition("?")
from urllib.parse import parse_qs
file_param = parse_qs(query).get("file", [None])[0]
if file_param:
rel = file_param
file_path = VAULT_DIR / rel
if not file_path.exists() or not str(file_path).startswith(str(VAULT_DIR)):
self._json({"error": "Not found"}, 404)
return
try:
raw = file_path.read_text(encoding="utf-8")
fm, body = _parse_frontmatter(raw)
self._json({"path": rel, "name": file_path.stem, "frontmatter": fm, "body": body})
except Exception as e:
self._json({"error": str(e)}, 500)
elif path == "/config.json":
self._serve_file(ROOT / "config.json")
else:
f = ROOT / path.lstrip("/")
if f.exists() and f.is_file():
self._serve_file(f)
else:
self.send_error(404)
def _serve_file(self, path):
if not path.exists():
self.send_error(404); return
data = path.read_bytes()
mime = MIME.get(path.suffix.lower(), "application/octet-stream")
self.send_response(200)
self.send_header("Content-Type", mime)
self.send_header("Content-Length", str(len(data)))
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(data)
def _json(self, obj, code=200):
data = json.dumps(obj, indent=2, default=str).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(data)
def main():
parser = argparse.ArgumentParser(description="Obsidian Web Viewer")
parser.add_argument("--vault", required=True, help="Path to your Obsidian vault folder")
parser.add_argument("--port", type=int, default=8765)
parser.add_argument("--host", default="0.0.0.0")
args = parser.parse_args()
global VAULT_DIR
VAULT_DIR = Path(args.vault).resolve()
if not VAULT_DIR.exists():
print(f"ERROR: Vault path does not exist: {VAULT_DIR}")
sys.exit(1)
md_count = sum(1 for _ in VAULT_DIR.rglob("*.md"))
print(f"Obsidian Web Viewer")
print(f" Vault: {VAULT_DIR}")
print(f" Notes: {md_count}")
print(f" URL: http://{args.host}:{args.port}")
server = ThreadingHTTPServer((args.host, args.port), VaultHandler)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nShutting down.")
if __name__ == "__main__":
main()
+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;
}
-302
View File
@@ -1,302 +0,0 @@
/* HermesCustom — Lukas' additions to obsidian-web-viewer
*
* Loaded by vault.html (one-line patch) AFTER the main script. Initializes
* automatically via HermesCustom.init() at the bottom of this file.
*
* Features:
* 1. Current-page highlighting in 3D Graph
* 2. Click-to-navigate on 3D Graph nodes (Three.js raycasting)
* 3. Responsive layout (graph collapses below 1024px, tree below 768px)
*
* Design notes: see docs/ARCHITECTURE.md
*/
(function() {
'use strict';
window.HermesCustom = window.HermesCustom || {};
const HermesCustom = window.HermesCustom;
// ============================================================
// 1. Current-page highlighting
// ============================================================
/**
* Determine which file is currently being viewed.
* Uses URL ?file=<path> parameter, or falls back to extracting from the
* page content (the last file fetched via loadFile).
*/
HermesCustom.getCurrentFile = function() {
// Method 1: ?file= query param (preferred)
const params = new URLSearchParams(window.location.search);
const fileParam = params.get('file');
if (fileParam) return fileParam;
// Method 2: Parse from URL hash if obv uses hash-based routing
if (window.location.hash) {
const hashMatch = window.location.hash.match(/file=([^&]+)/);
if (hashMatch) return decodeURIComponent(hashMatch[1]);
}
// Method 3: Track via the last loadFile() call (set up in init)
return HermesCustom._lastLoadedFile || null;
};
/**
* Find all WikiLink targets in the currently loaded file's content.
* We hook into the renderMarkdown flow by tracking what was last loaded.
*/
HermesCustom.getConnectedFiles = function(currentFile) {
if (!currentFile) return [];
// Use the global graph data loaded by obv's fetch('/api/vault/graph')
const graphData = HermesCustom._graphData;
if (!graphData) return [];
const connections = graphData.edges
.filter(e => e.source === currentFile)
.map(e => e.target);
// Also outgoing from current file (WikiLinks from current page)
// And bidirectional awareness
return connections;
};
/**
* Highlight the current node + connected nodes in the 3D Graph.
* Uses obv's global graph variables (graphScene, graphNodeObjects).
*/
HermesCustom.highlightCurrentNode = function() {
const currentFile = HermesCustom.getCurrentFile();
if (!currentFile) return;
// Hook into obv's globals (set after graph render)
if (typeof window.graphScene === 'undefined' || !window.graphNodeObjects) {
// Graph not yet built — try again shortly
setTimeout(HermesCustom.highlightCurrentNode, 500);
return;
}
const connected = new Set(HermesCustom.getConnectedFiles(currentFile));
connected.add(currentFile);
// Iterate node meshes and adjust material
window.graphNodeObjects.forEach((mesh, nodeId) => {
const isCurrent = (nodeId === currentFile);
const isConnected = connected.has(nodeId) && !isCurrent;
if (isCurrent) {
// Bright + slightly larger + emissive glow
mesh.material.color.setHex(0xFFD700); // gold
mesh.material.emissive.setHex(0xFFD700);
mesh.material.emissiveIntensity = 0.6;
mesh.scale.set(1.5, 1.5, 1.5);
} else if (isConnected) {
// Brighter than default, smaller than current
mesh.material.color.setHex(0x89b4fa); // light blue
mesh.material.emissive.setHex(0x89b4fa);
mesh.material.emissiveIntensity = 0.3;
mesh.scale.set(1.1, 1.1, 1.1);
} else {
// Dim everything else
mesh.material.color.setHex(0x313244); // muted
mesh.material.emissiveIntensity = 0;
mesh.material.opacity = 0.2;
mesh.material.transparent = true;
}
});
};
// ============================================================
// 2. Click-to-navigate on 3D Graph
// ============================================================
HermesCustom.setupGraphClickHandler = function() {
if (!window.graphRenderer || !window.graphScene || !window.graphCamera) {
setTimeout(HermesCustom.setupGraphClickHandler, 500);
return;
}
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
window.graphRenderer.domElement.addEventListener('click', (event) => {
const rect = window.graphRenderer.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, window.graphCamera);
const intersects = raycaster.intersectObjects(
Object.values(window.graphNodeObjects || {})
);
if (intersects.length > 0) {
const nodeId = intersects[0].object.userData.nodeId;
if (nodeId && typeof window.loadFile === 'function') {
window.loadFile(nodeId);
window.history.pushState({}, '', `?file=${encodeURIComponent(nodeId)}`);
}
}
});
};
// ============================================================
// 3. Responsive layout
// ============================================================
HermesCustom.responsiveLayout = function() {
const applyLayout = () => {
const app = document.querySelector('.vault-app');
if (!app) return;
const width = window.innerWidth;
if (width < 768) {
// Mobile: tree collapses to hamburger, graph hidden by default
app.classList.add('mobile-mode');
app.classList.remove('graph-visible');
} else if (width < 1024) {
// Tablet: tree visible, graph collapses to bottom panel
app.classList.add('tablet-mode');
app.classList.remove('mobile-mode');
} else {
// Desktop: full 3-panel layout
app.classList.remove('mobile-mode', 'tablet-mode');
}
};
// Throttled resize handler
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(applyLayout, 150);
});
applyLayout(); // Initial
};
/**
* Inject responsive CSS into <head> (since vault.html doesn't have these)
* Must run early so it doesn't conflict with obv's layout.
*/
HermesCustom.injectResponsiveCSS = function() {
if (document.getElementById('hermes-custom-css')) return;
const style = document.createElement('style');
style.id = 'hermes-custom-css';
style.textContent = `
/* === Tablet (768px - 1023px): graph → bottom panel === */
@media (max-width: 1023px) {
.vault-app.tablet-mode {
grid-template-columns: 240px 1fr !important;
grid-template-rows: 48px 1fr 200px !important;
}
.vault-app.tablet-mode .vault-graph {
grid-column: 1 / -1 !important;
grid-row: 3 !important;
border-left: none !important;
border-top: 1px solid var(--border);
}
.vault-app.tablet-mode .vault-content {
grid-column: 2 !important;
}
}
/* === Mobile (<768px): tree → hamburger menu === */
@media (max-width: 767px) {
.vault-app.mobile-mode {
grid-template-columns: 1fr !important;
grid-template-rows: 48px 1fr !important;
}
.vault-app.mobile-mode .vault-sidebar {
position: fixed;
top: 48px;
left: 0;
bottom: 0;
width: 280px;
transform: translateX(-100%);
transition: transform 0.2s ease;
z-index: 100;
}
.vault-app.mobile-mode .vault-sidebar.open {
transform: translateX(0);
}
.vault-app.mobile-mode .vault-graph {
display: none;
}
}
/* === Hamburger toggle button (mobile only) === */
.hermes-mobile-toggle {
display: none;
background: var(--bg-secondary);
border: 1px solid var(--border);
color: var(--text-secondary);
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
@media (max-width: 767px) {
.hermes-mobile-toggle { display: inline-block; }
}
`;
document.head.appendChild(style);
// Add hamburger button to header
const header = document.querySelector('.vault-header');
if (header && !document.querySelector('.hermes-mobile-toggle')) {
const btn = document.createElement('button');
btn.className = 'hermes-mobile-toggle';
btn.textContent = '☰ Tree';
btn.onclick = () => {
const sidebar = document.querySelector('.vault-sidebar');
if (sidebar) sidebar.classList.toggle('open');
};
header.insertBefore(btn, header.firstChild);
}
};
// ============================================================
// Init: wire everything together
// ============================================================
HermesCustom.init = function() {
console.log('[HermesCustom] init');
HermesCustom.injectResponsiveCSS();
HermesCustom.responsiveLayout();
// Hook into obv's loadFile to track current file + re-highlight
if (typeof window.loadFile === 'function') {
const originalLoadFile = window.loadFile;
window.loadFile = function(path) {
HermesCustom._lastLoadedFile = path;
const result = originalLoadFile.apply(this, arguments);
// Re-highlight after content renders
setTimeout(HermesCustom.highlightCurrentNode, 300);
return result;
};
}
// Fetch graph data once for highlight logic
fetch('/api/vault/graph')
.then(r => r.json())
.then(data => {
HermesCustom._graphData = data;
console.log(`[HermesCustom] Loaded graph: ${data.nodes.length} nodes, ${data.edges.length} edges`);
HermesCustom.highlightCurrentNode();
})
.catch(err => console.warn('[HermesCustom] Graph fetch failed:', err));
// Click handler + responsive layout (need to wait for Three.js to init)
setTimeout(() => {
HermesCustom.setupGraphClickHandler();
}, 1500);
};
// Auto-init when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', HermesCustom.init);
} else {
HermesCustom.init();
}
})();
-354
View File
@@ -1,354 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vault Viewer</title>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
:root {
--bg-primary: #0a0a14; --bg-secondary: #0e0e1a; --bg-surface: #1a1a2e;
--text-primary: #cdd6f4; --text-secondary: #a6adc8; --text-muted: #6c7086;
--accent: #FFD700; --link: #FFD700; --border: #313244;
--green: #a6e3a1; --red: #f38ba8; --blue: #89b4fa; --teal: #94e2d5;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: var(--bg-primary); color: var(--text-primary); font-family: -apple-system, 'Segoe UI', Inter, sans-serif; height: 100vh; overflow: hidden; }
.vault-app {
display: grid; grid-template-columns: 280px 1fr 260px;
grid-template-rows: 48px 1fr; height: 100vh;
}
.vault-app.graph-hidden { grid-template-columns: 280px 1fr; }
/* Header */
.vault-header {
grid-column: 1 / -1; background: var(--bg-secondary);
border-bottom: 1px solid var(--border); display: flex; align-items: center;
padding: 0 16px; gap: 12px;
}
.vault-header h1 { color: var(--accent); font-size: 14px; font-weight: 700; letter-spacing: 1px; }
.vault-header .search-box {
flex: 1; max-width: 400px; position: relative;
}
.vault-header input {
width: 100%; padding: 6px 12px 6px 32px; background: rgba(30,30,50,0.6);
border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);
font-size: 13px; outline: none;
}
.vault-header input:focus { border-color: var(--accent); }
.vault-header .toggle-btn {
background: none; border: 1px solid var(--border); color: var(--text-muted);
padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px;
}
.vault-header .toggle-btn:hover { border-color: var(--accent); color: var(--accent); }
/* Sidebar file tree */
.vault-sidebar {
background: var(--bg-secondary); border-right: 1px solid var(--border);
overflow-y: auto; padding: 8px 0; font-size: 13px;
}
.tree-folder { cursor: pointer; user-select: none; }
.tree-folder-header {
display: flex; align-items: center; padding: 4px 12px; gap: 6px;
color: var(--text-secondary); border-radius: 4px; margin: 1px 4px;
}
.tree-folder-header:hover { background: rgba(255,215,0,0.05); }
.tree-folder-header .arrow { color: var(--text-muted); font-size: 10px; width: 12px; }
.tree-children { padding-left: 12px; display: none; }
.tree-children.open { display: block; }
.tree-file {
display: flex; align-items: center; padding: 4px 12px; gap: 6px;
color: var(--text-primary); cursor: pointer; border-radius: 4px; margin: 1px 4px;
font-size: 13px;
}
.tree-file:hover { background: rgba(255,215,0,0.05); }
.tree-file.active { background: rgba(255,215,0,0.1); color: var(--accent); }
/* Content area */
.vault-content {
overflow-y: auto; padding: 32px 48px; max-width: 900px;
line-height: 1.7; font-size: 15px;
}
.vault-content h1 { color: var(--accent); font-size: 28px; margin: 24px 0 12px; }
.vault-content h2 { color: var(--accent); font-size: 22px; margin: 20px 0 10px; border-bottom: 1px solid var(--border); padding-bottom: 4px; }
.vault-content h3 { color: var(--text-primary); font-size: 18px; margin: 16px 0 8px; }
.vault-content p { margin: 8px 0; }
.vault-content a { color: var(--link); text-decoration: none; }
.vault-content a:hover { text-decoration: underline; }
.vault-content code { background: var(--bg-surface); padding: 2px 6px; border-radius: 4px; font-size: 13px; color: var(--teal); }
.vault-content pre { background: var(--bg-surface); padding: 16px; border-radius: 8px; overflow-x: auto; margin: 12px 0; }
.vault-content pre code { padding: 0; background: none; }
.vault-content blockquote { border-left: 3px solid var(--accent); padding-left: 16px; color: var(--text-secondary); margin: 12px 0; }
.vault-content ul, .vault-content ol { padding-left: 24px; }
.vault-content li { margin: 4px 0; }
.vault-content table { border-collapse: collapse; width: 100%; margin: 12px 0; }
.vault-content th, .vault-content td { border: 1px solid var(--border); padding: 8px 12px; text-align: left; }
.vault-content th { background: var(--bg-surface); color: var(--accent); font-size: 13px; }
.vault-content img { max-width: 100%; border-radius: 8px; }
.frontmatter { background: var(--bg-surface); border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px; margin-bottom: 16px; font-size: 12px; }
.frontmatter .fm-key { color: var(--text-muted); }
.frontmatter .fm-val { color: var(--text-primary); margin-left: 8px; }
/* Graph panel */
.vault-graph {
background: var(--bg-secondary); border-left: 1px solid var(--border);
position: relative; overflow: hidden;
}
.vault-graph canvas { width: 100% !important; height: 100% !important; }
/* Search results overlay */
#search-results {
position: absolute; top: 48px; left: 50%; transform: translateX(-50%);
width: 420px; max-height: 400px; overflow-y: auto;
background: rgba(14,14,26,0.95); border: 1px solid var(--border);
border-radius: 8px; z-index: 100; display: none;
backdrop-filter: blur(12px);
}
.search-item {
padding: 8px 14px; cursor: pointer; border-bottom: 1px solid rgba(49,50,68,0.5);
}
.search-item:hover { background: rgba(255,215,0,0.05); }
.search-item .name { color: var(--accent); font-weight: 600; }
.search-item .folder { color: var(--text-muted); font-size: 11px; margin-left: 8px; }
.search-item .snippet { color: var(--text-secondary); font-size: 12px; margin-top: 2px; }
/* Welcome screen */
.welcome {
display: flex; flex-direction: column; align-items: center; justify-content: center;
height: 100%; color: var(--text-muted); text-align: center;
}
.welcome h2 { color: var(--accent); margin-bottom: 8px; }
</style>
</head>
<body>
<div class="vault-app" id="app">
<div class="vault-header">
<h1>Vault</h1>
<div class="search-box">
<input type="text" id="search-input" placeholder="Search notes..." oninput="onSearch(this.value)" onfocus="showSearch()" onblur="setTimeout(hideSearch, 200)">
</div>
<button class="toggle-btn" onclick="toggleGraph()">Graph</button>
</div>
<div class="vault-sidebar" id="sidebar"></div>
<div class="vault-content" id="content">
<div class="welcome">
<h2>Welcome to Vault Viewer</h2>
<p>Select a note from the file tree or use search.</p>
</div>
</div>
<div class="vault-graph" id="graph-panel">
<canvas id="graph-canvas"></canvas>
</div>
</div>
<div id="search-results"></div>
<script>
let currentFile = null;
let graphVisible = true;
let graphData = null;
// Load file tree
async function loadTree() {
const resp = await fetch('/api/vault/tree');
const tree = await resp.json();
document.getElementById('sidebar').innerHTML = renderTree(tree, '');
}
function renderTree(node, path) {
if (node.type === 'file') {
const fullPath = path ? path + '/' + node.name : node.name;
return `<div class="tree-file" onclick="loadFile('${fullPath.replace(/'/g, "\\'")}')" data-path="${fullPath}">${node.name.replace('.md', '')}</div>`;
}
const fullPath = path ? path + '/' + node.name : node.name;
let html = '';
if (node.name) {
html += `<div class="tree-folder">
<div class="tree-folder-header" onclick="this.nextElementSibling.classList.toggle('open');this.querySelector('.arrow').textContent=this.nextElementSibling.classList.contains('open')?'v':'>'">
<span class="arrow">></span> ${node.name}
</div>
<div class="tree-children">`;
}
(node.children || []).forEach(c => { html += renderTree(c, fullPath); });
if (node.name) html += '</div></div>';
return html;
}
// Load file content
async function loadFile(path) {
// Highlight active
document.querySelectorAll('.tree-file').forEach(el => el.classList.remove('active'));
const activeEl = document.querySelector(`.tree-file[data-path="${path}"]`);
if (activeEl) activeEl.classList.add('active');
const resp = await fetch('/api/vault/file/' + encodeURIComponent(path));
const data = await resp.json();
if (data.error) {
document.getElementById('content').innerHTML = `<p style="color:var(--red)">${data.error}</p>`;
return;
}
currentFile = path;
let html = '';
// Frontmatter
if (data.frontmatter && Object.keys(data.frontmatter).length > 0) {
html += '<div class="frontmatter">';
Object.entries(data.frontmatter).forEach(([k, v]) => {
const val = Array.isArray(v) ? v.join(', ') : String(v);
html += `<div><span class="fm-key">${k}:</span><span class="fm-val">${val}</span></div>`;
});
html += '</div>';
}
// Markdown body — convert wikilinks first
let body = data.body || '';
body = body.replace(/\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g, (match, target, alias) => {
const display = alias || target;
return `<a href="#" onclick="navigateWikilink('${target.replace(/'/g, "\\'")}');return false">${display}</a>`;
});
html += marked.parse(body);
document.getElementById('content').innerHTML = html;
document.getElementById('content').scrollTop = 0;
}
// Wikilink navigation
async function navigateWikilink(target) {
// Search for the target file
const resp = await fetch('/api/vault/search?q=' + encodeURIComponent(target));
const data = await resp.json();
if (data.results && data.results.length > 0) {
// Find exact name match first
const exact = data.results.find(r => r.name.toLowerCase() === target.toLowerCase());
loadFile((exact || data.results[0]).path);
}
}
// Search
let searchTimeout = null;
function onSearch(query) {
clearTimeout(searchTimeout);
if (!query.trim()) { hideSearch(); return; }
searchTimeout = setTimeout(async () => {
const resp = await fetch('/api/vault/search?q=' + encodeURIComponent(query));
const data = await resp.json();
const el = document.getElementById('search-results');
if (!data.results || data.results.length === 0) {
el.innerHTML = '<div class="search-item"><span style="color:var(--text-muted)">No results</span></div>';
} else {
el.innerHTML = data.results.map(r =>
`<div class="search-item" onmousedown="loadFile('${r.path.replace(/'/g, "\\'")}');hideSearch()">
<div><span class="name">${r.name}</span><span class="folder">${r.folder}</span></div>
${r.snippet ? '<div class="snippet">' + r.snippet + '</div>' : ''}
</div>`
).join('');
}
el.style.display = 'block';
}, 200);
}
function showSearch() { if (document.getElementById('search-input').value) onSearch(document.getElementById('search-input').value); }
function hideSearch() { document.getElementById('search-results').style.display = 'none'; }
// Graph toggle
function toggleGraph() {
graphVisible = !graphVisible;
document.getElementById('app').classList.toggle('graph-hidden', !graphVisible);
if (graphVisible && !graphData) loadGraph();
}
// 3D Graph
let graphScene, graphCamera, graphRenderer;
async function loadGraph() {
const resp = await fetch('/api/vault/graph');
graphData = await resp.json();
const container = document.getElementById('graph-panel');
const canvas = document.getElementById('graph-canvas');
const W = container.clientWidth;
const H = container.clientHeight;
graphScene = new THREE.Scene();
graphScene.background = new THREE.Color(0x0e0e1a);
graphCamera = new THREE.PerspectiveCamera(60, W / H, 1, 2000);
graphCamera.position.set(0, 0, 300);
graphRenderer = new THREE.WebGLRenderer({ canvas, antialias: true });
graphRenderer.setSize(W, H);
// Position nodes in a 3D force layout (simplified)
const nodePositions = {};
const nodes = graphData.nodes || [];
nodes.forEach((n, i) => {
const angle = (i / nodes.length) * Math.PI * 2;
const radius = 80 + Math.random() * 120;
nodePositions[n.id] = {
x: Math.cos(angle) * radius + (Math.random() - 0.5) * 40,
y: (Math.random() - 0.5) * 100,
z: Math.sin(angle) * radius + (Math.random() - 0.5) * 40,
};
});
// Node dots
const colors = { wiki: 0x89b4fa, daily: 0xf9e2af, journal: 0xa6e3a1, projects: 0xf5c2e7, other: 0x6c7086 };
nodes.forEach(n => {
const pos = nodePositions[n.id];
const color = colors[n.type] || colors.other;
const geo = new THREE.SphereGeometry(1.5, 8, 8);
const mat = new THREE.MeshBasicMaterial({ color });
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(pos.x, pos.y, pos.z);
graphScene.add(mesh);
});
// Edges
(graphData.edges || []).forEach(e => {
const s = nodePositions[e.source];
const t = nodePositions[e.target];
if (!s || !t) return;
const geo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(s.x, s.y, s.z),
new THREE.Vector3(t.x, t.y, t.z),
]);
const mat = new THREE.LineBasicMaterial({ color: 0x313244, transparent: true, opacity: 0.3 });
graphScene.add(new THREE.Line(geo, mat));
});
// Orbit
let isDrag = false, lastX = 0, lastY = 0, rotX = 0, rotY = 0;
canvas.addEventListener('mousedown', e => { isDrag = true; lastX = e.clientX; lastY = e.clientY; });
canvas.addEventListener('mousemove', e => {
if (!isDrag) return;
rotY += (e.clientX - lastX) * 0.005;
rotX += (e.clientY - lastY) * 0.005;
lastX = e.clientX; lastY = e.clientY;
});
canvas.addEventListener('mouseup', () => isDrag = false);
canvas.addEventListener('wheel', e => {
graphCamera.position.z += e.deltaY * 0.5;
graphCamera.position.z = Math.max(50, Math.min(800, graphCamera.position.z));
});
function animateGraph() {
requestAnimationFrame(animateGraph);
graphScene.rotation.y = rotY;
graphScene.rotation.x = rotX;
graphRenderer.render(graphScene, graphCamera);
}
animateGraph();
}
// Init
loadTree();
loadGraph();
</script>
</body>
<script src="vault-custom.js"></script>
</html>