Compare commits
12
Commits
d88a987d94
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad59009107 | ||
|
|
9248ae62c7 | ||
|
|
d0e4e8ab9e | ||
|
|
b5631986e6 | ||
|
|
e57b0bc784 | ||
|
|
5394f7e9bf | ||
|
|
384281c747 | ||
|
|
5e0f8d87a8 | ||
|
|
7be4656106 | ||
|
|
4bb8134b9c | ||
|
|
bb9bdf817f | ||
|
|
59e22c3113 |
@@ -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
|
||||
|
||||
@@ -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) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
})[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
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
|
||||
Executable
+346
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
Reference in New Issue
Block a user