| 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 { }
|
| }
|
|
|
| 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>;
|
|
|