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:
Garfield
2026-04-29 09:52:53 -04:00
parent 166f5d55a6
commit e3a272c332
67 changed files with 6204 additions and 94 deletions

272
src/clients/obsidian.ts Normal file
View 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,
};
}
}