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

@ -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,