Address Obsidian review API compatibility

This commit is contained in:
ichaly 2026-06-06 18:04:20 +08:00
parent 158967ccc8
commit b453949419
5 changed files with 178 additions and 133 deletions

View file

@ -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",

View file

@ -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<TestSettings> = {}): TestPlugin {
const plugin = Object.create(ObsyncPlugin.prototype) as TestPlugin;
plugin.app = { vault: { getName: () => "ichaly" } };

View file

@ -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<void> {
const loaded = await this.loadData();
const loaded = await this.loadData() as Partial<ObsyncSettings> | null;
this.settings = Object.assign({}, DEFAULT_SETTINGS, loaded);
if (!this.settings.vaultKey) {
@ -529,129 +531,6 @@ function readOptionalString(record: Record<string, unknown>, key: string, fallba
return value.trim() || fallback;
}
class ObsidianVaultIO implements VaultIO {
private app: App;
constructor(app: App) {
this.app = app;
}
async scan(): Promise<Array<{ path: string; mtime: number; size: number }>> {
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<Uint8Array> {
const file = this.app.vault.getFileByPath(path);
if (!file) {
return new Uint8Array();
}
return new Uint8Array(await this.app.vault.readBinary(file));
}
async scanEmptyDirectories(): Promise<string[]> {
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<void> {
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<void> {
const file = this.app.vault.getFileByPath(path);
if (file) {
await this.app.vault.trash(file, false);
}
}
async createDirectory(path: string): Promise<void> {
let current = "";
for (const part of path.split("/")) {
current = current ? `${current}/${part}` : part;
await this.ensureFolderExists(current);
}
}
async deleteDirectory(path: string): Promise<void> {
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<void> {
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<void> {
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<Element> | null = null;

128
src/vault-io.ts Normal file
View file

@ -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<Array<{ path: string; mtime: number; size: number }>> {
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<Uint8Array> {
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<string[]> {
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<void> {
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<void> {
const file = this.app.vault.getAbstractFileByPath(path);
if (file instanceof TFile) {
await this.app.fileManager.trashFile(file);
}
}
async createDirectory(path: string): Promise<void> {
let current = "";
for (const part of path.split("/")) {
current = current ? `${current}/${part}` : part;
await this.ensureFolderExists(current);
}
}
async deleteDirectory(path: string): Promise<void> {
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<void> {
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<void> {
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;
}

View file

@ -1,4 +1,3 @@
{
"0.1.0": "1.5.0"
"0.1.0": "1.6.6"
}