From b453949419fb0301407ef045b9a70afe2f4c0b1d Mon Sep 17 00:00:00 2001 From: ichaly Date: Sat, 6 Jun 2026 18:04:20 +0800 Subject: [PATCH] Address Obsidian review API compatibility --- manifest.json | 2 +- src/main.test.ts | 39 +++++++++++++ src/main.ts | 139 +++-------------------------------------------- src/vault-io.ts | 128 +++++++++++++++++++++++++++++++++++++++++++ versions.json | 3 +- 5 files changed, 178 insertions(+), 133 deletions(-) create mode 100644 src/vault-io.ts diff --git a/manifest.json b/manifest.json index b4428ac..52d7b33 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "id": "obsync", "name": "S3 Vault Sync", "version": "0.1.0", - "minAppVersion": "1.5.0", + "minAppVersion": "1.6.6", "description": "Sync vault files through OSS / S3-compatible object storage.", "author": "Chaly", "authorUrl": "https://github.com/ichaly", diff --git a/src/main.test.ts b/src/main.test.ts index a919dd7..072ccd7 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TFile, TFolder } from "obsidian"; import ObsyncPlugin from "./main"; +import { ObsidianVaultIO } from "./vault-io"; vi.mock("obsidian", () => ({ App: class {}, @@ -218,6 +220,43 @@ describe("device identity settings", () => { }); }); +describe("vault IO compatibility", () => { + test("uses stable vault APIs to find and delete files and folders", async () => { + const file = Object.assign(new TFile(), { path: "note.md" }); + const folder = Object.assign(new TFolder(), { path: "empty", children: [] }); + const getAbstractFileByPath = vi.fn((path: string) => { + if (path === "note.md") return file; + if (path === "empty") return folder; + return null; + }); + const app = { + fileManager: { + trashFile: vi.fn(async () => {}), + }, + vault: { + adapter: { exists: vi.fn(async () => false) }, + createBinary: vi.fn(), + createFolder: vi.fn(), + getAbstractFileByPath, + getAllLoadedFiles: vi.fn(() => [file, folder]), + getFiles: vi.fn(() => [file]), + modifyBinary: vi.fn(), + readBinary: vi.fn(async () => new ArrayBuffer(0)), + }, + }; + const io = new ObsidianVaultIO(app as never); + + await io.read("note.md"); + await io.delete("note.md"); + await io.deleteDirectory("empty"); + + expect(getAbstractFileByPath).toHaveBeenCalledWith("note.md"); + expect(getAbstractFileByPath).toHaveBeenCalledWith("empty"); + expect(app.fileManager.trashFile).toHaveBeenCalledWith(file); + expect(app.fileManager.trashFile).toHaveBeenCalledWith(folder); + }); +}); + function createPlugin(settings: Partial = {}): TestPlugin { const plugin = Object.create(ObsyncPlugin.prototype) as TestPlugin; plugin.app = { vault: { getName: () => "ichaly" } }; diff --git a/src/main.ts b/src/main.ts index 2193525..7db15ca 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,10 +1,11 @@ -import { App, Notice, Platform, Plugin, PluginSettingTab, requestUrl, type TAbstractFile, TFile, TFolder } from "obsidian"; +import { App, Notice, Platform, Plugin, PluginSettingTab, requestUrl, type TAbstractFile, TFile } from "obsidian"; import { createApp, reactive, type App as VueApp } from "vue"; import { createRandomId, createVaultId, normalizeKey } from "./core/ids"; import SettingsApp from "./settings/SettingsApp.vue"; import "./styles.scss"; -import { releaseDeletedContent, syncOnce, type LocalSyncState, type VaultIO } from "./sync/engine"; +import { releaseDeletedContent, syncOnce, type LocalSyncState } from "./sync/engine"; import { S3ObjectStore } from "./store/s3"; +import { ObsidianVaultIO } from "./vault-io"; interface ObsyncSettings { endpoint: string; @@ -165,7 +166,7 @@ export default class ObsyncPlugin extends Plugin { } private moveRibbonIconToBottom(iconEl: HTMLElement): void { - const leftRibbonActions = document.querySelector(".workspace-ribbon.mod-left .side-dock-actions"); + const leftRibbonActions = activeDocument.querySelector(".workspace-ribbon.mod-left .side-dock-actions"); (leftRibbonActions ?? iconEl.parentElement)?.append(iconEl); } @@ -178,8 +179,8 @@ export default class ObsyncPlugin extends Plugin { this.queueAutoSync(); }); - this.registerDomEvent(document, "visibilitychange", () => { - if (!document.hidden) { + this.registerDomEvent(activeDocument, "visibilitychange", () => { + if (!activeDocument.hidden) { this.queueAutoSync(); } }); @@ -332,7 +333,8 @@ export default class ObsyncPlugin extends Plugin { deviceId: this.settings.deviceId, now: () => Date.now(), request: async (request) => { - const { host: _host, ...headers } = request.headers; + const headers = { ...request.headers }; + delete headers.host; const response = await requestUrl({ url: request.url, method: request.method, @@ -359,7 +361,7 @@ export default class ObsyncPlugin extends Plugin { } async loadSettings(): Promise { - const loaded = await this.loadData(); + const loaded = await this.loadData() as Partial | null; this.settings = Object.assign({}, DEFAULT_SETTINGS, loaded); if (!this.settings.vaultKey) { @@ -529,129 +531,6 @@ function readOptionalString(record: Record, key: string, fallba return value.trim() || fallback; } -class ObsidianVaultIO implements VaultIO { - private app: App; - - constructor(app: App) { - this.app = app; - } - - async scan(): Promise> { - const files = this.app.vault.getFiles(); - const result: Array<{ path: string; mtime: number; size: number }> = []; - - for (const file of files) { - result.push({ - path: file.path, - mtime: file.stat.mtime, - size: file.stat.size, - }); - } - - return result; - } - - async read(path: string): Promise { - const file = this.app.vault.getFileByPath(path); - - if (!file) { - return new Uint8Array(); - } - - return new Uint8Array(await this.app.vault.readBinary(file)); - } - - async scanEmptyDirectories(): Promise { - return this.app.vault - .getAllLoadedFiles() - .filter((file): file is TFolder => file instanceof TFolder) - .filter((folder) => folder.path && !isIgnoredVaultPath(folder.path) && folder.children.length === 0) - .map((folder) => folder.path); - } - - async write(path: string, bytes: Uint8Array): Promise { - await this.ensureParentFolder(path); - const file = this.app.vault.getFileByPath(path); - const data = toArrayBuffer(bytes); - - if (file instanceof TFile) { - await this.app.vault.modifyBinary(file, data); - return; - } - - await this.app.vault.createBinary(path, data); - } - - async delete(path: string): Promise { - const file = this.app.vault.getFileByPath(path); - - if (file) { - await this.app.vault.trash(file, false); - } - } - - async createDirectory(path: string): Promise { - let current = ""; - - for (const part of path.split("/")) { - current = current ? `${current}/${part}` : part; - await this.ensureFolderExists(current); - } - } - - async deleteDirectory(path: string): Promise { - const folder = this.app.vault.getFolderByPath(path); - - if (folder instanceof TFolder && folder.children.length === 0) { - await this.app.vault.trash(folder, false); - } - } - - private async ensureParentFolder(path: string): Promise { - const parts = path.split("/").slice(0, -1); - let current = ""; - - for (const part of parts) { - current = current ? `${current}/${part}` : part; - await this.ensureFolderExists(current); - } - } - - private async ensureFolderExists(path: string): Promise { - if (this.app.vault.getFolderByPath(path) instanceof TFolder) { - return; - } - - if (this.app.vault.getFileByPath(path) instanceof TFile) { - throw new Error(`Path exists as a file: ${path}`); - } - - if (await this.app.vault.adapter.exists(path)) { - return; - } - - try { - await this.app.vault.createFolder(path); - } catch (error) { - if (await this.app.vault.adapter.exists(path)) { - return; - } - - throw error; - } - } -} - -function isIgnoredVaultPath(path: string): boolean { - return path === ".obsidian" || path.startsWith(".obsidian/") || path === ".trash" || path.startsWith(".trash/"); -} - -function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { - const copy = new Uint8Array(bytes.byteLength); - copy.set(bytes); - return copy.buffer; -} - class ObsyncSettingTab extends PluginSettingTab { plugin: ObsyncPlugin; vueApp: VueApp | null = null; diff --git a/src/vault-io.ts b/src/vault-io.ts new file mode 100644 index 0000000..7a945e4 --- /dev/null +++ b/src/vault-io.ts @@ -0,0 +1,128 @@ +import { type App, TFile, TFolder } from "obsidian"; +import type { VaultIO } from "./sync/engine"; + +export class ObsidianVaultIO implements VaultIO { + private app: App; + + constructor(app: App) { + this.app = app; + } + + async scan(): Promise> { + const files = this.app.vault.getFiles(); + const result: Array<{ path: string; mtime: number; size: number }> = []; + + for (const file of files) { + result.push({ + path: file.path, + mtime: file.stat.mtime, + size: file.stat.size, + }); + } + + return result; + } + + async read(path: string): Promise { + const file = this.app.vault.getAbstractFileByPath(path); + + if (!(file instanceof TFile)) { + return new Uint8Array(); + } + + return new Uint8Array(await this.app.vault.readBinary(file)); + } + + async scanEmptyDirectories(): Promise { + return this.app.vault + .getAllLoadedFiles() + .filter((file): file is TFolder => file instanceof TFolder) + .filter((folder) => folder.path && !this.isIgnoredVaultPath(folder.path) && folder.children.length === 0) + .map((folder) => folder.path); + } + + async write(path: string, bytes: Uint8Array): Promise { + await this.ensureParentFolder(path); + const file = this.app.vault.getAbstractFileByPath(path); + const data = toArrayBuffer(bytes); + + if (file instanceof TFile) { + await this.app.vault.modifyBinary(file, data); + return; + } + + await this.app.vault.createBinary(path, data); + } + + async delete(path: string): Promise { + const file = this.app.vault.getAbstractFileByPath(path); + + if (file instanceof TFile) { + await this.app.fileManager.trashFile(file); + } + } + + async createDirectory(path: string): Promise { + let current = ""; + + for (const part of path.split("/")) { + current = current ? `${current}/${part}` : part; + await this.ensureFolderExists(current); + } + } + + async deleteDirectory(path: string): Promise { + const folder = this.app.vault.getAbstractFileByPath(path); + + if (folder instanceof TFolder && folder.children.length === 0) { + await this.app.fileManager.trashFile(folder); + } + } + + private async ensureParentFolder(path: string): Promise { + const parts = path.split("/").slice(0, -1); + let current = ""; + + for (const part of parts) { + current = current ? `${current}/${part}` : part; + await this.ensureFolderExists(current); + } + } + + private async ensureFolderExists(path: string): Promise { + const existing = this.app.vault.getAbstractFileByPath(path); + + if (existing instanceof TFolder) { + return; + } + + if (existing instanceof TFile) { + throw new Error(`Path exists as a file: ${path}`); + } + + if (await this.app.vault.adapter.exists(path)) { + return; + } + + try { + await this.app.vault.createFolder(path); + } catch (error) { + if (await this.app.vault.adapter.exists(path)) { + return; + } + + throw error; + } + } + + private isIgnoredVaultPath(path: string): boolean { + const configDir = this.app.vault.configDir || ".obsidian"; + return path === configDir || path.startsWith(`${configDir}/`) || path === ".trash" || path.startsWith(".trash/"); + } +} + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy.buffer; +} diff --git a/versions.json b/versions.json index 300f36f..85fb989 100644 --- a/versions.json +++ b/versions.json @@ -1,4 +1,3 @@ { - "0.1.0": "1.5.0" + "0.1.0": "1.6.6" } -