mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-01 20:03:30 +02:00
286 lines
8.1 KiB
TypeScript
286 lines
8.1 KiB
TypeScript
import { Notice, requestUrl, type RequestUrlParam, type RequestUrlResponse } from "obsidian";
|
|
import type {
|
|
ConnectResponse,
|
|
DeleteAck,
|
|
HealthResponse,
|
|
ManifestResponse,
|
|
NotePayload,
|
|
RenameAck,
|
|
RenameItem,
|
|
SearchSpace,
|
|
SyncAck,
|
|
} from "./types";
|
|
|
|
/**
|
|
* SurfSense backend client used by the Obsidian plugin.
|
|
*
|
|
* Mobile-safety contract (must hold for every transitive import):
|
|
* - Use Obsidian `requestUrl` only — no `fetch`, no `axios`, no
|
|
* `node:http`, no `node:https`. CORS is bypassed and mobile works.
|
|
* - No top-level `node:*` imports anywhere reachable from this file.
|
|
* - Hashing happens elsewhere via Web Crypto, not `node:crypto`.
|
|
*
|
|
* Auth + wire contract:
|
|
* - Every request carries `Authorization: Bearer <token>` only. No
|
|
* custom headers — the backend identifies the caller from the JWT
|
|
* and feature-detects the API via the `capabilities` array on
|
|
* `/health` and `/connect`.
|
|
* - 401 surfaces as `AuthError` so the orchestrator can show the
|
|
* "token expired, paste a fresh one" UX.
|
|
* - HealthResponse / ConnectResponse use index signatures so any
|
|
* additive backend field (e.g. new capabilities) parses without
|
|
* breaking the decoder. This mirrors `ConfigDict(extra='ignore')`
|
|
* on the server side.
|
|
*/
|
|
|
|
export class AuthError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "AuthError";
|
|
}
|
|
}
|
|
|
|
export class TransientError extends Error {
|
|
readonly status: number;
|
|
constructor(status: number, message: string) {
|
|
super(message);
|
|
this.name = "TransientError";
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
export class PermanentError extends Error {
|
|
readonly status: number;
|
|
constructor(status: number, message: string) {
|
|
super(message);
|
|
this.name = "PermanentError";
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
/** 404 `VAULT_NOT_REGISTERED` — `/connect` hasn't committed yet; retry after reconnect. */
|
|
export class VaultNotRegisteredError extends TransientError {
|
|
constructor(message: string) {
|
|
super(404, message);
|
|
this.name = "VaultNotRegisteredError";
|
|
}
|
|
}
|
|
|
|
export interface ApiClientOptions {
|
|
getServerUrl: () => string;
|
|
getToken: () => string;
|
|
onAuthError?: () => void;
|
|
}
|
|
|
|
export class SurfSenseApiClient {
|
|
private readonly opts: ApiClientOptions;
|
|
|
|
constructor(opts: ApiClientOptions) {
|
|
this.opts = opts;
|
|
}
|
|
|
|
updateOptions(partial: Partial<ApiClientOptions>): void {
|
|
Object.assign(this.opts, partial);
|
|
}
|
|
|
|
async health(): Promise<HealthResponse> {
|
|
return await this.request<HealthResponse>("GET", "/api/v1/obsidian/health");
|
|
}
|
|
|
|
async listSearchSpaces(): Promise<SearchSpace[]> {
|
|
const resp = await this.request<SearchSpace[] | { items: SearchSpace[] }>(
|
|
"GET",
|
|
"/api/v1/searchspaces/"
|
|
);
|
|
if (Array.isArray(resp)) return resp;
|
|
if (resp && Array.isArray((resp as { items?: SearchSpace[] }).items)) {
|
|
return (resp as { items: SearchSpace[] }).items;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
async verifyToken(): Promise<{ ok: true; health: HealthResponse }> {
|
|
// /health is gated by current_active_user, so a successful response
|
|
// transitively proves the token works. Cheaper than fetching a list.
|
|
const health = await this.health();
|
|
return { ok: true, health };
|
|
}
|
|
|
|
async connect(input: {
|
|
searchSpaceId: number;
|
|
vaultId: string;
|
|
vaultName: string;
|
|
vaultFingerprint: string;
|
|
}): Promise<ConnectResponse> {
|
|
return await this.request<ConnectResponse>(
|
|
"POST",
|
|
"/api/v1/obsidian/connect",
|
|
{
|
|
vault_id: input.vaultId,
|
|
vault_name: input.vaultName,
|
|
search_space_id: input.searchSpaceId,
|
|
vault_fingerprint: input.vaultFingerprint,
|
|
}
|
|
);
|
|
}
|
|
|
|
/** POST /sync — `failed[]` are paths whose `status === "error"` for retry. */
|
|
async syncBatch(input: {
|
|
vaultId: string;
|
|
notes: NotePayload[];
|
|
}): Promise<{ indexed: number; failed: string[] }> {
|
|
const resp = await this.request<SyncAck>(
|
|
"POST",
|
|
"/api/v1/obsidian/sync",
|
|
{ vault_id: input.vaultId, notes: input.notes }
|
|
);
|
|
const failed = resp.items
|
|
.filter((it) => it.status === "error")
|
|
.map((it) => it.path);
|
|
return { indexed: resp.indexed, failed };
|
|
}
|
|
|
|
/** POST /rename — `"missing"` counts as success; only `"error"` is retried. */
|
|
async renameBatch(input: {
|
|
vaultId: string;
|
|
renames: Pick<RenameItem, "oldPath" | "newPath">[];
|
|
}): Promise<{
|
|
renamed: number;
|
|
failed: Array<{ oldPath: string; newPath: string }>;
|
|
}> {
|
|
const resp = await this.request<RenameAck>(
|
|
"POST",
|
|
"/api/v1/obsidian/rename",
|
|
{
|
|
vault_id: input.vaultId,
|
|
renames: input.renames.map((r) => ({
|
|
old_path: r.oldPath,
|
|
new_path: r.newPath,
|
|
})),
|
|
}
|
|
);
|
|
const failed = resp.items
|
|
.filter((it) => it.status === "error")
|
|
.map((it) => ({ oldPath: it.old_path, newPath: it.new_path }));
|
|
return { renamed: resp.renamed, failed };
|
|
}
|
|
|
|
/** DELETE /notes — `"missing"` counts as success; only `"error"` is retried. */
|
|
async deleteBatch(input: {
|
|
vaultId: string;
|
|
paths: string[];
|
|
}): Promise<{ deleted: number; failed: string[] }> {
|
|
const resp = await this.request<DeleteAck>(
|
|
"DELETE",
|
|
"/api/v1/obsidian/notes",
|
|
{ vault_id: input.vaultId, paths: input.paths }
|
|
);
|
|
const failed = resp.items
|
|
.filter((it) => it.status === "error")
|
|
.map((it) => it.path);
|
|
return { deleted: resp.deleted, failed };
|
|
}
|
|
|
|
async getManifest(vaultId: string): Promise<ManifestResponse> {
|
|
return await this.request<ManifestResponse>(
|
|
"GET",
|
|
`/api/v1/obsidian/manifest?vault_id=${encodeURIComponent(vaultId)}`
|
|
);
|
|
}
|
|
|
|
private async request<T>(
|
|
method: RequestUrlParam["method"],
|
|
path: string,
|
|
body?: unknown
|
|
): Promise<T> {
|
|
const baseUrl = this.opts.getServerUrl().replace(/\/+$/, "");
|
|
const token = this.opts.getToken();
|
|
if (!token) {
|
|
throw new AuthError("Missing API token. Open SurfSense settings to paste one.");
|
|
}
|
|
const headers: Record<string, string> = {
|
|
Authorization: `Bearer ${token}`,
|
|
Accept: "application/json",
|
|
};
|
|
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
|
|
let resp: RequestUrlResponse;
|
|
try {
|
|
resp = await requestUrl({
|
|
url: `${baseUrl}${path}`,
|
|
method,
|
|
headers,
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
throw: false,
|
|
});
|
|
} catch (err) {
|
|
throw new TransientError(0, `Network error: ${(err as Error).message}`);
|
|
}
|
|
|
|
if (resp.status >= 200 && resp.status < 300) {
|
|
return parseJson<T>(resp);
|
|
}
|
|
|
|
const detail = extractDetail(resp);
|
|
|
|
if (resp.status === 401) {
|
|
this.opts.onAuthError?.();
|
|
new Notice("Surfsense: token expired or invalid. Paste a fresh token in settings.");
|
|
throw new AuthError(detail || "Unauthorized");
|
|
}
|
|
|
|
if (resp.status >= 500 || resp.status === 429) {
|
|
throw new TransientError(resp.status, detail || `HTTP ${resp.status}`);
|
|
}
|
|
|
|
if (resp.status === 404 && extractCode(resp) === "VAULT_NOT_REGISTERED") {
|
|
throw new VaultNotRegisteredError(detail || "Vault not registered yet");
|
|
}
|
|
|
|
throw new PermanentError(resp.status, detail || `HTTP ${resp.status}`);
|
|
}
|
|
}
|
|
|
|
function parseJson<T>(resp: RequestUrlResponse): T {
|
|
// Plugin endpoints always return JSON; non-JSON 2xx is usually a
|
|
// captive portal or CDN page — surface as transient so we back off.
|
|
const text = resp.text ?? "";
|
|
try {
|
|
return JSON.parse(text) as T;
|
|
} catch {
|
|
throw new TransientError(
|
|
resp.status,
|
|
`Invalid JSON from server (got: ${text.slice(0, 80)})`
|
|
);
|
|
}
|
|
}
|
|
|
|
function safeJson(resp: RequestUrlResponse): Record<string, unknown> {
|
|
try {
|
|
return resp.text ? (JSON.parse(resp.text) as Record<string, unknown>) : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function extractDetail(resp: RequestUrlResponse): string {
|
|
const json = safeJson(resp);
|
|
if (typeof json.detail === "string") return json.detail;
|
|
if (typeof json.message === "string") return json.message;
|
|
const detailObj = json.detail;
|
|
if (detailObj && typeof detailObj === "object") {
|
|
const obj = detailObj as Record<string, unknown>;
|
|
if (typeof obj.message === "string") return obj.message;
|
|
}
|
|
return resp.text?.slice(0, 200) ?? "";
|
|
}
|
|
|
|
function extractCode(resp: RequestUrlResponse): string | undefined {
|
|
const json = safeJson(resp);
|
|
const detailObj = json.detail;
|
|
if (detailObj && typeof detailObj === "object") {
|
|
const code = (detailObj as Record<string, unknown>).code;
|
|
if (typeof code === "string") return code;
|
|
}
|
|
return undefined;
|
|
}
|