diff --git a/surfsense_backend/app/routes/obsidian_plugin_routes.py b/surfsense_backend/app/routes/obsidian_plugin_routes.py index 9d41be2fb..0dae7a463 100644 --- a/surfsense_backend/app/routes/obsidian_plugin_routes.py +++ b/surfsense_backend/app/routes/obsidian_plugin_routes.py @@ -348,6 +348,7 @@ async def obsidian_connect( connector_id=collision.id, vault_id=collision_cfg["vault_id"], search_space_id=collision.search_space_id, + server_time_utc=datetime.now(UTC), **_build_handshake(), ) await session.commit() @@ -361,6 +362,7 @@ async def obsidian_connect( connector_id=existing_by_vid.id, vault_id=payload.vault_id, search_space_id=existing_by_vid.search_space_id, + server_time_utc=datetime.now(UTC), **_build_handshake(), ) await session.commit() @@ -379,6 +381,7 @@ async def obsidian_connect( connector_id=existing_by_fp.id, vault_id=survivor_cfg["vault_id"], search_space_id=existing_by_fp.search_space_id, + server_time_utc=datetime.now(UTC), **_build_handshake(), ) await session.commit() @@ -410,6 +413,7 @@ async def obsidian_connect( connector_id=inserted.id, vault_id=payload.vault_id, search_space_id=inserted.search_space_id, + server_time_utc=datetime.now(UTC), **_build_handshake(), ) await session.commit() @@ -431,6 +435,7 @@ async def obsidian_connect( connector_id=winner.id, vault_id=(winner.config or {})["vault_id"], search_space_id=winner.search_space_id, + server_time_utc=datetime.now(UTC), **_build_handshake(), ) await session.commit() diff --git a/surfsense_backend/app/schemas/obsidian_plugin.py b/surfsense_backend/app/schemas/obsidian_plugin.py index 7fdc2d932..89be08c8e 100644 --- a/surfsense_backend/app/schemas/obsidian_plugin.py +++ b/surfsense_backend/app/schemas/obsidian_plugin.py @@ -169,6 +169,7 @@ class ConnectResponse(_PluginBase): vault_id: str search_space_id: int capabilities: list[str] + server_time_utc: datetime class HealthResponse(_PluginBase): diff --git a/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py b/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py index 9d84afc12..41779a570 100644 --- a/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py +++ b/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py @@ -343,6 +343,7 @@ class TestWireContractSmoke: assert connect_resp.connector_id > 0 assert connect_resp.vault_id == vault_id assert "sync" in connect_resp.capabilities + assert connect_resp.server_time_utc is not None # 2. /sync — stub the indexer so the call doesn't drag the LLM / # embedding pipeline in. We're testing the wire contract, not the diff --git a/surfsense_obsidian/src/main.ts b/surfsense_obsidian/src/main.ts index b3b585132..1dea47b95 100644 --- a/surfsense_obsidian/src/main.ts +++ b/surfsense_obsidian/src/main.ts @@ -149,6 +149,7 @@ export default class SurfSensePlugin extends Plugin { }); const onNetChange = () => { + void this.engine.recoverConnectivityStatus(); if (this.shouldAutoSync()) void this.engine.flushQueue(); }; this.registerDomEvent(window, "online", onNetChange); diff --git a/surfsense_obsidian/src/settings.ts b/surfsense_obsidian/src/settings.ts index 646ac7dd0..6a01f2fd1 100644 --- a/surfsense_obsidian/src/settings.ts +++ b/surfsense_obsidian/src/settings.ts @@ -1,5 +1,6 @@ import { type App, + type ButtonComponent, Notice, Platform, PluginSettingTab, @@ -58,6 +59,11 @@ export class SurfSenseSettingTab extends PluginSettingTab { }), ); + let verifyButton: ButtonComponent | null = null; + const updateVerifyDisabled = (): void => { + verifyButton?.setDisabled(this.plugin.settings.apiToken.trim().length === 0); + }; + new Setting(containerEl) .setName("API token") .setDesc( @@ -78,29 +84,33 @@ export class SurfSenseSettingTab extends PluginSettingTab { this.plugin.settings.connectorId = null; } this.plugin.settings.apiToken = next; + updateVerifyDisabled(); await this.plugin.saveSettings(); this.plugin.api.resetAuthBlock(); }); }) - .addButton((btn) => - btn - .setButtonText("Verify") - .setCta() - .onClick(async () => { - btn.setDisabled(true); - try { - await this.plugin.api.verifyToken(); - new Notice("Surfsense: token verified."); - this.plugin.engine.refreshStatus({ force: true }); - await this.refreshSearchSpaces(); - this.display(); - } catch (err) { - this.handleApiError(err); - } finally { - btn.setDisabled(false); - } - }), - ); + .addButton((btn) => { + verifyButton = btn; + updateVerifyDisabled(); + btn.setButtonText("Verify").setCta().onClick(async () => { + if (this.plugin.settings.apiToken.trim().length === 0) { + new Notice("Surfsense: paste an API token before verifying."); + return; + } + btn.setDisabled(true); + try { + await this.plugin.api.verifyToken(); + new Notice("Surfsense: token verified."); + this.plugin.engine.refreshStatus({ force: true }); + await this.refreshSearchSpaces(); + this.display(); + } catch (err) { + this.handleApiError(err); + } finally { + updateVerifyDisabled(); + } + }); + }); new Setting(containerEl) .setName("Search space") @@ -233,12 +243,10 @@ export class SurfSenseSettingTab extends PluginSettingTab { }), ); - if (Platform.isMobileApp) { + if (Platform.isAndroidApp) { new Setting(containerEl) .setName("Sync only on WiFi") - .setDesc( - "Pause automatic syncing on cellular. Note: only Android can detect network type, on iOS this toggle has no effect.", - ) + .setDesc("Pause automatic syncing on cellular.") .addToggle((toggle) => toggle .setValue(settings.wifiOnly) @@ -367,7 +375,13 @@ export class SurfSenseSettingTab extends PluginSettingTab { } private handleApiError(err: unknown): void { - if (err instanceof AuthError) return; + if (err instanceof AuthError) { + if (err.message.startsWith("Missing API token")) { + new Notice("Surfsense: paste an API token before verifying."); + } + return; + } + this.plugin.engine.reportError(err); new Notice( `SurfSense: request failed — ${(err as Error).message ?? "unknown error"}`, ); diff --git a/surfsense_obsidian/src/sync-engine.ts b/surfsense_obsidian/src/sync-engine.ts index c98d2b354..6a47ee50d 100644 --- a/surfsense_obsidian/src/sync-engine.ts +++ b/surfsense_obsidian/src/sync-engine.ts @@ -239,7 +239,10 @@ export class SyncEngine { // ---- queue draining --------------------------------------------------- async flushQueue(): Promise { - if (this.deps.queue.size === 0) return; + if (this.deps.queue.size === 0) { + await this.recoverStatusIfNeeded(); + return; + } // Shared gate for every flush trigger so the first /sync can't race /connect. if (!this.deps.getSettings().connectorId) { const connected = await this.ensureConnected(); @@ -259,6 +262,31 @@ export class SyncEngine { this.setStatus(this.queueStatusKind(), this.statusDetail()); } + /** + * Lightweight status recovery path used after network-change signals. + * Clears stale offline/auth/error only when connectivity/auth is explicitly re-validated. + */ + async recoverConnectivityStatus(): Promise { + const settings = this.deps.getSettings(); + if (!settings.apiToken) { + this.refreshStatus({ force: true }); + return; + } + if (!settings.searchSpaceId) { + try { + const health = await this.deps.apiClient.health(); + this.applyHealth(health); + this.refreshStatus({ force: true }); + } catch (err) { + this.handleStartupError(err); + } + return; + } + const connected = await this.ensureConnected(); + if (!connected) return; + this.refreshStatus({ force: true }); + } + private async processBatch(batch: QueueItem[]): Promise { const settings = this.deps.getSettings(); const upserts = batch.filter((b): b is QueueItem & { op: "upsert" } => b.op === "upsert"); @@ -510,6 +538,7 @@ export class SyncEngine { refreshStatus(opts: { force?: boolean } = {}): void { if (!opts.force) { const last = this.lastAppliedKind; + if (last === "syncing") return; const isError = last === "auth-error" || last === "offline" || last === "error"; const s = this.deps.getSettings(); @@ -523,6 +552,18 @@ export class SyncEngine { this.setStatus("auth-error", message ?? "API token expired or invalid"); } + reportError(err: unknown): void { + if (err instanceof AuthError) { + this.reportAuthError(err.message); + return; + } + if (err instanceof TransientError) { + this.setStatus("offline", err.message); + return; + } + this.setStatus("error", (err as Error).message ?? "Unknown error"); + } + private setStatus(kind: StatusKind, detail?: string): void { const s = this.deps.getSettings(); if (!s.apiToken) { @@ -601,6 +642,19 @@ export class SyncEngine { this.setStatus(this.queueStatusKind(), `${prefix}: ${(err as Error).message}`); } + private async recoverStatusIfNeeded(): Promise { + if (!this.isRecoverableErrorState()) return; + await this.recoverConnectivityStatus(); + } + + private isRecoverableErrorState(): boolean { + return ( + this.lastAppliedKind === "offline" || + this.lastAppliedKind === "auth-error" || + this.lastAppliedKind === "error" + ); + } + // ---- predicates ------------------------------------------------------- private shouldTrack(file: TAbstractFile): boolean {