Phase 1c: init-vault + Indexer + Webapp (Erfolgskriterium)

1.3 init-vault:
- Erstellt komplette Vault-Struktur (12 Ordner + .gitignore + Konfig.md + README + CLAUDE.md)
- 2 Demo-Notizen mit heutigem Datum
- git init mit main-Branch
- Fragt interaktiv nach gitea-URL (oder --remote Parameter, --no-interactive)
- Macht initialen Commit
- Idempotent (Vault-existiert-Skip oder --force)
- Argumente: --force, --no-commit, --no-interactive, --remote <url>

1.5 Indexer:
- Deterministisch: 2 Laeufe ergeben byte-identische Outputs (Test bestaetigt)
- Liest Vault, parst Frontmatter mit gray-matter
- Extrahiert Wikilinks mit Regex
- Schreibt landkarte.json + 99_System/Index.md + 10_Wiki/Index.md
- Ueberspringt eigene Outputs (Index.md) und System-Ordner
- Pfad-Normalisierung zu Forward-Slashes fuer Windows-Kompatibilitaet
- Statisches generated-Feld (kein Timestamp) fuer Determinismus

1.6 Webapp:
- Vite + React + TypeScript + Cytoscape.js
- Liest landkarte.json, rendert Force-directed Graph
- Knoten nach Typ eingefaerbt (inbox/daily/wiki/project)
- Hover/Klick zeigt Node-Details
- Build in 2.25s, 32 Module, 590 KB JS (Cytoscape last)

Tests:
- 12 Unit-Tests gruen (10 Vault-Path + 2 Determinismus)
- Indexer-Smoke: 5 Notizen, 3 Kanten auf Test-Vault
- Webapp-Build erfolgreich

Phase 1 Erfolgskriterium: Webapp zeigt Notizen als verbundenen Graph (Build verifiziert)
This commit is contained in:
Jannik committed 2026-09-05 11:30:04 +02:00
1 parent f86c3bf8ce
commit 4f174e748e
13 files changed
+2693 -11

No files matched your search

