Enable automatic sync triggers

This commit is contained in:
ichaly 2026-06-04 23:43:36 +08:00
parent 4408b1f981
commit 2ec89b9bd0
2 changed files with 243 additions and 1 deletions

166
src/main.test.ts Normal file
View file

@ -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<void>((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<void>((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<TestSettings> = {}): 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<void>;
syncNow: ReturnType<typeof vi.fn<() => Promise<void>>>;
};
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<string, unknown> };
}

View file

@ -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<void> {
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<void> {
@ -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<void> {
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<void> {
const loaded = await this.loadData();
this.settings = Object.assign({}, DEFAULT_SETTINGS, loaded);