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:
1 parent
f86c3bf8ce
commit
4f174e748e
13 files changed
+2693
-11
No files matched your search
@@ -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
@@ -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
@@ -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');
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
*.err
|
||||
*.tsbuildinfo
|
||||
+12
-1
@@ -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>
|
||||
Generated
+1810
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
Reference in new issue
Block a user