import { readFile, writeFile, readdir, stat, mkdir } from 'fs/promises'; import { join, dirname, basename, extname } from 'path'; const VAULT_PATH = process.env['OBSIDIAN_VAULT_PATH'] ?? '/vaults'; const SYNCTHING_URL = process.env['SYNCTHING_URL'] ?? 'http://host.docker.internal:8384'; const SYNCTHING_API_KEY = process.env['SYNCTHING_API_KEY'] ?? ''; const SYNCTHING_FOLDER_ID = process.env['SYNCTHING_FOLDER_ID'] ?? 'obsidian-vault'; function parseFrontmatter(content: string): { meta: Record; body: string } { if (!content.startsWith('---\n') && !content.startsWith('---\r\n')) { return { meta: {}, body: content }; } const end = content.indexOf('\n---', 4); if (end === -1) return { meta: {}, body: content }; const yaml = content.slice(4, end); const body = content.slice(end + 4).replace(/^\r?\n/, ''); const meta: Record = {}; for (const line of yaml.split('\n')) { const colon = line.indexOf(':'); if (colon === -1) continue; const key = line.slice(0, colon).trim(); const val = line.slice(colon + 1).trim(); if (!key) continue; if (key === 'tags') { // Support "tags: [a, b]" or "tags: a, b" meta['tags'] = val .replace(/[\[\]]/g, '') .split(',') .map((t) => t.trim()) .filter(Boolean); } else { meta[key] = val; } } return { meta, body }; } function extractTitle(filePath: string, content: string): string { const { meta, body } = parseFrontmatter(content); if (meta['title']) return String(meta['title']); const h1 = body.match(/^#\s+(.+)/m); if (h1) return h1[1].trim(); return basename(filePath, '.md'); } async function getAllNotes(dir: string = VAULT_PATH): Promise { let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch { return []; } const files: string[] = []; for (const entry of entries) { if (entry.name.startsWith('.')) continue; const full = join(dir, entry.name); if (entry.isDirectory()) { files.push(...(await getAllNotes(full))); } else if (entry.isFile() && extname(entry.name) === '.md') { files.push(full); } } return files; } function relPath(fullPath: string): string { return fullPath.startsWith(VAULT_PATH) ? fullPath.slice(VAULT_PATH.length).replace(/^\//, '') : fullPath; } export interface NoteResult { path: string; title: string; excerpt: string; tags: string[]; modified_date: string; } export interface FullNote { path: string; title: string; content: string; tags: string[]; links: string[]; modified_date: string; } export async function searchNotes( query: string, tags?: string[], limit = 10, pathFilter?: string ): Promise { const files = await getAllNotes(); const results: NoteResult[] = []; const q = query.toLowerCase(); for (const file of files) { if (results.length >= limit) break; const rel = relPath(file); if (pathFilter && !rel.toLowerCase().includes(pathFilter.toLowerCase())) continue; let content: string; try { content = await readFile(file, 'utf-8'); } catch { continue; } const { meta, body } = parseFrontmatter(content); const fileTags = (meta['tags'] as string[]) ?? []; const title = extractTitle(file, content); if (tags && tags.length > 0) { const hasAllTags = tags.every((t) => fileTags.includes(t)); if (!hasAllTags) continue; } const matchesQuery = !q || title.toLowerCase().includes(q) || body.toLowerCase().includes(q) || rel.toLowerCase().includes(q); if (!matchesQuery) continue; let excerpt = body.slice(0, 200).replace(/\n+/g, ' ').trim(); if (q) { const idx = body.toLowerCase().indexOf(q); if (idx > 0) { excerpt = body.slice(Math.max(0, idx - 50), idx + 150).replace(/\n+/g, ' ').trim(); } } if (excerpt.length >= 150) excerpt += '...'; const s = await stat(file); results.push({ path: rel, title, excerpt, tags: fileTags, modified_date: s.mtime.toISOString(), }); } return results; } export async function getNote(notePath: string): Promise { let fullPath = notePath.startsWith('/') ? notePath : join(VAULT_PATH, notePath); // If path doesn't end in .md, try adding it if (!fullPath.endsWith('.md')) fullPath += '.md'; try { await stat(fullPath); } catch { // Fall back to searching by title / filename match const files = await getAllNotes(); const needle = notePath.replace(/\.md$/i, '').toLowerCase(); const match = files.find( (f) => basename(f, '.md').toLowerCase() === needle || relPath(f).replace(/\.md$/i, '').toLowerCase() === needle ); if (!match) throw new Error(`Note not found: ${notePath}`); fullPath = match; } const content = await readFile(fullPath, 'utf-8'); const { meta, body } = parseFrontmatter(content); const title = extractTitle(fullPath, content); const s = await stat(fullPath); const links = [...body.matchAll(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g)].map((m) => m[1].trim()); return { path: relPath(fullPath), title, content, tags: (meta['tags'] as string[]) ?? [], links: [...new Set(links)], modified_date: s.mtime.toISOString(), }; } export async function appendToNote( notePath: string, content: string, createIfMissing = true, header?: string ): Promise<{ success: boolean; path: string; bytes_written: number }> { let fullPath = notePath.startsWith('/') ? notePath : join(VAULT_PATH, notePath); if (!fullPath.endsWith('.md')) fullPath += '.md'; let existing = ''; try { existing = await readFile(fullPath, 'utf-8'); } catch { if (!createIfMissing) throw new Error(`Note not found: ${notePath}`); await mkdir(dirname(fullPath), { recursive: true }); } const separator = existing && !existing.endsWith('\n') ? '\n' : ''; const toAppend = header ? `${separator}\n## ${header}\n${content}\n` : `${separator}${content}\n`; await writeFile(fullPath, existing + toAppend, 'utf-8'); return { success: true, path: relPath(fullPath), bytes_written: Buffer.byteLength(toAppend, 'utf-8'), }; } export async function updateNote( notePath: string, content: string, ): Promise<{ success: boolean; path: string; bytes_written: number }> { let fullPath = notePath.startsWith('/') ? notePath : join(VAULT_PATH, notePath); if (!fullPath.endsWith('.md')) fullPath += '.md'; await mkdir(dirname(fullPath), { recursive: true }); await writeFile(fullPath, content, 'utf-8'); return { success: true, path: relPath(fullPath), bytes_written: Buffer.byteLength(content, 'utf-8') }; } export async function getSyncStatus(): Promise<{ status: string; last_sync: string | null; vault_size: number; pending_changes: number; }> { if (!SYNCTHING_API_KEY) { return { status: 'unconfigured – set SYNCTHING_API_KEY', last_sync: null, vault_size: 0, pending_changes: 0 }; } try { const url = `${SYNCTHING_URL}/rest/db/status?folder=${SYNCTHING_FOLDER_ID}`; const res = await fetch(url, { headers: { 'X-API-Key': SYNCTHING_API_KEY }, signal: AbortSignal.timeout(5000), }); if (!res.ok) throw new Error(`Syncthing API returned ${res.status}`); const data = (await res.json()) as { state: string; stateChanged: string; localBytes: number; needFiles: number; }; return { status: data.state, last_sync: data.stateChanged ?? null, vault_size: data.localBytes ?? 0, pending_changes: data.needFiles ?? 0, }; } catch (err) { return { status: `error: ${(err as Error).message}`, last_sync: null, vault_size: 0, pending_changes: 0, }; } }