feat: refine Obsidian plugin routes and schemas for improved device management and API stability

This commit is contained in:
Anish Sarkar 2026-04-20 18:19:30 +05:30
parent 60d9e7ed8c
commit b5c9388c8a
9 changed files with 182 additions and 385 deletions

View file

@ -1,31 +1,8 @@
"""
Obsidian plugin ingestion routes.
"""Obsidian plugin ingestion routes (``/api/v1/obsidian/*``).
This is the public surface that the SurfSense Obsidian plugin
(``surfsense_obsidian/``) speaks to. It is a separate router from the
legacy server-path Obsidian connector the legacy code stays in place
until the ``obsidian-legacy-cleanup`` plan ships.
Endpoints
---------
- ``GET /api/v1/obsidian/health`` version handshake
- ``POST /api/v1/obsidian/connect`` register or get a vault row
- ``POST /api/v1/obsidian/sync`` batch upsert
- ``POST /api/v1/obsidian/rename`` batch rename
- ``DELETE /api/v1/obsidian/notes`` batch soft-delete
- ``GET /api/v1/obsidian/manifest`` reconcile manifest
Auth contract
-------------
Every endpoint requires ``Depends(current_active_user)`` the same JWT
bearer the rest of the API uses; future PAT migration is transparent.
API stability is provided by the ``/api/v1/...`` URL prefix and the
``capabilities`` array advertised on ``/health`` (additive only). There
is no plugin-version gate; "your plugin is out of date" notices are
delegated to Obsidian's built-in community-store updater.
Wire surface for the ``surfsense_obsidian/`` plugin. API stability is the
``/api/v1/`` prefix plus the additive ``capabilities`` array on /health;
no plugin-version gate.
"""
from __future__ import annotations
@ -67,14 +44,10 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/obsidian", tags=["obsidian-plugin"])
# Bumped manually whenever the wire contract gains a non-additive change.
# Additive (extra='ignore'-safe) changes do NOT bump this.
# Bumped only on non-additive wire changes; additive ones ride extra='ignore'.
OBSIDIAN_API_VERSION = "1"
# Capabilities advertised on /health and /connect. Plugins use this list
# for feature gating ("does this server understand attachments_v2?"). Add
# new strings, never rename/remove existing ones — older plugins ignore
# unknown entries safely.
# Plugins feature-gate on these. Add entries, never rename or remove.
OBSIDIAN_CAPABILITIES: list[str] = ["sync", "rename", "delete", "manifest"]
@ -90,18 +63,41 @@ def _build_handshake() -> dict[str, object]:
}
def _upsert_device(
existing_devices: object,
device_id: str,
now_iso: str,
) -> dict[str, dict[str, str]]:
"""Upsert ``device_id`` into ``{device_id: {first_seen_at, last_seen_at}}``.
Keyed by device_id for O(1) dedup; ``len(devices)`` is the count.
Timestamps are kept for a future stale-device pruner.
"""
devices: dict[str, dict[str, str]] = {}
if isinstance(existing_devices, dict):
for key, val in existing_devices.items():
if not isinstance(key, str) or not key or not isinstance(val, dict):
continue
devices[key] = {
"first_seen_at": str(val.get("first_seen_at") or now_iso),
"last_seen_at": str(val.get("last_seen_at") or now_iso),
}
prev = devices.get(device_id)
devices[device_id] = {
"first_seen_at": prev["first_seen_at"] if prev else now_iso,
"last_seen_at": now_iso,
}
return devices
async def _resolve_vault_connector(
session: AsyncSession,
*,
user: User,
vault_id: str,
) -> SearchSourceConnector:
"""Find the OBSIDIAN_CONNECTOR row that owns ``vault_id`` for this user.
Looked up by the (user_id, connector_type, config['vault_id']) tuple
so users can have multiple vaults each backed by its own connector
row (one per search space).
"""
"""Find the OBSIDIAN_CONNECTOR row that owns ``vault_id`` for this user."""
result = await session.execute(
select(SearchSourceConnector).where(
and_(
@ -136,12 +132,7 @@ async def _ensure_search_space_access(
user: User,
search_space_id: int,
) -> SearchSpace:
"""Confirm the user owns the requested search space.
Plugin currently does not support shared search spaces (RBAC roles)
that's a follow-up. Restricting to owner-only here keeps the
surface narrow and avoids leaking other members' connectors.
"""
"""Owner-only access to the search space (shared spaces are a follow-up)."""
result = await session.execute(
select(SearchSpace).where(
and_(SearchSpace.id == search_space_id, SearchSpace.user_id == user.id)
@ -168,11 +159,7 @@ async def _ensure_search_space_access(
async def obsidian_health(
user: User = Depends(current_active_user),
) -> HealthResponse:
"""Return the API contract handshake.
The plugin calls this once per ``onload`` and caches the result for
capability-gating decisions.
"""
"""Return the API contract handshake; plugin caches it per onload."""
return HealthResponse(
**_build_handshake(),
server_time_utc=datetime.now(UTC),
@ -187,9 +174,9 @@ async def obsidian_connect(
) -> ConnectResponse:
"""Register a vault, or return the existing connector row.
Idempotent on the (user_id, OBSIDIAN_CONNECTOR, vault_id) tuple so
re-installing the plugin or reconnecting from a new device picks up
the same connector and therefore the same documents.
Idempotent on (user_id, OBSIDIAN_CONNECTOR, vault_id). Called on every
plugin onload as a heartbeat upserts ``device_id`` into
``config['devices']`` so the web UI can show a "Devices: N" tile.
"""
await _ensure_search_space_access(
session, user=user, search_space_id=payload.search_space_id
@ -215,27 +202,31 @@ async def obsidian_connect(
if existing is not None:
cfg = dict(existing.config or {})
devices = _upsert_device(cfg.get("devices"), payload.device_id, now_iso)
cfg.update(
{
"vault_id": payload.vault_id,
"vault_name": payload.vault_name,
"source": "plugin",
"plugin_version": payload.plugin_version,
"device_id": payload.device_id,
"devices": devices,
"device_count": len(devices),
"last_connect_at": now_iso,
}
)
if payload.device_label:
cfg["device_label"] = payload.device_label
cfg.pop("legacy", None)
cfg.pop("vault_path", None)
existing.config = cfg
# Re-stamp on every connect so vault renames in Obsidian propagate;
# the web UI hides the Name input for Obsidian connectors.
existing.name = f"Obsidian — {payload.vault_name}"
existing.is_indexable = False
existing.search_space_id = payload.search_space_id
await session.commit()
await session.refresh(existing)
connector = existing
else:
devices = _upsert_device(None, payload.device_id, now_iso)
connector = SearchSourceConnector(
name=f"Obsidian — {payload.vault_name}",
connector_type=SearchSourceConnectorType.OBSIDIAN_CONNECTOR,
@ -245,8 +236,8 @@ async def obsidian_connect(
"vault_name": payload.vault_name,
"source": "plugin",
"plugin_version": payload.plugin_version,
"device_id": payload.device_id,
"device_label": payload.device_label,
"devices": devices,
"device_count": len(devices),
"files_synced": 0,
"last_connect_at": now_iso,
},
@ -271,11 +262,7 @@ async def obsidian_sync(
user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
) -> dict[str, object]:
"""Batch-upsert notes pushed by the plugin.
Returns per-note ack so the plugin can dequeue successes and retry
failures.
"""
"""Batch-upsert notes; returns per-note ack so the plugin can dequeue/retry."""
connector = await _resolve_vault_connector(
session, user=user, vault_id=payload.vault_id
)
@ -439,11 +426,7 @@ async def obsidian_manifest(
user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
) -> ManifestResponse:
"""Return the server-side ``{path: {hash, mtime}}`` manifest.
Used by the plugin's ``onload`` reconcile to find files that were
edited or deleted while the plugin was offline.
"""
"""Return ``{path: {hash, mtime}}`` for the plugin's onload reconcile diff."""
connector = await _resolve_vault_connector(
session, user=user, vault_id=vault_id
)

View file

@ -1,23 +1,8 @@
"""
Obsidian Plugin connector schemas.
"""Wire schemas spoken between the SurfSense Obsidian plugin and the backend.
Wire format spoken between the SurfSense Obsidian plugin
(``surfsense_obsidian/``) and the FastAPI backend.
Stability contract
------------------
Every request and response schema sets ``model_config = ConfigDict(extra='ignore')``.
This is the API stability contract not just hygiene:
- Old plugins talking to a newer backend silently drop any new response fields
they don't understand instead of failing validation.
- New plugins talking to an older backend can include forward-looking request
fields (e.g. attachments metadata) without the older backend rejecting them.
Hard breaking changes are reserved for the URL prefix (``/api/v2/...``).
Additive evolution is signaled via the ``capabilities`` array on
``HealthResponse`` / ``ConnectResponse`` older plugins ignore unknown
capability strings safely.
All schemas inherit ``extra='ignore'`` from :class:`_PluginBase` so additive
field changes never break either side; hard breaks live behind a new URL
prefix (``/api/v2/...``).
"""
from __future__ import annotations
@ -31,22 +16,13 @@ _PLUGIN_MODEL_CONFIG = ConfigDict(extra="ignore")
class _PluginBase(BaseModel):
"""Base class for all plugin payload schemas.
Carries the forward-compatibility config so subclasses don't have to
repeat it.
"""
"""Base schema carrying the shared forward-compatibility config."""
model_config = _PLUGIN_MODEL_CONFIG
class NotePayload(_PluginBase):
"""One Obsidian note as pushed by the plugin.
The plugin is the source of truth: ``content`` is the post-frontmatter
body, ``frontmatter``/``tags``/``headings``/etc. are precomputed by the
plugin via ``app.metadataCache`` so the backend doesn't have to re-parse.
"""
"""One Obsidian note as pushed by the plugin (the source of truth)."""
vault_id: str = Field(..., description="Stable plugin-generated UUID for this vault")
path: str = Field(..., description="Vault-relative path, e.g. 'notes/foo.md'")
@ -68,7 +44,7 @@ class NotePayload(_PluginBase):
class SyncBatchRequest(_PluginBase):
"""Batch upsert. Plugin sends 10-20 notes per request to amortize HTTP overhead."""
"""Batch upsert; plugin sends 10-20 notes per request."""
vault_id: str
notes: list[NotePayload] = Field(default_factory=list, max_length=100)
@ -90,8 +66,6 @@ class DeleteBatchRequest(_PluginBase):
class ManifestEntry(_PluginBase):
"""One row of the server-side manifest used by the plugin to reconcile."""
hash: str
mtime: datetime
@ -104,26 +78,18 @@ class ManifestResponse(_PluginBase):
class ConnectRequest(_PluginBase):
"""First-call handshake to register or look up a vault connector row."""
"""Vault registration / heartbeat. Replayed on every plugin onload."""
vault_id: str
vault_name: str
search_space_id: int
plugin_version: str
device_id: str
device_label: str | None = Field(
default=None,
description="User-friendly device name shown in the web UI (e.g. 'iPad Pro').",
)
class ConnectResponse(_PluginBase):
"""Returned from POST /connect.
Carries the same handshake fields as ``HealthResponse`` so the plugin
learns the contract on its very first call without an extra round-trip
to ``GET /health``.
"""
"""Carries the same handshake fields as ``HealthResponse`` so the plugin
learns the contract without a separate ``GET /health`` round-trip."""
connector_id: int
vault_id: str
@ -133,14 +99,7 @@ class ConnectResponse(_PluginBase):
class HealthResponse(_PluginBase):
"""API contract handshake.
The plugin calls ``GET /health`` once per ``onload`` and caches the
result. ``capabilities`` is a forward-extensible string list: future
additions (``'pat_auth'``, ``'scoped_pat'``, ``'attachments_v2'``,
``'shared_search_spaces'``...) ship without breaking older plugins
because they only enable extra behavior, never gate existing endpoints.
"""
"""API contract handshake. ``capabilities`` is additive-only string list."""
api_version: str
capabilities: list[str]

View file

@ -105,7 +105,6 @@ export class SurfSenseApiClient {
vaultId: string;
vaultName: string;
deviceId: string;
deviceLabel: string;
}): Promise<ConnectResponse> {
return await this.request<ConnectResponse>(
"POST",
@ -117,7 +116,6 @@ export class SurfSenseApiClient {
vault_name: input.vaultName,
plugin_version: this.opts.pluginVersion,
device_id: input.deviceId,
device_label: input.deviceLabel,
}
);
}

View file

@ -11,28 +11,18 @@ import {
type SurfsensePluginSettings,
} from "./types";
/**
* SurfSense plugin entry point.
*
* Replaces the obsidian-sample-plugin SampleModal/ribbon stub. Lifecycle:
*
* onload():
* load settings seed identity (vault_id, device_id)
* wire api client + queue + sync engine + status bar
* register settings tab register vault + metadataCache events
* register commands (resync, sync current note, open settings)
* register status bar item
* kick off engine.start() (health drain reconcile).
*
* onunload():
* stop the queue's debounce timer; unregistered events and DOM
* handles auto-clean via the Plugin base class.
*/
/** SurfSense plugin entry point. */
export default class SurfSensePlugin extends Plugin {
settings!: SurfsensePluginSettings;
api!: SurfSenseApiClient;
queue!: PersistentQueue;
engine!: SyncEngine;
/**
* Per-install identifier kept in `app.saveLocalStorage` rather than
* `data.json`, so it does NOT travel through Obsidian Sync each
* machine on a synced vault stays distinguishable.
*/
deviceId = "";
private statusBar: StatusBar | null = null;
lastStatus: StatusState = { kind: "idle", queueDepth: 0 };
serverCapabilities: string[] = [];
@ -69,6 +59,7 @@ export default class SurfSensePlugin extends Plugin {
await this.saveSettings();
this.settingTab?.renderStatus();
},
getDeviceId: () => this.deviceId,
setStatus: (s) => {
this.lastStatus = s;
this.statusBar?.update(s);
@ -143,8 +134,7 @@ export default class SurfSensePlugin extends Plugin {
id: "open-settings",
name: "Open settings",
callback: () => {
// Obsidian exposes this through the Setting host on the workspace;
// fall back silently if the API moves so we never throw.
// `app.setting` isn't in the d.ts; fall back silently if it moves.
type SettingHost = {
open?: () => void;
openTabById?: (id: string) => void;
@ -155,8 +145,7 @@ export default class SurfSensePlugin extends Plugin {
},
});
// Kick off the start sequence after Obsidian finishes its own
// startup work, so the metadataCache is warm before reconcile.
// Wait for layout so the metadataCache is warm before reconcile.
this.app.workspace.onLayoutReady(() => {
void this.engine.start();
});
@ -188,13 +177,28 @@ export default class SurfSensePlugin extends Plugin {
await this.saveData(this.settings);
}
/**
* Mint vault_id (in data.json, travels with the vault) and device_id
* (in `app.saveLocalStorage`, stays per-install) on first run.
*/
private seedIdentity(): void {
if (!this.settings.vaultId) {
this.settings.vaultId = generateUuid();
}
if (!this.settings.deviceId) {
this.settings.deviceId = generateUuid();
// loadLocalStorage / saveLocalStorage aren't in the d.ts; cast at the boundary.
const localStore = this.app as unknown as {
loadLocalStorage: (key: string) => string | null;
saveLocalStorage: (key: string, value: string | null) => void;
};
const storageKey = "surfsense:deviceId";
let deviceId = localStore.loadLocalStorage(storageKey);
if (!deviceId) {
deviceId = generateUuid();
localStore.saveLocalStorage(storageKey, deviceId);
}
this.deviceId = deviceId;
if (!this.settings.vaultName) {
this.settings.vaultName = this.app.vault.getName();
}

View file

@ -9,22 +9,7 @@ import { parseExcludePatterns } from "./excludes";
import type SurfSensePlugin from "./main";
import type { SearchSpace } from "./types";
/**
* Plugin settings tab.
*
* Replaces the obsidian-sample-plugin SampleSettingTab stub. Same module
* path so existing imports from main.ts keep resolving.
*
* Surface mirrors the per-plan list:
* server URL · api token · search space · vault name · sync mode ·
* exclude patterns · include attachments · status panel.
*
* Vault id, device id, and device label are auto-generated UUIDs the
* first time settings load they're displayed (read-only) so users can
* audit them, but never editable. Vault id is decoupled from the OS
* folder name so renaming the vault doesn't invalidate the connector
* (edge case #5 from the plan).
*/
/** Plugin settings tab. */
export class SurfSenseSettingTab extends PluginSettingTab {
private readonly plugin: SurfSensePlugin;
@ -151,21 +136,6 @@ export class SurfSenseSettingTab extends PluginSettingTab {
}),
);
new Setting(containerEl)
.setName("Device label")
.setDesc(
"Optional human-readable label shown next to the device ID in the Surfsense web app.",
)
.addText((text) =>
text
.setPlaceholder("My laptop")
.setValue(settings.deviceLabel)
.onChange(async (value) => {
this.plugin.settings.deviceLabel = value.trim();
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName("Sync mode")
.setDesc("Auto syncs on every edit. Manual only syncs when you trigger it via the command palette.")
@ -214,19 +184,16 @@ export class SurfSenseSettingTab extends PluginSettingTab {
new Setting(containerEl)
.setName("Vault ID")
.setDesc("Stable identifier for this vault. Used by the backend to keep separate vaults distinct even if their folder names change.")
.setDesc(
"Stable identifier for this vault. Used by the backend to keep separate vaults distinct even if their folder names change.",
)
.addText((text) => {
text.inputEl.disabled = true;
text.setValue(settings.vaultId);
});
new Setting(containerEl)
.setName("Device ID")
.setDesc("Stable identifier for this install. Used by the backend so you can revoke a single device without disconnecting the others.")
.addText((text) => {
text.inputEl.disabled = true;
text.setValue(settings.deviceId);
});
// Device ID is deliberately not exposed: it's an opaque per-install UUID
// (see seedIdentity in main.ts) and the web UI only shows a device count.
new Setting(containerEl).setName("Status").setHeading();
this.statusEl = containerEl.createDiv({ cls: "surfsense-settings__status" });

View file

@ -19,20 +19,8 @@ import type {
/**
* Owner of "what does the vault look like vs the server" reasoning.
*
* Onload sequence (per plan §p4_plugin_sync_engine, in this exact order):
* 1. apiClient.health() proves connectivity and pulls the capabilities
* handshake before we issue any sync traffic.
* 2. Cache health.capabilities + api_version on the plugin instance
* so feature gating (e.g. "attachments_v2" before syncing binaries)
* reads from local state instead of round-tripping.
* 3. Drain queue items persisted from the previous session land first.
* 4. Reconcile GET /manifest, diff against vault, queue uploads/deletes.
* 5. Subscribe events only after the above so the user's first edit
* after launching Obsidian doesn't race with the manifest diff.
*
* Reconcile skips itself if last successful reconcile is < RECONCILE_MIN_INTERVAL_MS
* ago. ConnectResponse already carries handshake fields so first connect
* does not need a separate /health round-trip.
* Start order: connect (or fall back to /health) drain queue reconcile
* subscribe events. Reconcile no-ops if last run was < RECONCILE_MIN_INTERVAL_MS ago.
*/
export interface SyncEngineDeps {
@ -41,6 +29,8 @@ export interface SyncEngineDeps {
queue: PersistentQueue;
getSettings: () => SyncEngineSettings;
saveSettings: (mut: (s: SyncEngineSettings) => void) => Promise<void>;
/** Per-install id sourced from app.saveLocalStorage (not synced data.json). */
getDeviceId: () => string;
setStatus: (s: StatusState) => void;
onCapabilities: (caps: string[], apiVersion: string) => void;
}
@ -50,8 +40,6 @@ export interface SyncEngineSettings {
vaultName: string;
connectorId: number | null;
searchSpaceId: number | null;
deviceId: string;
deviceLabel: string;
excludePatterns: string[];
includeAttachments: boolean;
syncMode: "auto" | "manual";
@ -86,22 +74,27 @@ export class SyncEngine {
/** Run the onload sequence described in this file's docstring. */
async start(): Promise<void> {
this.setStatus("syncing", "Connecting to SurfSense…");
try {
const health = await this.deps.apiClient.health();
this.applyHealth(health);
} catch (err) {
this.handleStartupError(err);
return;
}
const settings = this.deps.getSettings();
if (!settings.connectorId || !settings.searchSpaceId) {
// No connector yet — settings tab will trigger ensureConnect once
// the user picks a search space, then re-call start().
if (!settings.searchSpaceId) {
// No target yet — bare /health probe still surfaces auth/network errors.
try {
const health = await this.deps.apiClient.health();
this.applyHealth(health);
} catch (err) {
this.handleStartupError(err);
return;
}
this.setStatus("idle", "Pick a search space in settings to start syncing.");
return;
}
// Re-announce on every load: /connect doubles as the device heartbeat
// that bumps last_seen_at and powers the "Devices: N" tile in the web UI.
await this.ensureConnected();
if (!this.deps.getSettings().connectorId) return;
await this.flushQueue();
await this.maybeReconcile();
this.setStatus(this.queueStatusKind(), undefined);
@ -119,8 +112,7 @@ export class SyncEngine {
searchSpaceId: settings.searchSpaceId,
vaultId: settings.vaultId,
vaultName: settings.vaultName,
deviceId: settings.deviceId,
deviceLabel: settings.deviceLabel,
deviceId: this.deps.getDeviceId(),
});
this.applyHealth(resp);
await this.deps.saveSettings((s) => {

View file

@ -1,20 +1,15 @@
/**
* Shared types for the SurfSense Obsidian plugin.
*
* Kept in a leaf module with no other src/ imports so it can be imported
* from anywhere (settings, api-client, sync-engine, status-bar, main)
* without creating cycles.
*/
/** Shared types for the SurfSense Obsidian plugin. Leaf module — no src/ imports. */
export interface SurfsensePluginSettings {
serverUrl: string;
apiToken: string;
searchSpaceId: number | null;
connectorId: number | null;
/** UUID for the vault — lives here so Obsidian Sync replicates it across devices. */
vaultId: string;
vaultName: string;
deviceId: string;
deviceLabel: string;
// Per-install deviceId is NOT in this interface on purpose: it lives in
// app.saveLocalStorage so it stays distinct on each device. See seedIdentity().
syncMode: "auto" | "manual";
excludePatterns: string[];
includeAttachments: boolean;
@ -32,8 +27,6 @@ export const DEFAULT_SETTINGS: SurfsensePluginSettings = {
connectorId: null,
vaultId: "",
vaultName: "",
deviceId: "",
deviceLabel: "",
syncMode: "auto",
excludePatterns: [".trash", "_attachments", "templates"],
includeAttachments: false,

View file

@ -1,19 +1,11 @@
"use client";
import { AlertTriangle, Check, Copy, Download, Info } from "lucide-react";
import { type FC, useCallback, useMemo, useRef, useState } from "react";
import { AlertTriangle, Download, Info } from "lucide-react";
import { type FC, useMemo } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useApiKey } from "@/hooks/use-api-key";
import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
import type { ConnectorConfigProps } from "../index";
export interface ObsidianConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void;
}
const PLUGIN_RELEASES_URL =
"https://github.com/MODSetter/SurfSense/releases?q=obsidian&expanded=true";
@ -27,55 +19,32 @@ function formatTimestamp(value: unknown): string {
/**
* Obsidian connector config view.
*
* Renders one of two modes depending on the connector's `config`:
* Read-only on purpose: the plugin owns vault identity, so the connector's
* display name is auto-derived from `payload.vault_name` server-side on
* every `/connect` (see `obsidian_plugin_routes.obsidian_connect`). The
* web UI doesn't expose a Name input or a Save button for Obsidian (the
* latter is suppressed in `connector-edit-view.tsx`).
*
* Renders one of three modes depending on the connector's `config`:
*
* 1. **Plugin connector** (`config.source === "plugin"`) read-only stats
* panel showing what the plugin most recently reported.
* 2. **Legacy server-path connector** (`config.legacy === true`, set by the
* Phase 3 alembic) migration banner plus an "Install Plugin" CTA.
* The user's existing notes stay searchable; only background sync stops.
* Phase 3 alembic) migration banner, an "Install Plugin" CTA, and a
* short "how to migrate" checklist that ends with the user pressing the
* standard Disconnect button (which deletes this connector along with
* every document it previously indexed).
* 3. **Unknown** fallback for rows that escaped the alembic; suggests a
* clean re-install.
*/
export const ObsidianConfig: FC<ObsidianConfigProps> = ({
connector,
onNameChange,
}) => {
const [name, setName] = useState<string>(connector.name || "");
export const ObsidianConfig: FC<ConnectorConfigProps> = ({ connector }) => {
const config = (connector.config ?? {}) as Record<string, unknown>;
const isLegacy = config.legacy === true;
const isPlugin = config.source === "plugin";
const handleNameChange = (value: string) => {
setName(value);
onNameChange?.(value);
};
return (
<div className="space-y-6">
{/* Connector name (always editable) */}
<div className="rounded-xl border border-border bg-slate-400/5 p-3 sm:p-6 space-y-3 sm:space-y-4 dark:bg-white/5">
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Connector Name</Label>
<Input
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Obsidian Vault"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
A friendly name to identify this connector.
</p>
</div>
</div>
{isLegacy ? (
<LegacyBanner />
) : isPlugin ? (
<PluginStats config={config} />
) : (
<UnknownConnectorState />
)}
</div>
);
if (isLegacy) return <LegacyBanner />;
if (isPlugin) return <PluginStats config={config} />;
return <UnknownConnectorState />;
};
const LegacyBanner: FC = () => {
@ -84,14 +53,12 @@ const LegacyBanner: FC = () => {
<Alert className="border-amber-500/40 bg-amber-500/10">
<AlertTriangle className="size-4 shrink-0 text-amber-500" />
<AlertTitle className="text-xs sm:text-sm">
This connector has been migrated
Sync stopped install the plugin to migrate
</AlertTitle>
<AlertDescription className="text-[11px] sm:text-xs leading-relaxed">
This Obsidian connector used the legacy server-path method, which has
been removed. To resume syncing, install the SurfSense Obsidian
plugin and connect with this account. Your existing notes remain
searchable. After the plugin re-indexes your vault, you can delete
this connector to remove older copies.
This Obsidian connector used the legacy server-path scanner, which has been removed. The
notes already indexed remain searchable, but they no longer reflect changes made in your
vault.
</AlertDescription>
</Alert>
@ -107,7 +74,25 @@ const LegacyBanner: FC = () => {
</Button>
</a>
<ApiKeyReminder />
<div className="rounded-xl border border-border bg-slate-400/5 p-3 sm:p-6 dark:bg-white/5">
<h3 className="mb-3 text-sm font-medium sm:text-base">How to migrate</h3>
<ol className="list-decimal space-y-2 pl-5 text-[11px] leading-relaxed text-muted-foreground sm:text-xs">
<li>Install the SurfSense Obsidian plugin using the button above.</li>
<li>
In Obsidian, open Settings SurfSense, sign in, pick a search space, and wait for the
first sync to finish.
</li>
<li>
Confirm the new "Obsidian — &lt;vault&gt;" connector shows your notes, then return here
and use the Disconnect button below to remove this legacy connector.
</li>
</ol>
<p className="mt-3 text-[11px] leading-relaxed text-amber-600 dark:text-amber-400 sm:text-xs">
Heads up: Disconnect also deletes every document this connector previously indexed. Make
sure the plugin has finished its first sync before you disconnect, otherwise your Obsidian
notes will disappear from search until the plugin re-indexes them.
</p>
</div>
</div>
);
};
@ -115,6 +100,14 @@ const LegacyBanner: FC = () => {
const PluginStats: FC<{ config: Record<string, unknown> }> = ({ config }) => {
const stats: { label: string; value: string }[] = useMemo(() => {
const filesSynced = config.files_synced;
// Prefer the stamped count; fall back to len(devices) for rows the
// backend hasn't re-stamped yet.
const deviceCount =
typeof config.device_count === "number"
? config.device_count
: config.devices && typeof config.devices === "object"
? Object.keys(config.devices as Record<string, unknown>).length
: null;
return [
{ label: "Vault", value: (config.vault_name as string) || "—" },
{
@ -122,11 +115,8 @@ const PluginStats: FC<{ config: Record<string, unknown> }> = ({ config }) => {
value: (config.plugin_version as string) || "—",
},
{
label: "Device",
value:
(config.device_label as string) ||
(config.device_id as string) ||
"—",
label: "Devices",
value: deviceCount !== null ? deviceCount.toLocaleString() : "—",
},
{
label: "Last sync",
@ -134,8 +124,7 @@ const PluginStats: FC<{ config: Record<string, unknown> }> = ({ config }) => {
},
{
label: "Files synced",
value:
typeof filesSynced === "number" ? filesSynced.toLocaleString() : "—",
value: typeof filesSynced === "number" ? filesSynced.toLocaleString() : "—",
},
];
}, [config]);
@ -146,8 +135,8 @@ const PluginStats: FC<{ config: Record<string, unknown> }> = ({ config }) => {
<Info className="size-4 shrink-0 text-emerald-500" />
<AlertTitle className="text-xs sm:text-sm">Plugin connected</AlertTitle>
<AlertDescription className="text-[11px] sm:text-xs">
Edits in Obsidian sync over HTTPS. To stop syncing, disable or
uninstall the plugin in Obsidian, or delete this connector.
Edits in Obsidian sync over HTTPS. To stop syncing, disable or uninstall the plugin in
Obsidian, or delete this connector.
</AlertDescription>
</Alert>
@ -162,9 +151,7 @@ const PluginStats: FC<{ config: Record<string, unknown> }> = ({ config }) => {
<dt className="text-[10px] uppercase tracking-wide text-muted-foreground sm:text-xs">
{stat.label}
</dt>
<dd className="mt-1 truncate text-xs font-medium sm:text-sm">
{stat.value}
</dd>
<dd className="mt-1 truncate text-xs font-medium sm:text-sm">{stat.value}</dd>
</div>
))}
</dl>
@ -178,98 +165,8 @@ const UnknownConnectorState: FC = () => (
<Info className="size-4 shrink-0" />
<AlertTitle className="text-xs sm:text-sm">Unrecognized config</AlertTitle>
<AlertDescription className="text-[11px] sm:text-xs">
This connector has neither plugin metadata nor a legacy marker. It may
predate the migration you can safely delete it and re-install the
SurfSense Obsidian plugin to resume syncing.
This connector has neither plugin metadata nor a legacy marker. It may predate the migration
you can safely delete it and re-install the SurfSense Obsidian plugin to resume syncing.
</AlertDescription>
</Alert>
);
const ApiKeyReminder: FC = () => {
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
const [copiedUrl, setCopiedUrl] = useState(false);
const urlCopyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined
);
const backendUrl =
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL ?? "https://api.surfsense.com";
const copyServerUrl = useCallback(async () => {
const ok = await copyToClipboardUtil(backendUrl);
if (!ok) return;
setCopiedUrl(true);
if (urlCopyTimerRef.current) clearTimeout(urlCopyTimerRef.current);
urlCopyTimerRef.current = setTimeout(() => setCopiedUrl(false), 2000);
}, [backendUrl]);
return (
<div className="rounded-xl border border-border bg-slate-400/5 p-3 sm:p-6 space-y-3 dark:bg-white/5">
<h3 className="text-sm font-medium sm:text-base">
Plugin connection details
</h3>
<p className="text-[11px] text-muted-foreground sm:text-xs">
Paste these into the plugin's settings inside Obsidian.
</p>
<div className="space-y-2">
<Label className="text-xs sm:text-sm">API token</Label>
{isLoading ? (
<div className="h-9 w-full animate-pulse rounded-md border border-border/60 bg-muted/30" />
) : (
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
<p className="cursor-text select-all whitespace-nowrap font-mono text-[10px] text-muted-foreground">
{apiKey || "No API key available"}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={copyToClipboard}
disabled={!apiKey}
className="size-7 shrink-0 text-muted-foreground hover:text-foreground"
aria-label={copied ? "Copied" : "Copy API key"}
>
{copied ? (
<Check className="size-3.5 text-green-500" />
) : (
<Copy className="size-3.5" />
)}
</Button>
</div>
)}
<p className="text-[10px] text-muted-foreground sm:text-xs">
Token expires after 24 hours; long-lived tokens are coming in a
future release.
</p>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Server URL</Label>
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
<p className="cursor-text select-all whitespace-nowrap font-mono text-[10px] text-muted-foreground">
{backendUrl}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={copyServerUrl}
className="size-7 shrink-0 text-muted-foreground hover:text-foreground"
aria-label={copiedUrl ? "Copied" : "Copy server URL"}
>
{copiedUrl ? (
<Check className="size-3.5 text-green-500" />
) : (
<Copy className="size-3.5" />
)}
</Button>
</div>
</div>
</div>
);
};

View file

@ -87,6 +87,10 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
const isAuthExpired = connector.config?.auth_expired === true;
const reauthEndpoint = REAUTH_ENDPOINTS[connector.connector_type];
const [reauthing, setReauthing] = useState(false);
// Obsidian is plugin-driven: name + config are owned by the plugin, so
// the web edit view has nothing the user can persist back. Hide Save
// (and re-auth, which Obsidian never uses) entirely for that type.
const isPluginManagedReadOnly = connector.connector_type === EnumConnectorName.OBSIDIAN_CONNECTOR;
const handleReauth = useCallback(async () => {
const spaceId = searchSpaceId ?? searchSpaceIdAtom;
@ -412,7 +416,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
Disconnect
</Button>
)}
{isAuthExpired && reauthEndpoint ? (
{isPluginManagedReadOnly ? null : isAuthExpired && reauthEndpoint ? (
<Button
onClick={handleReauth}
disabled={reauthing || isDisconnecting}