SurfSense/surfsense_obsidian/src/main.ts
Anish Sarkar 02795e08e3 feat: add server time to obsidian connect responses and enhance error handling
- Included server_time_utc in the connect response schema for better synchronization.
- Updated obsidian_connect function to set server_time_utc during connection handling.
- Enhanced integration tests to verify the presence of server_time_utc in responses.
- Improved connectivity status recovery in the sync engine for better error management.
2026-04-25 03:57:07 +05:30

292 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Notice, Platform, Plugin } from "obsidian";
import { SurfSenseApiClient } from "./api-client";
import { PersistentQueue } from "./queue";
import { SurfSenseSettingTab } from "./settings";
import { StatusBar } from "./status-bar";
import { StatusModal } from "./status-modal";
import { SyncEngine } from "./sync-engine";
import {
DEFAULT_SETTINGS,
type QueueItem,
type StatusState,
type SurfsensePluginSettings,
} from "./types";
import { generateVaultUuid } from "./vault-identity";
/** SurfSense plugin entry point. */
export default class SurfSensePlugin extends Plugin {
settings!: SurfsensePluginSettings;
api!: SurfSenseApiClient;
queue!: PersistentQueue;
engine!: SyncEngine;
private statusBar: StatusBar | null = null;
lastStatus: StatusState = { kind: "needs-setup", queueDepth: 0 };
serverCapabilities: string[] = [];
private settingTab: SurfSenseSettingTab | null = null;
private statusListeners = new Set<() => void>();
private reconcileTimerId: number | null = null;
private lastAuthToastAt = 0;
async onload() {
await this.loadSettings();
this.seedIdentity();
await this.saveSettings();
this.api = new SurfSenseApiClient({
getServerUrl: () => this.settings.serverUrl,
getToken: () => this.settings.apiToken,
onAuthError: () => this.notifyAuthError(),
});
this.queue = new PersistentQueue(this.settings.queue ?? [], {
persist: async (items) => {
this.settings.queue = items;
await this.saveData(this.settings);
},
});
this.engine = new SyncEngine({
app: this.app,
apiClient: this.api,
queue: this.queue,
getSettings: () => this.settings,
saveSettings: async (mut) => {
mut(this.settings);
await this.saveSettings();
this.notifyStatusChange();
},
setStatus: (s) => {
this.lastStatus = s;
this.statusBar?.update(s);
this.notifyStatusChange();
},
onCapabilities: (caps) => {
this.serverCapabilities = [...caps];
this.notifyStatusChange();
},
onReconcileBackoffChanged: () => {
this.restartReconcileTimer();
},
});
this.queue.setFlushHandler(() => {
if (!this.shouldAutoSync()) return;
void this.engine.flushQueue();
});
this.settingTab = new SurfSenseSettingTab(this.app, this);
this.addSettingTab(this.settingTab);
const statusHost = this.addStatusBarItem();
this.statusBar = new StatusBar(statusHost, () => this.openStatusModal());
this.statusBar.update(this.lastStatus);
this.registerEvent(
this.app.vault.on("create", (file) => this.engine.onCreate(file)),
);
this.registerEvent(
this.app.vault.on("modify", (file) => this.engine.onModify(file)),
);
this.registerEvent(
this.app.vault.on("delete", (file) => this.engine.onDelete(file)),
);
this.registerEvent(
this.app.vault.on("rename", (file, oldPath) =>
this.engine.onRename(file, oldPath),
),
);
this.registerEvent(
this.app.metadataCache.on("changed", (file, data, cache) =>
this.engine.onMetadataChanged(file, data, cache),
),
);
this.addCommand({
id: "resync-vault",
name: "Re-sync entire vault",
callback: async () => {
try {
await this.engine.maybeReconcile(true);
new Notice("Surfsense: re-sync started.");
} catch (err) {
new Notice(`Surfsense: re-sync failed — ${(err as Error).message}`);
}
},
});
this.addCommand({
id: "sync-current-note",
name: "Sync current note",
checkCallback: (checking) => {
const file = this.app.workspace.getActiveFile();
if (!file || file.extension.toLowerCase() !== "md") return false;
if (checking) return true;
this.queue.enqueueUpsert(file.path);
void this.engine.flushQueue();
return true;
},
});
this.addCommand({
id: "open-status",
name: "Open sync status",
callback: () => this.openStatusModal(),
});
this.addCommand({
id: "open-settings",
name: "Open settings",
callback: () => {
// `app.setting` isn't in the d.ts; fall back silently if it moves.
type SettingHost = {
open?: () => void;
openTabById?: (id: string) => void;
};
const setting = (this.app as unknown as { setting?: SettingHost }).setting;
if (setting?.open) setting.open();
if (setting?.openTabById) setting.openTabById(this.manifest.id);
},
});
const onNetChange = () => {
void this.engine.recoverConnectivityStatus();
if (this.shouldAutoSync()) void this.engine.flushQueue();
};
this.registerDomEvent(window, "online", onNetChange);
const conn = (navigator as unknown as { connection?: NetworkConnection }).connection;
if (conn && typeof conn.addEventListener === "function") {
conn.addEventListener("change", onNetChange);
this.register(() => conn.removeEventListener?.("change", onNetChange));
}
// Wait for layout so the metadataCache is warm before reconcile.
this.app.workspace.onLayoutReady(() => {
void this.engine.start();
this.restartReconcileTimer();
});
}
onunload() {
this.queue?.cancelFlush();
this.queue?.requestStop();
}
/**
* Obsidian fires this when another device rewrites our data.json.
* If the synced vault_id differs from ours, adopt it and
* re-handshake so the server routes us to the right row.
*/
async onExternalSettingsChange(): Promise<void> {
const previousVaultId = this.settings.vaultId;
const previousConnectorId = this.settings.connectorId;
await this.loadSettings();
const changed =
this.settings.vaultId !== previousVaultId ||
this.settings.connectorId !== previousConnectorId;
if (!changed) return;
this.engine?.refreshStatus();
this.notifyStatusChange();
if (this.settings.searchSpaceId !== null) {
void this.engine.ensureConnected();
}
}
get queueDepth(): number {
return this.queue?.size ?? 0;
}
openStatusModal(): void {
new StatusModal(this.app, this).open();
}
restartReconcileTimer(): void {
if (this.reconcileTimerId !== null) {
window.clearInterval(this.reconcileTimerId);
this.reconcileTimerId = null;
}
const minutes = this.settings.syncIntervalMinutes ?? 10;
if (minutes <= 0) return;
const baseMs = minutes * 60 * 1000;
// Idle vaults back off (×2 → ×4 → ×8); resets on the first edit or non-empty reconcile.
const effectiveMs = this.engine?.getReconcileBackoffMs(baseMs) ?? baseMs;
const id = window.setInterval(
() => {
if (!this.shouldAutoSync()) return;
void this.engine.maybeReconcile();
},
effectiveMs,
);
this.reconcileTimerId = id;
this.registerInterval(id);
}
/** Gate for background network activity; per-edit flush + periodic reconcile both consult this. */
shouldAutoSync(): boolean {
if (!this.settings.wifiOnly) return true;
if (!Platform.isMobileApp) return true;
// navigator.connection is supported on Android Capacitor; undefined on iOS.
// When unavailable, behave permissively so iOS users aren't blocked outright.
const conn = (navigator as unknown as { connection?: NetworkConnection }).connection;
if (!conn || typeof conn.type !== "string") return true;
return conn.type === "wifi" || conn.type === "ethernet";
}
onStatusChange(listener: () => void): void {
this.statusListeners.add(listener);
}
offStatusChange(listener: () => void): void {
this.statusListeners.delete(listener);
}
private notifyStatusChange(): void {
for (const fn of this.statusListeners) fn();
}
private notifyAuthError(): void {
this.engine?.reportAuthError();
const now = Date.now();
if (now - this.lastAuthToastAt < 10_000) return;
this.lastAuthToastAt = now;
new Notice("Surfsense: API token expired or invalid. Paste a fresh token in settings.", 8000);
}
async loadSettings() {
const data = (await this.loadData()) as Partial<SurfsensePluginSettings> | null;
this.settings = {
...DEFAULT_SETTINGS,
...(data ?? {}),
queue: (data?.queue ?? []).map((i: QueueItem) => ({ ...i })),
tombstones: { ...(data?.tombstones ?? {}) },
includeFolders: [...(data?.includeFolders ?? [])],
excludeFolders: [...(data?.excludeFolders ?? [])],
excludePatterns: data?.excludePatterns?.length
? [...data.excludePatterns]
: [...DEFAULT_SETTINGS.excludePatterns],
};
}
async saveSettings() {
await this.saveData(this.settings);
this.engine?.refreshStatus();
}
/**
* Mint a tentative vault_id locally on first run. The server's
* fingerprint dedup (see /obsidian/connect) may overwrite it on the
* first /connect when another device of the same vault has already
* registered; we always trust the server's response.
*/
private seedIdentity(): void {
if (!this.settings.vaultId) {
this.settings.vaultId = generateVaultUuid();
}
}
}
/** Subset of the Network Information API used to detect WiFi vs cellular on Android. */
interface NetworkConnection {
type?: string;
addEventListener?: (event: string, handler: () => void) => void;
removeEventListener?: (event: string, handler: () => void) => void;
}