+6
View File
@@ -23,3 +23,9 @@ models/
# System / Cache
99 System/.cache/
99 System/logs/
# Webapp
webapp/node_modules/
webapp/dist/
webapp/vite-build.log
webapp/vite-build.log.err
+285 -4
View File
@@ -1,7 +1,288 @@
// 1.5 index: Indiziert den Vault
// Phase 1.5: STUB
// 1.5 index: Deterministischer Vault-Indexer
// Phase 1.5
import { readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs';
import { join, sep } from 'node:path';
import matter from 'gray-matter';
interface VaultNode {
id: string; // Datei-Pfad relativ zum Vault
title: string;
type: 'inbox' | 'daily' | 'wiki' | 'project' | 'unknown';
cluster: string | null;
status: string | null;
created: string | null;
aktualisiert: string | null;
tags: string[];
size: number;
}
interface VaultEdge {
from: string; // Source-Node-ID
to: string; // Target-Node-ID (Wikilink)
type: 'wikilink';
}
interface Landkarte {
nodes: VaultNode[];
edges: VaultEdge[];
generated: string; // statisch fuer Determinismus (siehe Generator)
vault: string; // Vault-Pfad
}
const SKIP_DIRS = new Set([
'99_System/Cache',
'99_System/Logs',
'99_System/Skripte/Lock',
'.qmd',
'node_modules',
'.obsidian',
'.git',
]);
const SKIP_FILES = new Set([
'99_System/Index.md', // Output des Indexers
'10_Wiki/Index.md', // Output des Indexers
]);
const WIKILINK_RE = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
export async function indexCommand(vaultPath: string, _args: string[]): Promise<void> {
console.log(`index: würde Vault indizieren unter ${vaultPath}`);
console.log(' (Stub — vollständige Implementierung kommt in Phase 1.5)');
console.log(`Indexer: ${vaultPath}`);
// 1. Alle Markdown-Dateien finden
const mdFiles = findMarkdownFiles(vaultPath);
console.log(` ${mdFiles.length} Markdown-Dateien gefunden`);
// 2. Pro Datei: Node erstellen
const nodes: VaultNode[] = [];
const nodeIds = new Set<string>();
for (const file of mdFiles) {
try {
const node = parseVaultFile(vaultPath, file);
nodes.push(node);
nodeIds.add(node.id);
} catch (err) {
console.warn(` Warnung: konnte ${file} nicht parsen: ${(err as Error).message}`);
}
}
// 3. Edges (Wikilinks) extrahieren
const edges: VaultEdge[] = [];
for (const node of nodes) {
const filePath = join(vaultPath, node.id);
const content = readFileSync(filePath, 'utf-8');
const matches = content.matchAll(WIKILINK_RE);
for (const match of matches) {
const target = normalizeWikilinkTarget(match[1]);
// Nur Edges zu existierenden Nodes (oder toter Link separat)
if (nodeIds.has(target)) {
edges.push({ from: node.id, to: target, type: 'wikilink' });
} else {
// Toter Link: optional in edges mit Marker
edges.push({ from: node.id, to: target, type: 'wikilink' });
}
}
}
// 4. Sortieren für Determinismus
nodes.sort((a, b) => a.id.localeCompare(b.id));
edges.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
// 5. Deduplizieren
const seenEdge = new Set<string>();
const uniqueEdges = edges.filter((e) => {
const key = `${e.from}\0${e.to}`;
if (seenEdge.has(key)) return false;
seenEdge.add(key);
return true;
});
// 6. landkarte.json schreiben (statisches generated-Feld fuer Determinismus)
const landkarte: Landkarte = {
nodes,
edges: uniqueEdges,
generated: 'mindomat-indexer-0.1.0',
vault: vaultPath,
};
writeFileSync(join(vaultPath, 'landkarte.json'), JSON.stringify(landkarte, null, 2) + '\n');
console.log(` landkarte.json: ${nodes.length} Knoten, ${uniqueEdges.length} Kanten`);
// 7. 99_System/Index.md schreiben (Katalog)
const sysIndex = renderSystemIndex(nodes, uniqueEdges);
writeFileSync(join(vaultPath, '99_System', 'Index.md'), sysIndex);
console.log(' 99_System/Index.md geschrieben');
// 8. 10_Wiki/Index.md schreiben (nur Wiki-Seiten)
const wikiNodes = nodes.filter((n) => n.type === 'wiki');
const wikiIndex = renderWikiIndex(wikiNodes);
writeFileSync(join(vaultPath, '10_Wiki', 'Index.md'), wikiIndex);
console.log(` 10_Wiki/Index.md geschrieben (${wikiNodes.length} Wiki-Seiten)`);
console.log('Index fertig.');
}
function findMarkdownFiles(vaultPath: string): string[] {
const result: string[] = [];
function walk(dir: string, relPath: string) {
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return;
}
entries.sort(); // Determinismus
for (const name of entries) {
const fullPath = join(dir, name);
const relToVault = relPath ? join(relPath, name) : name;
// Normalisiere zu Forward-Slashes fuer deterministische Vergleiche
const relNorm = relToVault.split(sep).join('/');
// Skip-Verzeichnisse
if (SKIP_DIRS.has(relNorm) || SKIP_DIRS.has(name)) continue;
let stat;
try {
stat = statSync(fullPath);
} catch {
continue;
}
if (stat.isDirectory()) {
walk(fullPath, relToVault);
} else if (stat.isFile() && name.endsWith('.md')) {
if (SKIP_FILES.has(relNorm)) continue;
result.push(relToVault);
}
}
}
walk(vaultPath, '');
return result;
}
function parseVaultFile(vaultPath: string, relPath: string): VaultNode {
const fullPath = join(vaultPath, relPath);
const stat = statSync(fullPath);
const raw = readFileSync(fullPath, 'utf-8');
const parsed = matter(raw);
const fm = parsed.data as Record<string, unknown>;
const id = relPath.split(sep).join('/'); // Forward-Slashes, deterministisch
return {
id,
title: (fm.title as string) ?? id.replace(/\.md$/, ''),
type: detectType(id, fm),
cluster: (fm.cluster as string) ?? null,
status: (fm.status as string) ?? null,
created: (fm.created as string) ?? null,
aktualisiert: (fm.aktualisiert as string) ?? null,
tags: Array.isArray(fm.tags) ? (fm.tags as string[]) : [],
size: stat.size,
};
}
function detectType(id: string, fm: Record<string, unknown>): VaultNode['type'] {
if (fm.type === 'wiki' || id.startsWith('10_Wiki/')) return 'wiki';
if (id.startsWith('00_Inbox/')) return 'inbox';
if (id.startsWith('01_Daily/') || /\d{4}-\d{2}-\d{2}\.md$/.test(id)) return 'daily';
if (id.startsWith('20_Projekte/') || fm.type === 'project') return 'project';
return 'unknown';
}
function normalizeWikilinkTarget(raw: string): string {
// Wikilink ohne .md-Endung → füge .md hinzu
let target = raw.trim();
if (!target.endsWith('.md')) target += '.md';
// Relative Pfade auflösen
return target;
}
function renderSystemIndex(nodes: VaultNode[], edges: VaultEdge[]): string {
const lines: string[] = [];
lines.push('# Vault-Katalog');
lines.push('');
lines.push(`> ${nodes.length} Notizen, ${edges.length} Wikilinks. Generiert vom Indexer.`);
lines.push('');
// Verwaiste Links finden
const allNodeIds = new Set(nodes.map((n) => n.id));
const deadLinks = new Set<string>();
for (const e of edges) {
if (!allNodeIds.has(e.to)) {
deadLinks.add(e.to);
}
}
if (deadLinks.size > 0) {
lines.push('## Verwaiste Links');
lines.push('');
for (const dead of [...deadLinks].sort()) {
lines.push(`- [[${dead.replace(/\.md$/, '')}]]`);
}
lines.push('');
}
// Moegliche Duplikate (einfacher Hash-Vergleich)
lines.push('## Notizen nach Typ');
lines.push('');
const byType: Record<string, VaultNode[]> = {};
for (const n of nodes) {
if (!byType[n.type]) byType[n.type] = [];
byType[n.type].push(n);
}
for (const type of ['inbox', 'daily', 'wiki', 'project', 'unknown']) {
const items = byType[type];
if (!items || items.length === 0) continue;
lines.push(`### ${capitalize(type)} (${items.length})`);
lines.push('');
for (const n of items) {
const flag = n.aktualisiert ? '' : ' ⚠️';
const statusInfo = n.status ? ` [${n.status}]` : '';
lines.push(`- ${n.id}${flag}${statusInfo}`);
}
lines.push('');
}
return lines.join('\n');
}
function renderWikiIndex(wikiNodes: VaultNode[]): string {
const lines: string[] = [];
lines.push('# Wiki-Verzeichnis');
lines.push('');
lines.push(`> ${wikiNodes.length} Wiki-Seiten.`);
lines.push('');
if (wikiNodes.length === 0) {
lines.push('_Noch keine Wiki-Seiten vorhanden._');
return lines.join('\n');
}
// Nach Status gruppieren
const byStatus: Record<string, VaultNode[]> = {};
for (const n of wikiNodes) {
const s = n.status ?? 'neu';
if (!byStatus[s]) byStatus[s] = [];
byStatus[s].push(n);
}
for (const status of ['aktuell', 'neu', 'konflikt', 'entwurf', 'archiv']) {
const items = byStatus[status];
if (!items || items.length === 0) continue;
lines.push(`## ${capitalize(status)} (${items.length})`);
lines.push('');
for (const n of items) {
lines.push(`- [[${n.id.replace(/\.md$/, '')}]]`);
}
lines.push('');
}
return lines.join('\n');
}
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
+246 -6
View File
@@ -1,12 +1,252 @@
// 1.3 init-vault: Erstellt einen neuen Vault
// Phase 1.3: STUB — wird in 1.3 implementiert
// 1.3 init-vault: Erstellt einen neuen Vault komplett
// Phase 1.3
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { execSync } from 'node:child_process';
import { createInterface } from 'node:readline/promises';
import { stdin, stdout } from 'node:process';
const VAULT_DIRS = [
'00_Inbox',
'00_Inbox/Verarbeitet',
'00_Inbox/Problemfaelle',
'01_Daily',
'10_Wiki',
'10_Wiki/Seiten',
'20_Projekte',
'90_Templates',
'99_System',
'99_System/Skripte',
'99_System/Logs',
'99_System/Cache',
];
const GITIGNORE = `# Obsidian
.obsidian/workspace.json
.obsidian/plugins/
.obsidian/themes/
.obsidian/appearance.json
.obsidian/hotkeys.json
# Node
node_modules/
*.log
npm-debug.log*
# OS
.DS_Store
Thumbs.db
desktop.ini
# QMD / Modell-Cache (~2 GB)
.qmd/
99_System/Cache/
*.qmd-cache
models/
# Logs (Runtime-Muell)
99_System/Logs/*.log
99_System/Logs/*.err
# Lockfiles (Concurrency)
99_System/Skripte/Lock/
`;
const KONFIG_MD = `---
veraltet_schwellwert_monate: 12
token_budget_default: 4000
token_budget_hardcap: false
qmd_cache_pfad: 99_System/Cache
sync_remote: https://git.orfel.de/Dein-Name/Dein-Vault.git
ki_provider: minimax
ki_modell: MiniMax-M3
ki_api_url: https://api.minimaxi.chat/v1
ki_api_key_env: MINIMAX_API_KEY
---
`;
const README_MD = `# Mind-o-Mat Vault
Dein persoenlicher Second-Brain-Vault. Markdown-Dateien, getrennt vom Tool-Repo.
## Was ist hier
- \`00_Inbox/\` — Eingangskorb, unsortiert
- \`01_Daily/\` — Tagesnotizen
- \`10_Wiki/\` — kuratierte Wiki-Seiten (von Ingest gepflegt)
- \`20_Projekte/\` — aktive Initiativen
- \`90_Templates/\` — Notiz-Vorlagen
- \`99_System/\` — Konfig, Skripte, Logs, Cache
## Sync
Sync via gitea. Konfiguriere die Remote-URL in \`99_System/Konfig.md\` (\`sync_remote\`).
## Tool
Gehoert zu Mind-o-Mat, separater Code-Repo. Skripte liegen in \`99_System/Skripte/\`.
## Naechste Schritte
1. QMD installieren: \`npm install -g @tobilu/qmd\`
2. Collections anlegen: \`qmd collection add 00_Inbox --name inbox\` (analog fuer daily/wiki/projekte)
3. Embeddings generieren: \`qmd embed\`
4. Indexer laufen lassen: \`mindomat index\`
`;
const CLAUDE_TEMPLATE = `# CLAUDE.md — Brain-First-Suchleiter
Wird in Phase 2 (Wiki + Ingest) mit echtem Prompt befuellt.
Lese-Reihenfolge:
1. \`99_System/Index.md\` (Katalog)
2. \`10_Wiki/Index.md\` (Wiki-Struktur)
3. Wiki-Quellen reichen? -> antworten
4. Sonst: QMD-Suche (\`qmd query "X"\`)
5. Top-3 nach Relevanz
6. Antwort als nummerierte Liste, max. 3 Quellen
`;
const DEMO_KAFFEE = `---
title: Kaffee Ritual
created: <DATUM>
tags: [Gewohnheit, Persoenliches]
---
# Kaffee Ritual
Jeden Morgen um 7 Uhr steht der erste Kaffee auf dem Tisch. Filterkaffee, kein Espresso, gemahlen aus der Handmuehle. Drei Minuten laufen lassen, dann ein Schuss Hafermilch. Kein Zucker.
Was ich am Ritual schaetze: der Moment zwischen Aufwachen und Funktionieren. Das Handy bleibt aus, der Kaffee dampft, und ich denke an den Tag. Meistens faellt mir in diesen drei Minuten etwas ein, das am Abend vorher verloren ging.
Bezug zu [[2026-09-05_Second_Brain_Notiz]]: das System soll auch persoenliche Routinen kennen.
`;
const DEMO_SECOND_BRAIN = `---
title: Second Brain Notiz
created: <DATUM>
tags: [Wissen, Projekt/Mindomat]
---
# Second Brain Aufbau
Ich baue gerade mein eigenes Second-Brain-System. Lokal, auf meinem Rechner, kein Cloud-Sync ueber externe Dienste. Die Idee: ich will eine Wissensbasis, der ich vertrauen kann.
Bezug zu [[2026-09-05_Kaffee_Ritual]]: auch persoenliche Routinen sollen ins Wiki, nicht nur Faktenwissen.
`;
interface InitOptions {
force?: boolean;
remote?: string;
noCommit?: boolean;
interactive?: boolean;
}
export async function initVaultCommand(args: string[]): Promise<void> {
const target = args[0];
// Args parsen
const opts: InitOptions = { interactive: true };
const positional: string[] = [];
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--force') opts.force = true;
else if (a === '--no-commit') opts.noCommit = true;
else if (a === '--no-interactive') opts.interactive = false;
else if (a === '--remote') opts.remote = args[++i];
else positional.push(a);
}
const target = positional[0];
if (!target) {
console.error('Verwendung: mindomat init-vault <pfad>');
console.error('Verwendung: mindomat init-vault <pfad> [--force] [--no-commit] [--no-interactive] [--remote <url>]');
process.exit(2);
}
console.log(`init-vault: würde Vault erstellen unter ${target}`);
console.log(' (Stub — vollständige Implementierung kommt in Phase 1.3)');
const targetPath = resolve(process.cwd(), target);
// Existiert schon ein Vault?
const konfigPath = join(targetPath, '99_System', 'Konfig.md');
if (existsSync(konfigPath) && !opts.force) {
console.log(`Vault existiert bereits: ${targetPath}`);
console.log(' Skippe Erstellung. Nutze --force zum Ueberschreiben.');
return;
}
console.log(`Erstelle Vault unter: ${targetPath}`);
// 1. Ordner anlegen
for (const d of VAULT_DIRS) {
mkdirSync(join(targetPath, d), { recursive: true });
}
console.log(` ${VAULT_DIRS.length} Ordner angelegt`);
// 2. .gitignore
writeFileSync(join(targetPath, '.gitignore'), GITIGNORE);
// 3. Konfig.md
writeFileSync(konfigPath, KONFIG_MD);
// 4. README.md
writeFileSync(join(targetPath, 'README.md'), README_MD);
// 5. CLAUDE.md
writeFileSync(join(targetPath, 'CLAUDE.md'), CLAUDE_TEMPLATE);
// 6. Demo-Notizen (mit heutigem Datum)
const today = new Date().toISOString().slice(0, 10);
writeFileSync(
join(targetPath, '00_Inbox', `${today}_Kaffee_Ritual.md`),
DEMO_KAFFEE.replace(/<DATUM>/g, today)
);
writeFileSync(
join(targetPath, '00_Inbox', `${today}_Second_Brain_Notiz.md`),
DEMO_SECOND_BRAIN.replace(/<DATUM>/g, today)
);
console.log(' 2 Demo-Notizen angelegt');
// 7. git init
if (!existsSync(join(targetPath, '.git'))) {
execSync('git init -b main', { cwd: targetPath, stdio: 'inherit' });
console.log(' git init (main-Branch)');
}
// 8. gitea-Remote
let remoteUrl = opts.remote;
if (!remoteUrl && opts.interactive && stdin.isTTY) {
const rl = createInterface({ input: stdin, output: stdout });
const answer = await rl.question('gitea-URL (leer = kein Remote): ');
rl.close();
remoteUrl = answer.trim() || undefined;
}
if (remoteUrl) {
try {
execSync(`git remote add origin ${remoteUrl}`, { cwd: targetPath, stdio: 'inherit' });
console.log(` Remote 'origin' gesetzt: ${remoteUrl}`);
} catch (err) {
console.warn(` Konnte Remote nicht setzen: ${(err as Error).message}`);
}
}
// 9. Erster Commit
if (!opts.noCommit) {
try {
execSync('git add .', { cwd: targetPath, stdio: 'inherit' });
execSync('git commit -m "Initial: Vault-Struktur, Konfig, Demo-Notizen"', { cwd: targetPath, stdio: 'inherit' });
console.log(' Initial-Commit erstellt');
} catch (err) {
console.warn(` Konnte Commit nicht erstellen: ${(err as Error).message}`);
}
}
console.log('');
console.log('Vault erstellt. Naechste Schritte:');
console.log(` cd ${targetPath}`);
console.log(' npm install -g @tobilu/qmd');
console.log(' qmd collection add 00_Inbox --name inbox');
console.log(' qmd collection add 01_Daily --name daily');
console.log(' qmd collection add 10_Wiki --name wiki');
console.log(' qmd collection add 20_Projekte --name projekte');
console.log(' qmd embed');
console.log(' mindomat index');
}
+95
View File
@@ -0,0 +1,95 @@
// Deterministik-Test: zwei Indexer-Laeufe ergeben byte-identische Outputs
// Phase 1.5
import { describe, it, expect } from 'vitest';
import { execSync } from 'node:child_process';
import { readFileSync, mkdtempSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const TOOL_PATH = 'C:/GitHub/Mind-o-Mat';
const CLI = `node ${TOOL_PATH}/bin/mindomat.mjs`;
function createTestVault(): string {
const dir = mkdtempSync(join(tmpdir(), 'mindomat-determinism-'));
// 12 Ordner
const dirs = [
'00_Inbox', '00_Inbox/Verarbeitet', '00_Inbox/Problemfaelle',
'01_Daily', '10_Wiki', '10_Wiki/Seiten',
'20_Projekte', '90_Templates',
'99_System', '99_System/Skripte', '99_System/Logs', '99_System/Cache',
];
for (const d of dirs) {
mkdirSync(join(dir, d), { recursive: true });
}
// 3 Test-Notizen mit deterministischen Wikilinks
writeFileSync(join(dir, '00_Inbox', '2026-01-01_A.md'), `---
title: A
created: 2026-01-01
---
# A
Verweist auf [[B]] und [[C]].
`);
writeFileSync(join(dir, '00_Inbox', '2026-01-02_B.md'), `---
title: B
created: 2026-01-02
---
# B
Verweist auf [[A]].
`);
writeFileSync(join(dir, '00_Inbox', '2026-01-03_C.md'), `---
title: C
created: 2026-01-03
---
# C
Kein Link.
`);
return dir;
}
describe('Indexer Determinismus', () => {
it('zwei Laeufe ergeben byte-identische Outputs', () => {
const vault = createTestVault();
try {
// Erster Lauf
execSync(`${CLI} index --vault "${vault}" --no-interactive`, { stdio: 'pipe' });
const l1 = readFileSync(join(vault, 'landkarte.json'), 'utf-8');
const i1sys = readFileSync(join(vault, '99_System', 'Index.md'), 'utf-8');
const i1wiki = readFileSync(join(vault, '10_Wiki', 'Index.md'), 'utf-8');
// Zweiter Lauf
execSync(`${CLI} index --vault "${vault}" --no-interactive`, { stdio: 'pipe' });
const l2 = readFileSync(join(vault, 'landkarte.json'), 'utf-8');
const i2sys = readFileSync(join(vault, '99_System', 'Index.md'), 'utf-8');
const i2wiki = readFileSync(join(vault, '10_Wiki', 'Index.md'), 'utf-8');
expect(l2).toBe(l1);
expect(i2sys).toBe(i1sys);
expect(i2wiki).toBe(i1wiki);
} finally {
rmSync(vault, { recursive: true, force: true });
}
});
it('landkarte.json enthaelt alle Notizen + Edges', () => {
const vault = createTestVault();
try {
execSync(`${CLI} index --vault "${vault}" --no-interactive`, { stdio: 'pipe' });
const landkarte = JSON.parse(readFileSync(join(vault, 'landkarte.json'), 'utf-8'));
expect(landkarte.nodes.length).toBe(3);
// A->B, A->C, B->A = 3 Edges
expect(landkarte.edges.length).toBe(3);
} finally {
rmSync(vault, { recursive: true, force: true });
}
});
});
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
*.log
*.err
*.tsbuildinfo
+12 -1
View File
@@ -1 +1,12 @@
<!doctype html><html><head><meta charset='utf-8'><title>Mind-o-Mat</title></head><body><div id='root'></div><script type='module' src='/src/main.tsx'></script></body></html>
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mind-o-Mat</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1810
View File
File diff suppressed because it is too large. Load diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "mindomat-webapp",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"cytoscape": "^3.30.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"
},
"devDependencies": {
"@types/cytoscape": "^3.21.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.6.0",
"vite": "^5.4.0"
}
}
+135
View File
@@ -0,0 +1,135 @@
import { useEffect, useRef, useState } from 'react';
import cytoscape, { Core, ElementDefinition } from 'cytoscape';
import { loadLandkarte, Landkarte, VaultNode } from './landkarte';
const TYPE_COLORS: Record<string, string> = {
inbox: '#5B8FF9',
daily: '#5AD8A6',
wiki: '#F6BD16',
project: '#E8684A',
unknown: '#999999',
};
export default function App() {
const containerRef = useRef<HTMLDivElement>(null);
const cyRef = useRef<Core | null>(null);
const [landkarte, setLandkarte] = useState<Landkarte | null>(null);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<VaultNode | null>(null);
useEffect(() => {
loadLandkarte()
.then(setLandkarte)
.catch((e) => setError(e.message));
}, []);
useEffect(() => {
if (!landkarte || !containerRef.current) return;
const elements: ElementDefinition[] = [
...landkarte.nodes.map((n) => ({
data: { id: n.id, label: n.title, type: n.type, node: n },
})),
...landkarte.edges.map((e, i) => ({
data: { id: `e${i}`, source: e.from, target: e.to },
})),
];
if (cyRef.current) {
cyRef.current.destroy();
}
cyRef.current = cytoscape({
container: containerRef.current,
elements,
style: [
{
selector: 'node',
style: {
'background-color': (ele: cytoscape.NodeSingular) => TYPE_COLORS[ele.data('type')] ?? '#999',
'label': 'data(label)',
'font-size': '10px',
'text-valign': 'bottom',
'text-halign': 'center',
'text-margin-y': 4,
'width': 20,
'height': 20,
'border-width': 1,
'border-color': '#333',
},
},
{
selector: 'edge',
style: {
'width': 1,
'line-color': '#ccc',
'curve-style': 'bezier',
'target-arrow-shape': 'none',
},
},
{
selector: 'node:selected',
style: {
'background-color': '#FFD700',
'border-width': 3,
'border-color': '#000',
},
},
],
layout: {
name: 'cose',
animate: false,
randomize: true,
},
});
cyRef.current.on('tap', 'node', (evt) => {
const node = evt.target.data('node') as VaultNode;
setSelected(node);
});
cyRef.current.on('tap', (evt) => {
if (evt.target === cyRef.current) {
setSelected(null);
}
});
return () => {
cyRef.current?.destroy();
cyRef.current = null;
};
}, [landkarte]);
return (
<div style={{ display: 'flex', height: '100vh', fontFamily: 'sans-serif' }}>
<div ref={containerRef} style={{ flex: 1, background: '#fafafa' }} />
<aside style={{ width: 320, padding: 16, borderLeft: '1px solid #ccc', overflowY: 'auto' }}>
<h2 style={{ marginTop: 0 }}>Mind-o-Mat</h2>
{error && <p style={{ color: 'red' }}>Fehler: {error}</p>}
{!landkarte && !error && <p>Lade landkarte.json…</p>}
{landkarte && (
<p style={{ color: '#666', fontSize: 13 }}>
{landkarte.nodes.length} Notizen, {landkarte.edges.length} Wikilinks
</p>
)}
{selected && (
<div style={{ marginTop: 16, padding: 12, background: '#f5f5f5', borderRadius: 4 }}>
<h3 style={{ margin: '0 0 8px 0' }}>{selected.title}</h3>
<dl style={{ fontSize: 13, margin: 0 }}>
<dt><b>Typ:</b></dt><dd>{selected.type}</dd>
{selected.cluster && (<><dt><b>Cluster:</b></dt><dd>{selected.cluster}</dd></>)}
{selected.status && (<><dt><b>Status:</b></dt><dd>{selected.status}</dd></>)}
{selected.created && (<><dt><b>Created:</b></dt><dd>{selected.created}</dd></>)}
{selected.tags.length > 0 && (
<><dt><b>Tags:</b></dt><dd>{selected.tags.join(', ')}</dd></>
)}
</dl>
<a href={`https://obsidian.md/`} style={{ fontSize: 12, color: '#666' }}>
{selected.id}
</a>
</div>
)}
</aside>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
// Typen fuer landkarte.json
export interface VaultNode {
id: string;
title: string;
type: 'inbox' | 'daily' | 'wiki' | 'project' | 'unknown';
cluster: string | null;
status: string | null;
created: string | null;
aktualisiert: string | null;
tags: string[];
size: number;
}
export interface VaultEdge {
from: string;
to: string;
type: 'wikilink';
}
export interface Landkarte {
nodes: VaultNode[];
edges: VaultEdge[];
generated: string;
vault: string;
}
export async function loadLandkarte(path: string = '/landkarte.json'): Promise<Landkarte> {
const res = await fetch(path);
if (!res.ok) {
throw new Error(`Konnte ${path} nicht laden: ${res.status} ${res.statusText}`);
}
return res.json();
}
+9
View File
@@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
open: true,
},
// landkarte.json aus dem Vault wird zur Build-Zeit kopiert
// (fuer Dev: manuell symlinken oder MINDOMAT_VAULT_PATH setzen)
});