diff --git a/manifest.json b/manifest.json index e02c508..6dd21e4 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "cohere", "name": "Cohere", - "version": "0.1.3", + "version": "0.1.4", "minAppVersion": "1.6.6", "description": "Sync vault files through OSS / S3-compatible object storage.", "author": "Chaly", diff --git a/package.json b/package.json index 3ba1625..0f5c7b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cohere", - "version": "0.1.3", + "version": "0.1.4", "description": "Plugin for OSS / S3-compatible vault sync.", "main": "main.js", "scripts": { diff --git a/src/main.test.ts b/src/main.test.ts index 5b7f066..2de0885 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -200,10 +200,68 @@ describe("connection config import and export", () => { expect(plugin.saveSettings).toHaveBeenCalledTimes(1); }); + test("exports and imports encoded connection config", async () => { + const source = createPlugin({ + accessKeyId: "AKIA_TEST", + secretAccessKey: "SECRET_TEST", + rootPrefix: "cohere/v2", + accountKey: "team", + vaultKey: "notes", + }); + const target = createPlugin({ + accessKeyId: "existing-key", + secretAccessKey: "existing-secret", + }); + + const encodedConfig = source.getEncodedConnectionConfig(true); + expect(encodedConfig).toMatch(/^cohere:\/\/config\/v1\//); + + await target.importConnectionConfig(encodedConfig); + + expect(target.settings).toMatchObject({ + endpoint: "https://example.com", + bucket: "vault", + addressingStyle: "auto", + region: "auto", + rootPrefix: "cohere/v2", + accountKey: "team", + vaultKey: "notes", + accessKeyId: "AKIA_TEST", + secretAccessKey: "SECRET_TEST", + }); + expect(target.saveSettings).toHaveBeenCalledTimes(1); + }); + + test("imports encoded connection config without replacing credentials when secrets are excluded", async () => { + const source = createPlugin({ + accessKeyId: "AKIA_TEST", + secretAccessKey: "SECRET_TEST", + rootPrefix: "cohere/v2", + accountKey: "team", + vaultKey: "notes", + }); + const target = createPlugin({ + accessKeyId: "existing-key", + secretAccessKey: "existing-secret", + }); + + await target.importConnectionConfig(source.getEncodedConnectionConfig()); + + expect(target.settings).toMatchObject({ + rootPrefix: "cohere/v2", + accountKey: "team", + vaultKey: "notes", + accessKeyId: "existing-key", + secretAccessKey: "existing-secret", + }); + expect(target.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("cohere://config/v1/%")).rejects.toThrow("连接配置编码串无效。"); await expect(plugin.importConnectionConfig(JSON.stringify({ schemaVersion: 2 }))).rejects.toThrow("连接配置版本不支持。"); await expect(plugin.importConnectionConfig(JSON.stringify({ schemaVersion: 1, endpoint: "x" }))).rejects.toThrow("连接配置缺少 bucket。"); }); @@ -224,6 +282,21 @@ describe("device identity settings", () => { }); describe("vault IO compatibility", () => { + test("skips internal vault paths during file scans", async () => { + const note = Object.assign(new TFile(), { path: "note.md", stat: { mtime: 1, size: 4 } }); + const gitObject = Object.assign(new TFile(), { path: ".git/objects/aa/hash", stat: { mtime: 1, size: 4 } }); + const pluginData = Object.assign(new TFile(), { path: ".obsidian/plugins/cohere/data.json", stat: { mtime: 1, size: 4 } }); + const trashed = Object.assign(new TFile(), { path: ".trash/deleted.md", stat: { mtime: 1, size: 4 } }); + const io = new ObsidianVaultIO({ + vault: { + configDir: ".obsidian", + getFiles: vi.fn(() => [note, gitObject, pluginData, trashed]), + }, + } as never); + + await expect(io.scan()).resolves.toEqual([{ path: "note.md", mtime: 1, size: 4 }]); + }); + 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: [] }); @@ -278,6 +351,7 @@ function createPlugin(settings: Partial = {}): TestPlugin { deviceName: "Mac", syncIntervalMinutes: 5, autoSync: true, + syncEmptyDirectories: false, syncState: { files: {} }, ...settings, }; @@ -289,7 +363,8 @@ function createPlugin(settings: Partial = {}): TestPlugin { type TestPlugin = { app: { vault: { getName: () => string } }; settings: TestSettings; - getConnectionConfig: () => Record; + getConnectionConfig: (includeSecrets?: boolean) => Record; + getEncodedConnectionConfig: (includeSecrets?: boolean) => string; importConnectionConfig: (text: string) => Promise; updateSettings: (update: Partial) => Promise; saveSettings: ReturnType Promise>>; @@ -316,5 +391,6 @@ interface TestSettings { deviceName: string; syncIntervalMinutes: number; autoSync: boolean; + syncEmptyDirectories: boolean; syncState: { files: Record }; } diff --git a/src/main.ts b/src/main.ts index 0d1c0de..54eaf7f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -63,6 +63,7 @@ const DEFAULT_SETTINGS: CohereSettings = { const AUTO_SYNC_DEBOUNCE_MS = 2_000; const FILE_EVENT_SUPPRESSION_MS = 1_000; +const CONNECTION_CONFIG_PREFIX = "cohere://config/v1/"; type SyncTrigger = "manual" | "auto"; @@ -103,8 +104,8 @@ export default class CoherePlugin extends Plugin { id: "copy-connection-config", name: "复制连接配置", callback: async () => { - await navigator.clipboard.writeText(JSON.stringify(this.getConnectionConfig(), null, 2)); - new Notice("Cohere 连接配置已复制。"); + await navigator.clipboard.writeText(this.getEncodedConnectionConfig()); + new Notice("Cohere 连接配置已导出并复制。"); }, }); @@ -145,6 +146,10 @@ export default class CoherePlugin extends Plugin { deviceId: this.settings.deviceId, syncEmptyDirectories: this.settings.syncEmptyDirectories, now: () => Date.now(), + onProgress: (progress) => { + const text = progress.total > 0 ? `同步中 ${progress.completed}/${progress.total}` : "同步中..."; + this.setOperationStatus(text, progress.current); + }, }); await this.saveSettings(); @@ -312,10 +317,10 @@ export default class CoherePlugin extends Plugin { this.activeNotice = new Notice(message); } - private setOperationStatus(text: string): void { + private setOperationStatus(text: string, detail?: string): void { this.statusBarItem?.classList.remove("is-hidden"); this.statusBarItem?.setText(text); - this.statusBarItem?.setAttribute("title", text); + this.statusBarItem?.setAttribute("title", detail ? `${text}:${detail}` : text); } private clearOperationStatus(): void { @@ -434,6 +439,10 @@ export default class CoherePlugin extends Plugin { return config; } + getEncodedConnectionConfig(includeSecrets = false): string { + return `${CONNECTION_CONFIG_PREFIX}${encodeBase64Url(JSON.stringify(this.getConnectionConfig(includeSecrets)))}`; + } + async importConnectionConfig(configText: string): Promise { const config = parseConnectionConfig(configText); @@ -484,9 +493,10 @@ function getDeviceNameSuffix(deviceId: string): string { function parseConnectionConfig(configText: string): ConnectionConfig { let parsed: unknown; + const normalizedText = decodeConnectionConfigText(configText.trim()); try { - parsed = JSON.parse(configText); + parsed = JSON.parse(normalizedText); } catch { throw new Error("连接配置不是有效 JSON。"); } @@ -516,6 +526,42 @@ function parseConnectionConfig(configText: string): ConnectionConfig { return config; } +function decodeConnectionConfigText(configText: string): string { + if (!configText.startsWith(CONNECTION_CONFIG_PREFIX)) { + return configText; + } + + const encoded = configText.slice(CONNECTION_CONFIG_PREFIX.length); + if (!/^[A-Za-z0-9_-]+$/.test(encoded)) { + throw new Error("连接配置编码串无效。"); + } + + try { + return decodeBase64Url(encoded); + } catch { + throw new Error("连接配置编码串无效。"); + } +} + +function encodeBase64Url(text: string): string { + const bytes = new TextEncoder().encode(text); + let binary = ""; + + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +function decodeBase64Url(encoded: string): string { + const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + const binary = atob(padded); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + return new TextDecoder().decode(bytes); +} + function readAddressingStyle(record: Record, key: string, fallback: S3AddressingStyle): S3AddressingStyle { const value = record[key]; @@ -588,9 +634,9 @@ class CohereSettingTab extends PluginSettingTab { await navigator.clipboard.writeText(this.plugin.settings.deviceId); new Notice("设备 ID 已复制。"); }, - onCopyConnectionConfig: async (includeSecrets: boolean) => { - await navigator.clipboard.writeText(JSON.stringify(this.plugin.getConnectionConfig(includeSecrets), null, 2)); - new Notice(includeSecrets ? "完整连接配置已复制。" : "连接配置已复制。"); + onCopyEncodedConnectionConfig: async (includeSecrets: boolean) => { + await navigator.clipboard.writeText(this.plugin.getEncodedConnectionConfig(includeSecrets)); + new Notice(includeSecrets ? "完整连接配置已导出并复制。" : "连接配置已导出并复制。"); }, onPasteConnectionConfig: async () => { try { diff --git a/src/settings/SettingsApp.vue b/src/settings/SettingsApp.vue index e7bf1ce..84e1b91 100644 --- a/src/settings/SettingsApp.vue +++ b/src/settings/SettingsApp.vue @@ -28,7 +28,7 @@ const props = defineProps<{ const emit = defineEmits<{ update: [update: Partial]; copyDeviceId: []; - copyConnectionConfig: [includeSecrets: boolean]; + copyEncodedConnectionConfig: [includeSecrets: boolean]; pasteConnectionConfig: []; releaseDeletedContent: []; }>(); @@ -309,8 +309,8 @@ watchEffect(() => {
{{ JSON.stringify(connectionConfigPreview, null, 2) }}
-