diff --git a/src/main.test.ts b/src/main.test.ts index 768759f..a19c23e 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -118,8 +118,76 @@ describe("auto sync scheduling", () => { }); }); +describe("connection config import and export", () => { + test("exports shareable fields without access credentials", () => { + const plugin = createPlugin({ + accessKeyId: "AKIA_TEST", + secretAccessKey: "SECRET_TEST", + rootPrefix: "obsync/v2", + accountKey: "team", + vaultKey: "notes", + vaultId: "vlt_notes", + }); + + expect(plugin.getConnectionConfig()).toEqual({ + schemaVersion: 1, + endpoint: "https://example.com", + bucket: "vault", + region: "auto", + rootPrefix: "obsync/v2", + accountKey: "team", + vaultKey: "notes", + vaultId: "vlt_notes", + }); + expect(JSON.stringify(plugin.getConnectionConfig())).not.toContain("AKIA_TEST"); + expect(JSON.stringify(plugin.getConnectionConfig())).not.toContain("SECRET_TEST"); + }); + + test("imports connection config without replacing access credentials", async () => { + const plugin = createPlugin({ + accessKeyId: "existing-key", + secretAccessKey: "existing-secret", + vaultId: "old-vault", + }); + + await plugin.importConnectionConfig(JSON.stringify({ + schemaVersion: 1, + endpoint: "https://r2.example.com", + bucket: "shared-vault", + region: "auto", + rootPrefix: "obsync/v2", + accountKey: "team", + vaultKey: "product", + vaultId: "ignored-remote-id", + })); + + expect(plugin.settings).toMatchObject({ + endpoint: "https://r2.example.com", + bucket: "shared-vault", + region: "auto", + rootPrefix: "obsync/v2", + accountKey: "team", + vaultKey: "product", + accessKeyId: "existing-key", + secretAccessKey: "existing-secret", + }); + expect(plugin.settings.vaultId).not.toBe("old-vault"); + expect(plugin.settings.vaultId).not.toBe("ignored-remote-id"); + expect(plugin.saveSettings).toHaveBeenCalledTimes(1); + }); + + test("rejects invalid connection config text", async () => { + const plugin = createPlugin(); + + await expect(plugin.importConnectionConfig("not json")).rejects.toThrow("连接配置不是有效 JSON。"); + await expect(plugin.importConnectionConfig(JSON.stringify({ schemaVersion: 2 }))).rejects.toThrow("连接配置版本不支持。"); + await expect(plugin.importConnectionConfig(JSON.stringify({ schemaVersion: 1, endpoint: "x" }))).rejects.toThrow("连接配置缺少 bucket。"); + }); +}); + function createPlugin(settings: Partial = {}): TestPlugin { const plugin = Object.create(ObsyncPlugin.prototype) as TestPlugin; + plugin.app = { vault: { getName: () => "ichaly" } }; plugin.settings = { endpoint: "https://example.com", bucket: "vault", @@ -137,12 +205,17 @@ function createPlugin(settings: Partial = {}): TestPlugin { syncState: { files: {} }, ...settings, }; + plugin.saveSettings = vi.fn(async () => {}); plugin.syncNow = vi.fn(async () => {}); return plugin; } type TestPlugin = { + app: { vault: { getName: () => string } }; settings: TestSettings; + getConnectionConfig: () => Record; + importConnectionConfig: (text: string) => Promise; + saveSettings: ReturnType Promise>>; queueAutoSync: (delayMs?: number) => void; runAutoSync: () => Promise; syncNow: ReturnType Promise>>; diff --git a/src/main.ts b/src/main.ts index 647fd96..073ecf5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -62,9 +62,13 @@ export default class ObsyncPlugin extends Plugin { private autoSyncTimer: number | null = null; private autoSyncRunning = false; private autoSyncQueued = false; + private statusBarItem: HTMLElement | null = null; async onload(): Promise { await this.loadSettings(); + this.statusBarItem = this.addStatusBarItem(); + this.statusBarItem.classList.add("obsync-status-bar-item"); + this.clearOperationStatus(); this.addCommand({ id: "manual-sync", @@ -104,14 +108,7 @@ export default class ObsyncPlugin extends Plugin { } async syncNow(): Promise { - if (!this.settings.endpoint || !this.settings.bucket || !this.settings.accessKeyId || !this.settings.secretAccessKey) { - new Notice("请先填写端点、Bucket、Access Key ID 和 Secret Access Key。"); - return; - } - - const syncingNotice = new Notice("Obsync 正在同步...", 0); - - try { + await this.runConfiguredOperation("同步中...", "Obsync 同步失败", async () => { const store = this.createObjectStore(); const result = await syncOnce({ vault: new ObsidianVaultIO(this.app), @@ -123,7 +120,6 @@ export default class ObsyncPlugin extends Plugin { }); await this.saveSettings(); - syncingNotice.hide(); if (result.locked) { new Notice("另一台设备正在同步,本次已跳过。"); @@ -131,11 +127,7 @@ export default class ObsyncPlugin extends Plugin { } new Notice(`Obsync 同步完成:上传 ${result.uploaded},下载 ${result.downloaded},冲突 ${result.conflicts}。`); - } catch (error) { - syncingNotice.hide(); - const message = error instanceof Error ? error.message : String(error); - new Notice(`Obsync 同步失败:${message}`); - } + }); } private moveRibbonIconToBottom(iconEl: HTMLElement): void { @@ -214,17 +206,8 @@ export default class ObsyncPlugin extends Plugin { } async releaseDeletedContentNow(): Promise { - if (!this.hasConnectionSettings()) { - new Notice("请先填写端点、Bucket、Access Key ID 和 Secret Access Key。"); - return; - } - - const notice = new Notice("Obsync 正在释放已删除内容...", 0); - - try { - const store = this.createObjectStore(); - const result = await releaseDeletedContent({ store, now: () => Date.now() }); - notice.hide(); + await this.runConfiguredOperation("清理中...", "释放已删除内容失败", async () => { + const result = await releaseDeletedContent({ store: this.createObjectStore(), now: () => Date.now() }); if (result.locked) { new Notice("另一台设备正在同步,本次释放空间已跳过。"); @@ -234,11 +217,37 @@ export default class ObsyncPlugin extends Plugin { this.pruneLocalDeletedState(); await this.saveSettings(); new Notice(`Obsync 已释放:清理删除记录 ${result.deletedTombstones},删除 Blob ${result.deletedBlobs}。`); - } catch (error) { - notice.hide(); - const message = error instanceof Error ? error.message : String(error); - new Notice(`释放已删除内容失败:${message}`); + }); + } + + private async runConfiguredOperation(progressText: string, failurePrefix: string, operation: () => Promise): Promise { + if (!this.hasConnectionSettings()) { + new Notice("请先填写端点、Bucket、Access Key ID 和 Secret Access Key。"); + return; } + + this.setOperationStatus(progressText); + + try { + await operation(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + new Notice(`${failurePrefix}:${message}`); + } finally { + this.clearOperationStatus(); + } + } + + private setOperationStatus(text: string): void { + this.statusBarItem?.classList.remove("is-hidden"); + this.statusBarItem?.setText(text); + this.statusBarItem?.setAttribute("title", text); + } + + private clearOperationStatus(): void { + this.statusBarItem?.classList.add("is-hidden"); + this.statusBarItem?.setText(""); + this.statusBarItem?.removeAttribute("title"); } private createObjectStore(): S3ObjectStore { diff --git a/src/settings/SettingsApp.vue b/src/settings/SettingsApp.vue index 5713d2c..fc0ccb7 100644 --- a/src/settings/SettingsApp.vue +++ b/src/settings/SettingsApp.vue @@ -223,7 +223,7 @@ watchEffect(() => { -
+

自动同步

启动、恢复前台和文件变化时自动同步。

diff --git a/src/store/s3.ts b/src/store/s3.ts index 307e462..400cdb2 100644 --- a/src/store/s3.ts +++ b/src/store/s3.ts @@ -236,13 +236,7 @@ function formatAmzDate(timestamp: number): string { function createCanonicalQueryString(url: URL): string { return Array.from(url.searchParams.entries()) - .sort(([leftKey, leftValue], [rightKey, rightValue]) => { - if (leftKey === rightKey) { - return leftValue.localeCompare(rightValue); - } - - return leftKey.localeCompare(rightKey); - }) + .sort(([leftKey, leftValue], [rightKey, rightValue]) => leftKey.localeCompare(rightKey) || leftValue.localeCompare(rightValue)) .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) .join("&"); } @@ -262,15 +256,7 @@ function safeParseJson(value: string): unknown { } function parseListBucketKeys(xml: string): string[] { - const keys: string[] = []; - const keyPattern = /([^<]*)<\/Key>/g; - let match: RegExpExecArray | null; - - while ((match = keyPattern.exec(xml)) !== null) { - keys.push(decodeXml(match[1])); - } - - return keys; + return readXmlTags(xml, "Key"); } function readListContinuationToken(xml: string): string | undefined { @@ -278,8 +264,19 @@ function readListContinuationToken(xml: string): string | undefined { return undefined; } - const match = xml.match(/([^<]*)<\/NextContinuationToken>/); - return match ? decodeXml(match[1]) : undefined; + return readXmlTags(xml, "NextContinuationToken")[0]; +} + +function readXmlTags(xml: string, tag: string): string[] { + const values: string[] = []; + const pattern = new RegExp(`<${tag}>([^<]*)<\\/${tag}>`, "g"); + let match: RegExpExecArray | null; + + while ((match = pattern.exec(xml)) !== null) { + values.push(decodeXml(match[1])); + } + + return values; } function decodeXml(value: string): string { diff --git a/src/styles.scss b/src/styles.scss index bdc6bca..15ab2ea 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -4,6 +4,15 @@ container-type: inline-size; } +.obsync-status-bar-item { + @apply flex items-center gap-1 text-xs; + color: var(--text-muted); +} + +.obsync-status-bar-item.is-hidden { + display: none; +} + .obsync-panel { @apply w-full max-w-3xl pb-6; color: var(--text-normal); @@ -232,6 +241,14 @@ } } +.obsync-toggle-row { + @apply flex items-center justify-between gap-5; + + .obsync-row-copy { + @apply mb-0; + } +} + .obsync-row-block { @apply block; } @@ -282,6 +299,14 @@ @apply mb-2; } + .obsync-toggle-row { + @apply flex gap-4; + + .obsync-row-copy { + @apply mb-0; + } + } + .obsync-row-title { @apply items-start; } @@ -309,7 +334,7 @@ @apply mt-2; } - .obsync-switch { + .obsync-row:not(.obsync-toggle-row) .obsync-switch { @apply mt-2; } } diff --git a/src/sync/engine.test.ts b/src/sync/engine.test.ts index f659f04..9ab0941 100644 --- a/src/sync/engine.test.ts +++ b/src/sync/engine.test.ts @@ -7,7 +7,7 @@ describe("sync engine", () => { const store = new FakeObjectStore(); const state: LocalSyncState = { files: {} }; - const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 }); + const result = await sync(vault, store, state, 1000); const hash = await hashText("hello"); expect(result.uploaded).toBe(1); @@ -21,9 +21,9 @@ describe("sync engine", () => { const store = new FakeObjectStore(); const state: LocalSyncState = { files: {} }; - await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 }); + await sync(vault, store, state, 1000); vault.readCount = 0; - const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 2000 }); + const result = await sync(vault, store, state, 2000); expect(result.uploaded).toBe(0); expect(result.downloaded).toBe(0); @@ -35,11 +35,11 @@ describe("sync engine", () => { const store = new FakeObjectStore(); const state: LocalSyncState = { files: {} }; - await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 }); + await sync(vault, store, state, 1000); store.acquireLockCount = 0; store.writeManifestCount = 0; - const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 2000 }); + const result = await sync(vault, store, state, 2000); expect(result.locked).toBe(false); expect(store.acquireLockCount).toBe(0); @@ -69,7 +69,7 @@ describe("sync engine", () => { version: "ver_remote", }; - const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 2000 }); + const result = await sync(vault, store, state, 2000); expect(result.downloaded).toBe(1); expect(await vault.readText("notes/today.md")).toBe("remote"); @@ -157,7 +157,7 @@ describe("sync engine", () => { const store = new FakeObjectStore(); const state: LocalSyncState = { files: {} }; - await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 }); + await sync(vault, store, state, 1000); const hash = await hashText("hello"); expect(await store.getText(`blobs/sha256/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash}`)).toBe("hello"); @@ -176,7 +176,7 @@ describe("sync engine", () => { createdAt: 1000, }; - await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 2000 }); + await sync(vault, store, state, 2000); expect(await store.getText(blobObjectKey(hash))).toBe("hello"); }); @@ -186,10 +186,10 @@ describe("sync engine", () => { const store = new FakeObjectStore(); const state: LocalSyncState = { files: {} }; - await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 }); + await sync(vault, store, state, 1000); const hash = await hashText("hello"); await vault.delete("notes/today.md"); - const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 2000 }); + const result = await sync(vault, store, state, 2000); expect(result.deletedRemote).toBe(1); expect(result.conflicts).toBe(0); @@ -222,7 +222,7 @@ describe("sync engine", () => { version: "ver_remote", }; - const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 2000 }); + const result = await sync(vault, store, state, 2000); expect(result.deletedRemote).toBe(1); expect(result.conflicts).toBe(0); @@ -252,7 +252,7 @@ describe("sync engine", () => { deletedBy: "dev_other", }; - const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 3000 }); + const result = await sync(vault, store, state, 3000); expect(result.deletedLocal).toBe(1); await expect(vault.readText("notes/today.md")).resolves.toBe(""); @@ -281,7 +281,7 @@ describe("sync engine", () => { deletedBy: "dev_other", }; - const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 3000 }); + const result = await sync(vault, store, state, 3000); expect(result.conflicts).toBe(1); expect(result.deletedLocal).toBe(1); @@ -303,7 +303,7 @@ describe("sync engine", () => { deletedBy: "dev_other", }; - const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 3000 }); + const result = await sync(vault, store, state, 3000); expect(result.conflicts).toBe(1); expect(result.uploaded).toBe(0); @@ -334,7 +334,7 @@ describe("sync engine", () => { }; store.writeManifestCount = 0; - await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 + 29 * 24 * 60 * 60 * 1000 }); + await sync(vault, store, state, 1000 + 29 * 24 * 60 * 60 * 1000); expect(store.manifest.deleted["notes/today.md"]).toBeDefined(); expect(state.files["notes/today.md"]?.deleted).toBe(true); @@ -362,7 +362,7 @@ describe("sync engine", () => { deletedBy: "dev_other", }; - await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 + 30 * 24 * 60 * 60 * 1000 }); + await sync(vault, store, state, 1000 + 30 * 24 * 60 * 60 * 1000); expect(store.manifest.deleted["notes/today.md"]).toBeUndefined(); expect(state.files["notes/today.md"]).toBeUndefined(); @@ -402,7 +402,7 @@ describe("sync engine", () => { deletedBy: "dev_mac", }; - const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 + 30 * 24 * 60 * 60 * 1000 }); + const result = await sync(vault, store, state, 1000 + 30 * 24 * 60 * 60 * 1000); expect(result.uploaded).toBe(0); expect(store.manifest.deleted["notes/today.md"]).toBeUndefined(); @@ -416,13 +416,13 @@ describe("sync engine", () => { const store = new FakeObjectStore(); const state: LocalSyncState = { files: {} }; - await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 }); + await sync(vault, store, state, 1000); const oldVersion = store.manifest.paths["notes/today.md"]?.version; await vault.delete("notes/today.md"); - await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 2000 }); + await sync(vault, store, state, 2000); await vault.write("notes/today.md", new TextEncoder().encode("new")); - const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 3000 }); + const result = await sync(vault, store, state, 3000); expect(result.uploaded).toBe(1); expect(store.manifest.paths["notes/today.md"]?.contentHash).toBe(await hashText("new")); @@ -436,13 +436,13 @@ describe("sync engine", () => { const store = new FakeObjectStore(); const state: LocalSyncState = { files: {} }; - await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 }); + await sync(vault, store, state, 1000); const oldVersion = store.manifest.paths["notes/today.md"]?.version; await vault.delete("notes/today.md"); - await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 2000 }); + await sync(vault, store, state, 2000); await vault.write("notes/today.md", new TextEncoder().encode("same")); - await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 3000 }); + await sync(vault, store, state, 3000); expect(store.manifest.paths["notes/today.md"]?.contentHash).toBe(await hashText("same")); expect(store.manifest.paths["notes/today.md"]?.version).not.toBe(oldVersion); @@ -480,7 +480,7 @@ describe("sync engine", () => { deletedBy: "dev_other", }; - const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 4000 }); + const result = await sync(vault, store, state, 4000); expect(result.conflicts).toBe(1); expect(await vault.readText("notes/today.md")).toBe("local-new"); @@ -512,8 +512,8 @@ describe("sync engine", () => { version: "ver_remote", }; - const firstResult = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 3000 }); - const secondResult = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 4000 }); + const firstResult = await sync(vault, store, state, 3000); + const secondResult = await sync(vault, store, state, 4000); expect(firstResult.conflicts).toBe(1); expect(secondResult.conflicts).toBe(0); @@ -603,7 +603,7 @@ describe("sync engine", () => { version: "ver_remote", }; - const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 2000 }); + const result = await sync(vault, store, state, 2000); expect(result.conflicts).toBe(1); expect(await vault.readText("notes/today.md")).toBe("local"); @@ -704,6 +704,10 @@ class FakeObjectStore implements ObjectStore { } } +function sync(vault: VaultIO, store: ObjectStore, state: LocalSyncState, timestamp: number) { + return syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => timestamp }); +} + async function hashText(value: string): Promise { const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join(""); diff --git a/src/sync/engine.ts b/src/sync/engine.ts index 8913ead..2f2adcf 100644 --- a/src/sync/engine.ts +++ b/src/sync/engine.ts @@ -143,104 +143,7 @@ export async function syncOnce(input: SyncOnceInput): Promise { } try { - await mapLimit(actions, SYNC_CONCURRENCY, async (item) => { - if (item.action === "upload" && item.local) { - await writeBlob(input.store, manifest, item.local.hash, item.local.bytes, item.local.size, input.now()); - manifest.paths[item.path] = { - contentHash: item.local.hash, - size: item.local.size, - updatedAt: input.now(), - updatedBy: input.deviceId, - revision: nextRevision(manifest), - version: createVersion(input.deviceId, input.now(), item.local.hash), - }; - input.state.files[item.path] = syncedState(item.local.hash, item.local, manifest.paths[item.path].version); - result.uploaded += 1; - } - - if (item.action === "download" && item.remote) { - const bytes = await input.store.readObject(blobObjectKey(item.remote.contentHash)); - await input.vault.write(item.path, bytes); - input.state.files[item.path] = syncedState(item.remote.contentHash, { size: bytes.byteLength, mtime: null }, item.remote.version); - result.downloaded += 1; - } - - if (item.action === "conflict" && item.remote) { - const bytes = await input.store.readObject(blobObjectKey(item.remote.contentHash)); - await input.vault.write(createConflictPath(item.path, input.deviceName, input.now()), bytes); - input.state.files[item.path] = { - lastSyncedHash: item.lastSyncedHash, - remoteHash: item.remote.contentHash, - deleted: false, - version: item.remote.version, - localMtime: null, - localSize: null, - }; - result.conflicts += 1; - } - - if (item.action === "conflict" && item.deleted && item.local && !item.remote) { - await input.vault.write(createConflictPath(item.path, input.deviceName, input.now()), item.local.bytes); - await input.vault.delete(item.path); - input.state.files[item.path] = { - lastSyncedHash: item.deleted.previousContentHash, - remoteHash: item.deleted.previousContentHash, - deleted: true, - version: item.deleted.previousVersion, - localMtime: null, - localSize: null, - }; - result.conflicts += 1; - result.deletedLocal += 1; - } - - if (item.action === "mark-remote-deleted") { - const remote = manifest.paths[item.path]; - delete manifest.paths[item.path]; - manifest.deleted[item.path] = { - deletedAt: input.now(), - deletedRevision: nextRevision(manifest), - previousContentHash: item.lastSyncedHash ?? remote?.contentHash ?? null, - previousVersion: item.lastVersion ?? remote?.version ?? null, - deletedBy: input.deviceId, - }; - input.state.files[item.path] = { - lastSyncedHash: item.lastSyncedHash ?? remote?.contentHash ?? null, - remoteHash: item.lastSyncedHash ?? remote?.contentHash ?? null, - deleted: true, - version: item.lastVersion ?? remote?.version ?? null, - localMtime: null, - localSize: null, - }; - result.deletedRemote += 1; - } - - if (item.action === "delete-local" && item.remote) { - await input.vault.delete(item.path); - input.state.files[item.path] = { - lastSyncedHash: item.remote.contentHash, - remoteHash: item.remote.contentHash, - deleted: true, - version: item.remote.version, - localMtime: null, - localSize: null, - }; - result.deletedLocal += 1; - } - - if (item.action === "delete-local" && item.deleted) { - await input.vault.delete(item.path); - input.state.files[item.path] = { - lastSyncedHash: item.deleted.previousContentHash, - remoteHash: item.deleted.previousContentHash, - deleted: true, - version: item.deleted.previousVersion, - localMtime: null, - localSize: null, - }; - result.deletedLocal += 1; - } - }); + await mapLimit(actions, SYNC_CONCURRENCY, (item) => applyAction(item, input, manifest, result)); pruneTombstones(manifest, input.state, expiredTombstones); updateDeviceCheckpoint(manifest, input); @@ -259,6 +162,64 @@ export async function syncOnce(input: SyncOnceInput): Promise { } } +async function applyAction(item: PlannedFile, input: SyncOnceInput, manifest: RemoteManifest, result: SyncResult): Promise { + if (item.action === "upload" && item.local) { + await writeBlob(input.store, manifest, item.local.hash, item.local.bytes, item.local.size, input.now()); + manifest.paths[item.path] = activeEntry(item.local, input.deviceId, input.now(), nextRevision(manifest)); + input.state.files[item.path] = syncedState(item.local.hash, item.local, manifest.paths[item.path].version); + result.uploaded += 1; + return; + } + + if (item.action === "download" && item.remote) { + const bytes = await input.store.readObject(blobObjectKey(item.remote.contentHash)); + await input.vault.write(item.path, bytes); + input.state.files[item.path] = syncedState(item.remote.contentHash, { size: bytes.byteLength, mtime: null }, item.remote.version); + result.downloaded += 1; + return; + } + + if (item.action === "conflict") { + await applyConflict(item, input, result); + return; + } + + if (item.action === "mark-remote-deleted") { + const remote = manifest.paths[item.path]; + delete manifest.paths[item.path]; + manifest.deleted[item.path] = tombstoneEntry(item, remote, input.deviceId, input.now(), nextRevision(manifest)); + input.state.files[item.path] = deletedState(manifest.deleted[item.path].previousContentHash, manifest.deleted[item.path].previousVersion); + result.deletedRemote += 1; + return; + } + + if (item.action === "delete-local") { + await input.vault.delete(item.path); + input.state.files[item.path] = deletedState(item.remote?.contentHash ?? item.deleted?.previousContentHash ?? null, item.remote?.version ?? item.deleted?.previousVersion ?? null); + result.deletedLocal += 1; + } +} + +async function applyConflict(item: PlannedFile, input: SyncOnceInput, result: SyncResult): Promise { + if (item.remote) { + const bytes = await input.store.readObject(blobObjectKey(item.remote.contentHash)); + await input.vault.write(createConflictPath(item.path, input.deviceName, input.now()), bytes); + input.state.files[item.path] = { + ...syncedState(item.lastSyncedHash, { mtime: null, size: null }, item.remote.version), + remoteHash: item.remote.contentHash, + }; + result.conflicts += 1; + } + + if (item.deleted && item.local && !item.remote) { + await input.vault.write(createConflictPath(item.path, input.deviceName, input.now()), item.local.bytes); + await input.vault.delete(item.path); + input.state.files[item.path] = deletedState(item.deleted.previousContentHash, item.deleted.previousVersion); + result.conflicts += 1; + result.deletedLocal += 1; + } +} + export interface ReleaseDeletedContentResult { deletedTombstones: number; deletedBlobs: number; @@ -401,7 +362,28 @@ export function blobObjectKey(hash: string): string { return `blobs/sha256/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash}`; } -function syncedState(hash: string, local: { mtime: number | null; size: number | null }, version: string): LocalFileState { +function activeEntry(local: { hash: string; size: number }, deviceId: string, now: number, revision: number): ManifestFileEntry { + return { + contentHash: local.hash, + size: local.size, + updatedAt: now, + updatedBy: deviceId, + revision, + version: createVersion(deviceId, now, local.hash), + }; +} + +function tombstoneEntry(item: PlannedFile, remote: ManifestFileEntry | undefined, deviceId: string, now: number, revision: number): DeletedPathEntry { + return { + deletedAt: now, + deletedRevision: revision, + previousContentHash: item.lastSyncedHash ?? remote?.contentHash ?? null, + previousVersion: item.lastVersion ?? remote?.version ?? null, + deletedBy: deviceId, + }; +} + +function syncedState(hash: string | null, local: { mtime: number | null; size: number | null }, version: string | null): LocalFileState { return { lastSyncedHash: hash, remoteHash: hash, @@ -412,6 +394,17 @@ function syncedState(hash: string, local: { mtime: number | null; size: number | }; } +function deletedState(hash: string | null, version: string | null): LocalFileState { + return { + lastSyncedHash: hash, + remoteHash: hash, + deleted: true, + version, + localMtime: null, + localSize: null, + }; +} + async function writeBlob(store: ObjectStore, manifest: RemoteManifest, hash: string, bytes: Uint8Array, size: number, now: number): Promise { const key = blobObjectKey(hash);