diff --git a/surfsense_backend/app/routes/obsidian_plugin_routes.py b/surfsense_backend/app/routes/obsidian_plugin_routes.py index c7656332d..0d2ce703d 100644 --- a/surfsense_backend/app/routes/obsidian_plugin_routes.py +++ b/surfsense_backend/app/routes/obsidian_plugin_routes.py @@ -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 ) diff --git a/surfsense_backend/app/schemas/obsidian_plugin.py b/surfsense_backend/app/schemas/obsidian_plugin.py index c4c3cd8d4..5de0a093a 100644 --- a/surfsense_backend/app/schemas/obsidian_plugin.py +++ b/surfsense_backend/app/schemas/obsidian_plugin.py @@ -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] diff --git a/surfsense_obsidian/src/api-client.ts b/surfsense_obsidian/src/api-client.ts index d686f661f..4b5ae0e33 100644 --- a/surfsense_obsidian/src/api-client.ts +++ b/surfsense_obsidian/src/api-client.ts @@ -105,7 +105,6 @@ export class SurfSenseApiClient { vaultId: string; vaultName: string; deviceId: string; - deviceLabel: string; }): Promise { return await this.request( "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, } ); } diff --git a/surfsense_obsidian/src/main.ts b/surfsense_obsidian/src/main.ts index 34e5715a1..262886e55 100644 --- a/surfsense_obsidian/src/main.ts +++ b/surfsense_obsidian/src/main.ts @@ -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(); } diff --git a/surfsense_obsidian/src/settings.ts b/surfsense_obsidian/src/settings.ts index d22b66384..224959f95 100644 --- a/surfsense_obsidian/src/settings.ts +++ b/surfsense_obsidian/src/settings.ts @@ -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" }); diff --git a/surfsense_obsidian/src/sync-engine.ts b/surfsense_obsidian/src/sync-engine.ts index ce22b69c1..b2c1b0a5a 100644 --- a/surfsense_obsidian/src/sync-engine.ts +++ b/surfsense_obsidian/src/sync-engine.ts @@ -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; + /** 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 { 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) => { diff --git a/surfsense_obsidian/src/types.ts b/surfsense_obsidian/src/types.ts index 8b353c2f4..33b0d01a7 100644 --- a/surfsense_obsidian/src/types.ts +++ b/surfsense_obsidian/src/types.ts @@ -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, diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/obsidian-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/obsidian-config.tsx index acea1c51b..feca9c35e 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/obsidian-config.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/obsidian-config.tsx @@ -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 = ({ - connector, - onNameChange, -}) => { - const [name, setName] = useState(connector.name || ""); +export const ObsidianConfig: FC = ({ connector }) => { const config = (connector.config ?? {}) as Record; const isLegacy = config.legacy === true; const isPlugin = config.source === "plugin"; - const handleNameChange = (value: string) => { - setName(value); - onNameChange?.(value); - }; - - return ( -
- {/* Connector name (always editable) */} -
-
- - handleNameChange(e.target.value)} - placeholder="My Obsidian Vault" - className="border-slate-400/20 focus-visible:border-slate-400/40" - /> -

- A friendly name to identify this connector. -

-
-
- - {isLegacy ? ( - - ) : isPlugin ? ( - - ) : ( - - )} -
- ); + if (isLegacy) return ; + if (isPlugin) return ; + return ; }; const LegacyBanner: FC = () => { @@ -84,14 +53,12 @@ const LegacyBanner: FC = () => { - This connector has been migrated + Sync stopped — install the plugin to migrate - 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. @@ -107,7 +74,25 @@ const LegacyBanner: FC = () => { - +
+

How to migrate

+
    +
  1. Install the SurfSense Obsidian plugin using the button above.
  2. +
  3. + In Obsidian, open Settings → SurfSense, sign in, pick a search space, and wait for the + first sync to finish. +
  4. +
  5. + Confirm the new "Obsidian — <vault>" connector shows your notes, then return here + and use the Disconnect button below to remove this legacy connector. +
  6. +
+

+ 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. +

+
); }; @@ -115,6 +100,14 @@ const LegacyBanner: FC = () => { const PluginStats: FC<{ config: Record }> = ({ 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).length + : null; return [ { label: "Vault", value: (config.vault_name as string) || "—" }, { @@ -122,11 +115,8 @@ const PluginStats: FC<{ config: Record }> = ({ 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 }> = ({ 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 }> = ({ config }) => { Plugin connected - 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. @@ -162,9 +151,7 @@ const PluginStats: FC<{ config: Record }> = ({ config }) => {
{stat.label}
-
- {stat.value} -
+
{stat.value}
))} @@ -178,98 +165,8 @@ const UnknownConnectorState: FC = () => ( Unrecognized config - 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. ); - -const ApiKeyReminder: FC = () => { - const { apiKey, isLoading, copied, copyToClipboard } = useApiKey(); - const [copiedUrl, setCopiedUrl] = useState(false); - const urlCopyTimerRef = useRef | 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 ( -
-

- Plugin connection details -

-

- Paste these into the plugin's settings inside Obsidian. -

- -
- - {isLoading ? ( -
- ) : ( -
-
-

- {apiKey || "No API key available"} -

-
- -
- )} -

- Token expires after 24 hours; long-lived tokens are coming in a - future release. -

-
- -
- -
-
-

- {backendUrl} -

-
- -
-
-
- ); -}; diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx index e19600ab2..256e9a4e7 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx @@ -87,6 +87,10 @@ export const ConnectorEditView: FC = ({ 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 = ({ Disconnect )} - {isAuthExpired && reauthEndpoint ? ( + {isPluginManagedReadOnly ? null : isAuthExpired && reauthEndpoint ? (