2026-04-20 23:48:51 +05:30
|
|
|
|
import { Notice, Platform, Plugin } from "obsidian";
|
2026-04-20 04:04:19 +05:30
|
|
|
|
import { SurfSenseApiClient } from "./api-client";
|
|
|
|
|
|
import { PersistentQueue } from "./queue";
|
|
|
|
|
|
import { SurfSenseSettingTab } from "./settings";
|
|
|
|
|
|
import { StatusBar } from "./status-bar";
|
2026-04-20 23:13:49 +05:30
|
|
|
|
import { StatusModal } from "./status-modal";
|
2026-04-20 04:04:19 +05:30
|
|
|
|
import { SyncEngine } from "./sync-engine";
|
|
|
|
|
|
import {
|
|
|
|
|
|
DEFAULT_SETTINGS,
|
|
|
|
|
|
type QueueItem,
|
|
|
|
|
|
type StatusState,
|
|
|
|
|
|
type SurfsensePluginSettings,
|
|
|
|
|
|
} from "./types";
|
2026-04-21 04:21:33 +05:30
|
|
|
|
import { generateVaultUuid } from "./vault-identity";
|
2026-04-20 04:04:19 +05:30
|
|
|
|
|
2026-04-20 18:19:30 +05:30
|
|
|
|
/** SurfSense plugin entry point. */
|
2026-04-20 04:04:19 +05:30
|
|
|
|
export default class SurfSensePlugin extends Plugin {
|
|
|
|
|
|
settings!: SurfsensePluginSettings;
|
|
|
|
|
|
api!: SurfSenseApiClient;
|
|
|
|
|
|
queue!: PersistentQueue;
|
|
|
|
|
|
engine!: SyncEngine;
|
|
|
|
|
|
private statusBar: StatusBar | null = null;
|
2026-04-25 02:16:38 +05:30
|
|
|
|
lastStatus: StatusState = { kind: "needs-setup", queueDepth: 0 };
|
2026-04-20 04:04:19 +05:30
|
|
|
|
serverCapabilities: string[] = [];
|
|
|
|
|
|
private settingTab: SurfSenseSettingTab | null = null;
|
2026-04-20 23:13:49 +05:30
|
|
|
|
private statusListeners = new Set<() => void>();
|
2026-04-20 23:48:51 +05:30
|
|
|
|
private reconcileTimerId: number | null = null;
|
2026-04-25 01:07:02 +05:30
|
|
|
|
private lastAuthToastAt = 0;
|
2026-04-19 23:48:18 +05:30
|
|
|
|
|
|
|
|
|
|
async onload() {
|
|
|
|
|
|
await this.loadSettings();
|
2026-04-20 04:04:19 +05:30
|
|
|
|
this.seedIdentity();
|
|
|
|
|
|
await this.saveSettings();
|
|
|
|
|
|
|
|
|
|
|
|
this.api = new SurfSenseApiClient({
|
|
|
|
|
|
getServerUrl: () => this.settings.serverUrl,
|
|
|
|
|
|
getToken: () => this.settings.apiToken,
|
2026-04-25 01:07:02 +05:30
|
|
|
|
onAuthError: () => this.notifyAuthError(),
|
2026-04-19 23:48:18 +05:30
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-20 04:04:19 +05:30
|
|
|
|
this.queue = new PersistentQueue(this.settings.queue ?? [], {
|
|
|
|
|
|
persist: async (items) => {
|
|
|
|
|
|
this.settings.queue = items;
|
|
|
|
|
|
await this.saveData(this.settings);
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
2026-04-19 23:48:18 +05:30
|
|
|
|
|
2026-04-20 04:04:19 +05:30
|
|
|
|
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();
|
2026-04-20 23:13:49 +05:30
|
|
|
|
this.notifyStatusChange();
|
2026-04-20 04:04:19 +05:30
|
|
|
|
},
|
|
|
|
|
|
setStatus: (s) => {
|
|
|
|
|
|
this.lastStatus = s;
|
|
|
|
|
|
this.statusBar?.update(s);
|
2026-04-20 23:13:49 +05:30
|
|
|
|
this.notifyStatusChange();
|
2026-04-20 04:04:19 +05:30
|
|
|
|
},
|
2026-04-20 23:13:49 +05:30
|
|
|
|
onCapabilities: (caps) => {
|
2026-04-20 04:04:19 +05:30
|
|
|
|
this.serverCapabilities = [...caps];
|
2026-04-20 23:13:49 +05:30
|
|
|
|
this.notifyStatusChange();
|
2026-04-20 04:04:19 +05:30
|
|
|
|
},
|
2026-04-20 23:48:51 +05:30
|
|
|
|
onReconcileBackoffChanged: () => {
|
|
|
|
|
|
this.restartReconcileTimer();
|
|
|
|
|
|
},
|
2026-04-19 23:48:18 +05:30
|
|
|
|
});
|
2026-04-20 04:04:19 +05:30
|
|
|
|
|
|
|
|
|
|
this.queue.setFlushHandler(() => {
|
2026-04-20 23:48:51 +05:30
|
|
|
|
if (!this.shouldAutoSync()) return;
|
2026-04-20 04:04:19 +05:30
|
|
|
|
void this.engine.flushQueue();
|
2026-04-19 23:48:18 +05:30
|
|
|
|
});
|
2026-04-20 04:04:19 +05:30
|
|
|
|
|
|
|
|
|
|
this.settingTab = new SurfSenseSettingTab(this.app, this);
|
|
|
|
|
|
this.addSettingTab(this.settingTab);
|
|
|
|
|
|
|
|
|
|
|
|
const statusHost = this.addStatusBarItem();
|
2026-04-20 23:13:49 +05:30
|
|
|
|
this.statusBar = new StatusBar(statusHost, () => this.openStatusModal());
|
2026-04-20 04:04:19 +05:30
|
|
|
|
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),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-04-19 23:48:18 +05:30
|
|
|
|
this.addCommand({
|
2026-04-20 04:04:19 +05:30
|
|
|
|
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}`);
|
2026-04-19 23:48:18 +05:30
|
|
|
|
}
|
2026-04-20 04:04:19 +05:30
|
|
|
|
},
|
2026-04-19 23:48:18 +05:30
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-20 04:04:19 +05:30
|
|
|
|
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;
|
|
|
|
|
|
},
|
2026-04-19 23:48:18 +05:30
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-20 23:13:49 +05:30
|
|
|
|
this.addCommand({
|
|
|
|
|
|
id: "open-status",
|
|
|
|
|
|
name: "Open sync status",
|
|
|
|
|
|
callback: () => this.openStatusModal(),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-20 04:04:19 +05:30
|
|
|
|
this.addCommand({
|
|
|
|
|
|
id: "open-settings",
|
|
|
|
|
|
name: "Open settings",
|
|
|
|
|
|
callback: () => {
|
2026-04-20 18:19:30 +05:30
|
|
|
|
// `app.setting` isn't in the d.ts; fall back silently if it moves.
|
2026-04-20 04:04:19 +05:30
|
|
|
|
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);
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
2026-04-19 23:48:18 +05:30
|
|
|
|
|
2026-04-20 23:48:51 +05:30
|
|
|
|
const onNetChange = () => {
|
2026-04-25 03:57:07 +05:30
|
|
|
|
void this.engine.recoverConnectivityStatus();
|
2026-04-20 23:48:51 +05:30
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 18:19:30 +05:30
|
|
|
|
// Wait for layout so the metadataCache is warm before reconcile.
|
2026-04-20 04:04:19 +05:30
|
|
|
|
this.app.workspace.onLayoutReady(() => {
|
|
|
|
|
|
void this.engine.start();
|
2026-04-20 23:48:51 +05:30
|
|
|
|
this.restartReconcileTimer();
|
2026-04-20 04:04:19 +05:30
|
|
|
|
});
|
2026-04-19 23:48:18 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onunload() {
|
2026-04-20 04:04:19 +05:30
|
|
|
|
this.queue?.cancelFlush();
|
|
|
|
|
|
this.queue?.requestStop();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 04:21:33 +05:30
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
2026-04-25 03:24:17 +05:30
|
|
|
|
this.engine?.refreshStatus();
|
2026-04-21 04:21:33 +05:30
|
|
|
|
this.notifyStatusChange();
|
|
|
|
|
|
if (this.settings.searchSpaceId !== null) {
|
|
|
|
|
|
void this.engine.ensureConnected();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 04:04:19 +05:30
|
|
|
|
get queueDepth(): number {
|
|
|
|
|
|
return this.queue?.size ?? 0;
|
2026-04-19 23:48:18 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 23:13:49 +05:30
|
|
|
|
openStatusModal(): void {
|
|
|
|
|
|
new StatusModal(this.app, this).open();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 23:48:51 +05:30
|
|
|
|
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";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 23:13:49 +05:30
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-25 01:07:02 +05:30
|
|
|
|
private notifyAuthError(): void {
|
2026-04-25 03:24:17 +05:30
|
|
|
|
this.engine?.reportAuthError();
|
2026-04-25 01:07:02 +05:30
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 23:48:18 +05:30
|
|
|
|
async loadSettings() {
|
2026-04-20 04:04:19 +05:30
|
|
|
|
const data = (await this.loadData()) as Partial<SurfsensePluginSettings> | null;
|
|
|
|
|
|
this.settings = {
|
|
|
|
|
|
...DEFAULT_SETTINGS,
|
|
|
|
|
|
...(data ?? {}),
|
|
|
|
|
|
queue: (data?.queue ?? []).map((i: QueueItem) => ({ ...i })),
|
|
|
|
|
|
tombstones: { ...(data?.tombstones ?? {}) },
|
2026-04-20 23:13:49 +05:30
|
|
|
|
includeFolders: [...(data?.includeFolders ?? [])],
|
|
|
|
|
|
excludeFolders: [...(data?.excludeFolders ?? [])],
|
2026-04-20 04:04:19 +05:30
|
|
|
|
excludePatterns: data?.excludePatterns?.length
|
|
|
|
|
|
? [...data.excludePatterns]
|
|
|
|
|
|
: [...DEFAULT_SETTINGS.excludePatterns],
|
|
|
|
|
|
};
|
2026-04-19 23:48:18 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async saveSettings() {
|
|
|
|
|
|
await this.saveData(this.settings);
|
2026-04-25 02:16:38 +05:30
|
|
|
|
this.engine?.refreshStatus();
|
2026-04-19 23:48:18 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 04:21:33 +05:30
|
|
|
|
/**
|
|
|
|
|
|
* 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.
|
|
|
|
|
|
*/
|
2026-04-20 04:04:19 +05:30
|
|
|
|
private seedIdentity(): void {
|
|
|
|
|
|
if (!this.settings.vaultId) {
|
2026-04-21 04:21:33 +05:30
|
|
|
|
this.settings.vaultId = generateVaultUuid();
|
2026-04-20 04:04:19 +05:30
|
|
|
|
}
|
2026-04-19 23:48:18 +05:30
|
|
|
|
}
|
2026-04-20 04:04:19 +05:30
|
|
|
|
}
|
2026-04-19 23:48:18 +05:30
|
|
|
|
|
2026-04-20 23:48:51 +05:30
|
|
|
|
/** 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;
|
|
|
|
|
|
}
|