Add multi-account OAuth, Obsidian integration, product assets, and test tooling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
272
src/clients/obsidian.ts
Normal file
272
src/clients/obsidian.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user