mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-06 20:15:17 +02:00
feat: refine Obsidian plugin routes and schemas for improved device management and API stability
This commit is contained in:
parent
60d9e7ed8c
commit
b5c9388c8a
9 changed files with 182 additions and 385 deletions
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue