diff --git a/src/main.test.ts b/src/main.test.ts new file mode 100644 index 0000000..768759f --- /dev/null +++ b/src/main.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import ObsyncPlugin from "./main"; + +vi.mock("obsidian", () => ({ + App: class {}, + Notice: class { + hide(): void {} + }, + Platform: { + isAndroidApp: false, + isIosApp: false, + isTablet: false, + }, + Plugin: class {}, + PluginSettingTab: class {}, + requestUrl: vi.fn(), + TFile: class {}, + TFolder: class {}, +})); + +describe("auto sync scheduling", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("window", { + clearTimeout: globalThis.clearTimeout, + setTimeout: globalThis.setTimeout, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + test("does not schedule when auto sync is disabled", async () => { + const plugin = createPlugin({ autoSync: false }); + + plugin.queueAutoSync(0); + await vi.runAllTimersAsync(); + + expect(plugin.syncNow).not.toHaveBeenCalled(); + }); + + test("skips scheduled sync when auto sync is disabled before the timer fires", async () => { + const plugin = createPlugin(); + + plugin.queueAutoSync(); + plugin.settings.autoSync = false; + await vi.advanceTimersByTimeAsync(2_000); + + expect(plugin.syncNow).not.toHaveBeenCalled(); + }); + + test("skips when connection settings are incomplete", async () => { + const plugin = createPlugin({ secretAccessKey: "" }); + + plugin.queueAutoSync(0); + await vi.runAllTimersAsync(); + + expect(plugin.syncNow).not.toHaveBeenCalled(); + }); + + test("debounces repeated file-change triggers into one sync", async () => { + const plugin = createPlugin(); + + plugin.queueAutoSync(); + await vi.advanceTimersByTimeAsync(1_000); + plugin.queueAutoSync(); + await vi.advanceTimersByTimeAsync(1_000); + plugin.queueAutoSync(); + await vi.advanceTimersByTimeAsync(1_999); + + expect(plugin.syncNow).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + + expect(plugin.syncNow).toHaveBeenCalledTimes(1); + }); + + test("runs one follow-up sync when triggered during an active sync", async () => { + const plugin = createPlugin(); + let finishSync: (() => void) | undefined; + plugin.syncNow.mockImplementationOnce(() => new Promise((resolve) => { + finishSync = resolve; + })); + + const firstRun = plugin.runAutoSync(); + await Promise.resolve(); + + void plugin.runAutoSync(); + expect(plugin.syncNow).toHaveBeenCalledTimes(1); + + finishSync?.(); + await firstRun; + await vi.advanceTimersByTimeAsync(2_000); + + expect(plugin.syncNow).toHaveBeenCalledTimes(2); + }); + + test("does not run queued follow-up if auto sync is disabled while syncing", async () => { + const plugin = createPlugin(); + let finishSync: (() => void) | undefined; + plugin.syncNow.mockImplementationOnce(() => new Promise((resolve) => { + finishSync = resolve; + })); + + const firstRun = plugin.runAutoSync(); + await Promise.resolve(); + + void plugin.runAutoSync(); + plugin.settings.autoSync = false; + finishSync?.(); + await firstRun; + await vi.runAllTimersAsync(); + + expect(plugin.syncNow).toHaveBeenCalledTimes(1); + }); +}); + +function createPlugin(settings: Partial = {}): TestPlugin { + const plugin = Object.create(ObsyncPlugin.prototype) as TestPlugin; + plugin.settings = { + endpoint: "https://example.com", + bucket: "vault", + region: "auto", + accessKeyId: "key", + secretAccessKey: "secret", + rootPrefix: "obsync/v1", + accountKey: "default", + vaultKey: "vault", + vaultId: "vlt_test", + deviceId: "dev_test", + deviceName: "Mac", + syncIntervalMinutes: 5, + autoSync: true, + syncState: { files: {} }, + ...settings, + }; + plugin.syncNow = vi.fn(async () => {}); + return plugin; +} + +type TestPlugin = { + settings: TestSettings; + queueAutoSync: (delayMs?: number) => void; + runAutoSync: () => Promise; + syncNow: ReturnType Promise>>; +}; + +interface TestSettings { + endpoint: string; + bucket: string; + region: string; + accessKeyId: string; + secretAccessKey: string; + rootPrefix: string; + accountKey: string; + vaultKey: string; + vaultId: string; + deviceId: string; + deviceName: string; + syncIntervalMinutes: number; + autoSync: boolean; + syncState: { files: Record }; +} diff --git a/src/main.ts b/src/main.ts index b7c2f3c..55e0d28 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import { App, Notice, Platform, Plugin, PluginSettingTab, requestUrl, TFile, TFolder } from "obsidian"; +import { App, Notice, Platform, Plugin, PluginSettingTab, requestUrl, type TAbstractFile, TFile, TFolder } from "obsidian"; import { createApp, reactive, type App as VueApp } from "vue"; import { createRandomId, createVaultId, normalizeKey } from "./core/ids"; import SettingsApp from "./settings/SettingsApp.vue"; @@ -55,8 +55,13 @@ const DEFAULT_SETTINGS: ObsyncSettings = { }, }; +const AUTO_SYNC_DEBOUNCE_MS = 2_000; + export default class ObsyncPlugin extends Plugin { settings: ObsyncSettings = DEFAULT_SETTINGS; + private autoSyncTimer: number | null = null; + private autoSyncRunning = false; + private autoSyncQueued = false; async onload(): Promise { await this.loadSettings(); @@ -87,6 +92,7 @@ export default class ObsyncPlugin extends Plugin { }); this.addSettingTab(new ObsyncSettingTab(this.app, this)); + this.registerAutoSyncTriggers(); } async syncNow(): Promise { @@ -154,6 +160,76 @@ export default class ObsyncPlugin extends Plugin { (leftRibbonActions ?? iconEl.parentElement)?.append(iconEl); } + private registerAutoSyncTriggers(): void { + this.app.workspace.onLayoutReady(() => { + this.queueAutoSync(0); + }); + + this.registerDomEvent(window, "focus", () => { + this.queueAutoSync(); + }); + + this.registerDomEvent(document, "visibilitychange", () => { + if (!document.hidden) { + this.queueAutoSync(); + } + }); + + const queueFileSync = (file: TAbstractFile) => { + if (file instanceof TFile) { + this.queueAutoSync(); + } + }; + + this.registerEvent(this.app.vault.on("create", queueFileSync)); + this.registerEvent(this.app.vault.on("modify", queueFileSync)); + this.registerEvent(this.app.vault.on("delete", queueFileSync)); + this.registerEvent(this.app.vault.on("rename", queueFileSync)); + } + + private queueAutoSync(delayMs = AUTO_SYNC_DEBOUNCE_MS): void { + if (!this.settings.autoSync) { + return; + } + + if (this.autoSyncTimer !== null) { + window.clearTimeout(this.autoSyncTimer); + } + + this.autoSyncTimer = window.setTimeout(() => { + this.autoSyncTimer = null; + void this.runAutoSync(); + }, delayMs); + } + + private async runAutoSync(): Promise { + if (!this.settings.autoSync || !this.hasConnectionSettings()) { + return; + } + + if (this.autoSyncRunning) { + this.autoSyncQueued = true; + return; + } + + this.autoSyncRunning = true; + + try { + await this.syncNow(); + } finally { + this.autoSyncRunning = false; + + if (this.autoSyncQueued) { + this.autoSyncQueued = false; + this.queueAutoSync(); + } + } + } + + private hasConnectionSettings(): boolean { + return Boolean(this.settings.endpoint && this.settings.bucket && this.settings.accessKeyId && this.settings.secretAccessKey); + } + async loadSettings(): Promise { const loaded = await this.loadData(); this.settings = Object.assign({}, DEFAULT_SETTINGS, loaded);