273 lines
7.7 KiB
TypeScript
273 lines
7.7 KiB
TypeScript
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<string, unknown>; 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<string, unknown> = {};
|
||
|
||
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<string[]> {
|
||
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<NoteResult[]> {
|
||
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<FullNote> {
|
||
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,
|
||
};
|
||
}
|
||
}
|