Spaces:
Runtime error
Runtime error
| import { getDbInstance } from "@/lib/db/core"; | |
| const DEFAULT_OBSIDIAN_BASE_URL = "http://127.0.0.1:27123"; | |
| const MAX_RETRIES = 2; | |
| const TIMEOUT_MS = 30000; | |
| export class ObsidianAuthError extends Error { | |
| constructor(msg: string) { | |
| super(msg); | |
| this.name = "ObsidianAuthError"; | |
| } | |
| } | |
| export class ObsidianNotFoundError extends Error { | |
| constructor(msg: string) { | |
| super(msg); | |
| this.name = "ObsidianNotFoundError"; | |
| } | |
| } | |
| export class ObsidianServerError extends Error { | |
| constructor(msg: string) { | |
| super(msg); | |
| this.name = "ObsidianServerError"; | |
| } | |
| } | |
| export class ObsidianTimeoutError extends Error { | |
| constructor(msg: string) { | |
| super(msg); | |
| this.name = "ObsidianTimeoutError"; | |
| } | |
| } | |
| type ObsidianResult = { | |
| content: Array<{ type: "text"; text: string }>; | |
| isError?: boolean; | |
| }; | |
| function classifyObsidianError(status: number, message: string): Error { | |
| switch (status) { | |
| case 401: | |
| case 403: | |
| return new ObsidianAuthError(message); | |
| case 404: | |
| return new ObsidianNotFoundError(message); | |
| default: | |
| if (status >= 500) return new ObsidianServerError(message); | |
| return new Error(`Obsidian API error (${status}): ${message}`); | |
| } | |
| } | |
| function obsidianFetch( | |
| path: string, | |
| apiKey: string, | |
| baseUrl: string, | |
| options: RequestInit = {} | |
| ): Promise<unknown> { | |
| const url = `${baseUrl}${path}`; | |
| const controller = new AbortController(); | |
| const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); | |
| const mergedSignal = options.signal | |
| ? combineSignals(options.signal, controller.signal) | |
| : controller.signal; | |
| let lastError: Error | null = null; | |
| const attempt = async (retryCount: number): Promise<unknown> => { | |
| try { | |
| const response = await fetch(url, { | |
| ...options, | |
| headers: { | |
| Authorization: `Bearer ${apiKey}`, | |
| ...(options.headers as Record<string, string>), | |
| }, | |
| signal: mergedSignal, | |
| }); | |
| clearTimeout(timeout); | |
| if (!response.ok) { | |
| const body = await response.json().catch(() => ({})) as Record<string, unknown>; | |
| const msg = (body?.message as string) ?? `HTTP ${response.status}`; | |
| const error = classifyObsidianError(response.status, msg); | |
| if (error instanceof ObsidianServerError && retryCount < MAX_RETRIES - 1) { | |
| lastError = error; | |
| await sleep(Math.pow(2, retryCount) * 200); | |
| return attempt(retryCount + 1); | |
| } | |
| throw error; | |
| } | |
| const ct = response.headers.get("content-type") ?? ""; | |
| if (ct.includes("application/json")) { | |
| return response.json(); | |
| } | |
| return response.text(); | |
| } catch (err) { | |
| if (err instanceof Error && err.name === "AbortError") { | |
| clearTimeout(timeout); | |
| throw new ObsidianTimeoutError("Obsidian API request timed out after 30s"); | |
| } | |
| if (err instanceof ObsidianAuthError || err instanceof ObsidianNotFoundError) { | |
| clearTimeout(timeout); | |
| throw err; | |
| } | |
| if (err instanceof TypeError && err.message === "fetch failed") { | |
| clearTimeout(timeout); | |
| throw new ObsidianServerError( | |
| `Cannot reach Obsidian at ${baseUrl}. Ensure the Local REST API plugin is running ` + | |
| `and using the correct port. The REST API uses HTTP on port 27123 — do not use ` + | |
| `port 27124 (that is a separate MCP endpoint with HTTPS). If connecting via ` + | |
| `Tailscale, use http://<tailscale-ip>:27123.` | |
| ); | |
| } | |
| if (retryCount < MAX_RETRIES - 1) { | |
| lastError = err instanceof Error ? err : new ObsidianServerError(String(err)); | |
| await sleep(Math.pow(2, retryCount) * 200); | |
| return attempt(retryCount + 1); | |
| } | |
| clearTimeout(timeout); | |
| throw err; | |
| } | |
| }; | |
| return attempt(0); | |
| } | |
| function combineSignals(...signals: AbortSignal[]): AbortSignal { | |
| const controller = new AbortController(); | |
| for (const signal of signals) { | |
| if (signal.aborted) { | |
| controller.abort(signal.reason); | |
| return controller.signal; | |
| } | |
| signal.addEventListener("abort", () => controller.abort(signal.reason), { once: true }); | |
| } | |
| return controller.signal; | |
| } | |
| function sleep(ms: number): Promise<void> { | |
| return new Promise((resolve) => setTimeout(resolve, ms)); | |
| } | |
| function encodePath(segments: string): string { | |
| return segments.split("/").map(encodeURIComponent).join("/"); | |
| } | |
| export type PatchOperation = "append" | "prepend" | "replace"; | |
| export type TargetType = "heading" | "block" | "frontmatter"; | |
| export function createObsidianClient(apiKey: string, baseUrl?: string) { | |
| const resolvedBaseUrl = baseUrl ?? DEFAULT_OBSIDIAN_BASE_URL; | |
| const client = { | |
| async checkStatus(): Promise<unknown> { | |
| return obsidianFetch("/", apiKey, resolvedBaseUrl); | |
| }, | |
| async searchSimple(query: string, contextLength = 100): Promise<unknown> { | |
| const params = new URLSearchParams(); | |
| params.set("query", query); | |
| params.set("contextLength", String(contextLength)); | |
| return obsidianFetch(`/search/simple/?${params}`, apiKey, resolvedBaseUrl, { | |
| method: "POST", | |
| }); | |
| }, | |
| async searchStructured(jsonLogic: unknown): Promise<unknown> { | |
| return obsidianFetch("/search/", apiKey, resolvedBaseUrl, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/vnd.olrapi.jsonlogic+json" }, | |
| body: JSON.stringify(jsonLogic), | |
| }); | |
| }, | |
| async readNote( | |
| path: string, | |
| targetType?: TargetType, | |
| target?: string | |
| ): Promise<unknown> { | |
| const headers: Record<string, string> = {}; | |
| if (targetType) headers["Target-Type"] = targetType; | |
| if (target) headers["Target"] = encodeURIComponent(target); | |
| return obsidianFetch(`/vault/${encodePath(path)}`, apiKey, resolvedBaseUrl, { headers }); | |
| }, | |
| async listVault(path = ""): Promise<unknown> { | |
| const suffix = path ? `/${encodePath(path)}/` : "/"; | |
| return obsidianFetch(`/vault${suffix}`, apiKey, resolvedBaseUrl); | |
| }, | |
| async getDocumentMap(path: string): Promise<unknown> { | |
| return obsidianFetch(`/vault/${encodePath(path)}`, apiKey, resolvedBaseUrl, { | |
| headers: { Accept: "application/vnd.olrapi.document-map+json" }, | |
| }); | |
| }, | |
| async getNoteMetadata(path: string): Promise<unknown> { | |
| return obsidianFetch(`/vault/${encodePath(path)}`, apiKey, resolvedBaseUrl, { | |
| headers: { Accept: "application/vnd.olrapi.note+json" }, | |
| }); | |
| }, | |
| async getActiveFile(): Promise<unknown> { | |
| return obsidianFetch("/active/", apiKey, resolvedBaseUrl); | |
| }, | |
| async getPeriodicNote( | |
| period: string, | |
| year?: number, | |
| month?: number, | |
| day?: number | |
| ): Promise<unknown> { | |
| let url: string; | |
| if (year && month && day) { | |
| url = `/periodic/${period}/${year}/${month}/${day}/`; | |
| } else { | |
| url = `/periodic/${period}/`; | |
| } | |
| return obsidianFetch(url, apiKey, resolvedBaseUrl); | |
| }, | |
| async getTags(): Promise<unknown> { | |
| return obsidianFetch("/tags/", apiKey, resolvedBaseUrl); | |
| }, | |
| async commandList(): Promise<unknown> { | |
| return obsidianFetch("/commands/", apiKey, resolvedBaseUrl); | |
| }, | |
| async writeNote(path: string, content: string): Promise<void> { | |
| await obsidianFetch(`/vault/${encodePath(path)}`, apiKey, resolvedBaseUrl, { | |
| method: "PUT", | |
| headers: { "Content-Type": "text/markdown" }, | |
| body: content, | |
| }); | |
| }, | |
| async appendNote( | |
| path: string, | |
| content: string, | |
| targetType?: TargetType, | |
| target?: string | |
| ): Promise<void> { | |
| const headers: Record<string, string> = { "Content-Type": "text/markdown" }; | |
| if (targetType) headers["Target-Type"] = targetType; | |
| if (target) headers["Target"] = encodeURIComponent(target); | |
| await obsidianFetch(`/vault/${encodePath(path)}`, apiKey, resolvedBaseUrl, { | |
| method: "POST", | |
| headers, | |
| body: content, | |
| }); | |
| }, | |
| async patchNote( | |
| path: string, | |
| operation: PatchOperation, | |
| targetType: TargetType, | |
| target: string, | |
| content: string, | |
| createTargetIfMissing = false | |
| ): Promise<unknown> { | |
| const headers: Record<string, string> = { | |
| Operation: operation, | |
| "Target-Type": targetType, | |
| Target: encodeURIComponent(target), | |
| "Content-Type": "text/markdown", | |
| }; | |
| if (createTargetIfMissing) headers["Create-Target-If-Missing"] = "true"; | |
| return obsidianFetch(`/vault/${encodePath(path)}`, apiKey, resolvedBaseUrl, { | |
| method: "PATCH", | |
| headers, | |
| body: content, | |
| }); | |
| }, | |
| async deleteNote(path: string): Promise<void> { | |
| await obsidianFetch(`/vault/${encodePath(path)}`, apiKey, resolvedBaseUrl, { | |
| method: "DELETE", | |
| }); | |
| }, | |
| async moveNote(path: string, destination: string): Promise<void> { | |
| await obsidianFetch(`/vault/${encodePath(path)}`, apiKey, resolvedBaseUrl, { | |
| method: "MOVE", | |
| headers: { Destination: encodeURIComponent(destination) }, | |
| }); | |
| }, | |
| async executeCommand(commandId: string): Promise<void> { | |
| await obsidianFetch(`/commands/${encodeURIComponent(commandId)}/`, apiKey, resolvedBaseUrl, { | |
| method: "POST", | |
| }); | |
| }, | |
| async openFile(path: string): Promise<void> { | |
| await obsidianFetch(`/open/${encodePath(path)}`, apiKey, resolvedBaseUrl, { | |
| method: "POST", | |
| }); | |
| }, | |
| }; | |
| return client; | |
| } | |
| export type ObsidianClient = ReturnType<typeof createObsidianClient>; | |
| const DEFAULT_SYNC_SERVER_URL = "http://127.0.0.1:27781"; | |
| const SYNC_TOKEN_KEY = "omniroute_sync_token"; | |
| export function getSyncToken(): string | null { | |
| try { | |
| const db = getDbInstance(); | |
| const row = db.prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?").get("sync", SYNC_TOKEN_KEY) as { value?: string } | undefined; | |
| return typeof row?.value === "string" ? JSON.parse(row.value) : null; | |
| } catch { return null; } | |
| } | |
| export function setSyncToken(token: string | null): void { | |
| try { | |
| const db = getDbInstance(); | |
| if (token === null) { | |
| db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run("sync", SYNC_TOKEN_KEY); | |
| } else { | |
| const existing = db.prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?").get("sync", SYNC_TOKEN_KEY); | |
| if (existing) { | |
| db.prepare("UPDATE key_value SET value = ? WHERE namespace = ? AND key = ?").run(JSON.stringify(token), "sync", SYNC_TOKEN_KEY); | |
| } else { | |
| db.prepare("INSERT INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run("sync", SYNC_TOKEN_KEY, JSON.stringify(token)); | |
| } | |
| } | |
| } catch { /* ignore */ } | |
| } | |
| export interface SyncServerStatus { | |
| running: boolean; | |
| uptime: number; | |
| port: number; | |
| vaultName: string; | |
| lastSync: { ok: boolean; pulled: number; pushed: number; deleted: number; conflicts: number }; | |
| } | |
| export interface SyncConflict { | |
| path: string; | |
| conflictPath: string; | |
| detectedAt: number; | |
| } | |
| export function createSyncServerClient(syncToken: string, baseUrl?: string) { | |
| const resolvedBaseUrl = baseUrl ?? DEFAULT_SYNC_SERVER_URL; | |
| async function request<T>(path: string, init?: RequestInit): Promise<T> { | |
| const res = await fetch(`${resolvedBaseUrl}${path}`, { | |
| ...init, | |
| headers: { | |
| "Content-Type": "application/json", | |
| ...(syncToken ? { Authorization: `Bearer ${syncToken}` } : {}), | |
| ...init?.headers, | |
| }, | |
| }); | |
| if (!res.ok) { | |
| const body = await res.text(); | |
| throw new Error(`Sync server ${res.status}: ${body}`); | |
| } | |
| return res.json() as Promise<T>; | |
| } | |
| return { | |
| async getStatus(): Promise<SyncServerStatus> { | |
| return request<SyncServerStatus>("/vault/sync/status"); | |
| }, | |
| async triggerSync(): Promise<{ ok: boolean; pulled: number; pushed: number; deleted: number; conflicts: number }> { | |
| return request("/vault/sync/trigger", { method: "POST" }); | |
| }, | |
| async getConflicts(): Promise<{ conflicts: SyncConflict[] }> { | |
| return request("/vault/sync/conflicts"); | |
| }, | |
| async resolveConflict(path: string, resolution: "local" | "remote" | "keep-both"): Promise<unknown> { | |
| return request("/vault/sync/resolve", { | |
| method: "POST", | |
| body: JSON.stringify({ path, resolution }), | |
| }); | |
| }, | |
| }; | |
| } | |
| export type SyncServerClient = ReturnType<typeof createSyncServerClient>; | |