Release 0.1.4

This commit is contained in:
shi.changliang 2026-06-15 14:53:17 +08:00
parent a2862334d2
commit 71458ddfde
11 changed files with 303 additions and 21 deletions

View file

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

View file

@ -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": {

View file

@ -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<TestSettings> = {}): TestPlugin {
deviceName: "Mac",
syncIntervalMinutes: 5,
autoSync: true,
syncEmptyDirectories: false,
syncState: { files: {} },
...settings,
};
@ -289,7 +363,8 @@ function createPlugin(settings: Partial<TestSettings> = {}): TestPlugin {
type TestPlugin = {
app: { vault: { getName: () => string } };
settings: TestSettings;
getConnectionConfig: () => Record<string, string | number>;
getConnectionConfig: (includeSecrets?: boolean) => Record<string, string | number>;
getEncodedConnectionConfig: (includeSecrets?: boolean) => string;
importConnectionConfig: (text: string) => Promise<void>;
updateSettings: (update: Partial<TestSettings>) => Promise<void>;
saveSettings: ReturnType<typeof vi.fn<() => Promise<void>>>;
@ -316,5 +391,6 @@ interface TestSettings {
deviceName: string;
syncIntervalMinutes: number;
autoSync: boolean;
syncEmptyDirectories: boolean;
syncState: { files: Record<string, unknown> };
}

View file

@ -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<void> {
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<string, unknown>, 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 {

View file

@ -28,7 +28,7 @@ const props = defineProps<{
const emit = defineEmits<{
update: [update: Partial<CohereSettings>];
copyDeviceId: [];
copyConnectionConfig: [includeSecrets: boolean];
copyEncodedConnectionConfig: [includeSecrets: boolean];
pasteConnectionConfig: [];
releaseDeletedContent: [];
}>();
@ -309,8 +309,8 @@ watchEffect(() => {
<pre class="cohere-config-display">{{ JSON.stringify(connectionConfigPreview, null, 2) }}</pre>
<div class="cohere-action-row">
<button type="button" class="cohere-primary" @click="emit('copyConnectionConfig', includeSecrets)">
{{ includeSecrets ? "复制完整配置" : "复制连接配置" }}
<button type="button" class="cohere-primary" @click="emit('copyEncodedConnectionConfig', includeSecrets)">
导出并复制
</button>
<button type="button" class="cohere-secondary" @click="emit('pasteConnectionConfig')">

View file

@ -171,6 +171,26 @@ describe("S3ObjectStore", () => {
expect(requests[0]?.url).toBe("https://cohere-test.s3.example.com/cohere/v1/vaults/vlt_TEST/files/notes/today.md");
});
test("retries transient request failures", async () => {
let attempts = 0;
const store = createStore(async () => {
attempts += 1;
if (attempts === 1) {
throw new Error("net::ERR_CONNECTION_TIMED_OUT");
}
return {
status: 200,
text: "",
arrayBuffer: new ArrayBuffer(0),
};
});
await expect(store.writeObject("files/notes/today.md", new TextEncoder().encode("hello"))).resolves.toBeUndefined();
expect(attempts).toBe(2);
});
});
function createStore(

View file

@ -30,6 +30,9 @@ interface S3ObjectStoreOptions {
export type S3AddressingStyle = "auto" | "path" | "virtual-hosted";
const REQUEST_RETRY_LIMIT = 3;
const REQUEST_RETRY_BASE_DELAY_MS = 500;
export class S3ObjectStore implements ObjectStore {
private options: S3ObjectStoreOptions;
private layout: ReturnType<typeof createRemoteLayout>;
@ -142,13 +145,37 @@ export class S3ObjectStore implements ObjectStore {
timestamp: this.options.now(),
});
return this.options.request({
return this.requestWithRetry({
url,
method,
headers,
body: body ? toArrayBuffer(body) : undefined,
});
}
private async requestWithRetry(request: HttpRequest): Promise<HttpResponse> {
let lastError: unknown;
for (let attempt = 0; attempt <= REQUEST_RETRY_LIMIT; attempt += 1) {
try {
const response = await this.options.request(request);
if (!isRetriableStatus(response.status) || attempt === REQUEST_RETRY_LIMIT) {
return response;
}
} catch (error) {
lastError = error;
if (attempt === REQUEST_RETRY_LIMIT) {
throw error;
}
}
await delay(REQUEST_RETRY_BASE_DELAY_MS * 2 ** attempt);
}
throw lastError instanceof Error ? lastError : new Error(String(lastError));
}
}
function createObjectUrl(endpoint: string, bucket: string, key: string, addressingStyle: S3AddressingStyle, query?: Record<string, string>): string {
@ -188,6 +215,14 @@ function safeHostname(endpoint: string): string {
}
}
function isRetriableStatus(status: number): boolean {
return status === 408 || status === 429 || status >= 500;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => globalThis.setTimeout(resolve, ms));
}
function encodeKey(key: string): string {
return key.split("/").map(encodePathPart).join("/");
}

View file

@ -16,6 +16,31 @@ describe("sync engine", () => {
expect(state.files["notes/today.md"]?.lastSyncedHash).toBe(hash);
});
test("reports progress for changed files", async () => {
const vault = new FakeVault({
"notes/a.md": "a",
"notes/b.md": "b",
});
const store = new FakeObjectStore();
const state: LocalSyncState = { files: {} };
const events: Array<{ completed: number; total: number }> = [];
await syncOnce({
vault,
store,
state,
deviceName: "Mac",
deviceId: "dev_a",
now: () => 1000,
onProgress: (progress) => {
events.push({ completed: progress.completed, total: progress.total });
},
});
expect(events[0]).toEqual({ completed: 0, total: 2 });
expect(events[events.length - 1]).toEqual({ completed: 2, total: 2 });
});
test("does not read unchanged local files after metadata is cached", async () => {
const vault = new FakeVault({ "notes/today.md": "hello" });
const store = new FakeObjectStore();
@ -30,6 +55,23 @@ describe("sync engine", () => {
expect(vault.readCount).toBe(0);
});
test("uploads real bytes when remote state was cleared but local metadata is cached", async () => {
const vault = new FakeVault({ "notes/today.md": "hello" });
const state: LocalSyncState = { files: {} };
await sync(vault, new FakeObjectStore(), state, 1000);
const resetStore = new FakeObjectStore();
vault.readCount = 0;
const result = await sync(vault, resetStore, state, 2000);
const hash = await hashText("hello");
expect(result.uploaded).toBe(1);
expect(vault.readCount).toBe(1);
expect(await resetStore.getText(blobObjectKey(hash))).toBe("hello");
expect(resetStore.manifest.paths["notes/today.md"]?.contentHash).toBe(hash);
});
test("skips lock and manifest write when nothing changed", async () => {
const vault = new FakeVault({ "notes/today.md": "hello" });
const store = new FakeObjectStore();

View file

@ -100,6 +100,13 @@ interface SyncOnceInput {
deviceId: string;
syncEmptyDirectories?: boolean;
now(): number;
onProgress?(progress: SyncProgress): void;
}
export interface SyncProgress {
completed: number;
total: number;
current?: string;
}
interface PlannedFile {
@ -115,7 +122,7 @@ interface PlannedFile {
type SyncAction = "noop" | "upload" | "download" | "conflict" | "mark-remote-deleted" | "delete-local";
type DirectorySyncAction = "noop" | "upload-directory" | "create-local-directory" | "mark-directory-deleted" | "delete-local-directory" | "prune-directory";
const SYNC_CONCURRENCY = 4;
const SYNC_CONCURRENCY = 8;
const TOMBSTONE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
export function createEmptyManifest(): RemoteManifest {
@ -156,6 +163,7 @@ export async function syncOnce(input: SyncOnceInput): Promise<SyncResult> {
directoryActions.some((item) => item.action === "upload-directory" || item.action === "mark-directory-deleted" || item.action === "prune-directory") ||
expiredTombstones.length > 0 ||
expiredDirectoryTombstones.length > 0;
const progress = createProgressReporter(input, actions.length + directoryActions.length + expiredTombstones.length + expiredDirectoryTombstones.length);
if (actions.length === 0 && directoryActions.length === 0 && expiredTombstones.length === 0 && expiredDirectoryTombstones.length === 0) {
updateDeviceCheckpoint(manifest, input);
@ -175,11 +183,16 @@ export async function syncOnce(input: SyncOnceInput): Promise<SyncResult> {
}
try {
await mapLimit(actions, SYNC_CONCURRENCY, (item) => applyAction(item, input, manifest, result));
await applyDirectoryActions(directoryActions, input, manifest);
await mapLimit(actions, SYNC_CONCURRENCY, async (item) => {
await applyAction(item, input, manifest, result);
progress.tick(item.path);
});
await applyDirectoryActions(directoryActions, input, manifest, progress.tick);
pruneTombstones(manifest, input.state, expiredTombstones);
progress.tickMany(expiredTombstones.length, "清理删除记录");
pruneDirectoryTombstones(manifest, input.state, expiredDirectoryTombstones);
progress.tickMany(expiredDirectoryTombstones.length, "清理目录删除记录");
updateDeviceCheckpoint(manifest, input);
input.state.lastSyncAt = input.now();
@ -196,9 +209,31 @@ export async function syncOnce(input: SyncOnceInput): Promise<SyncResult> {
}
}
function createProgressReporter(input: SyncOnceInput, total: number): { tick(current?: string): void; tickMany(count: number, current?: string): void } {
let completed = 0;
input.onProgress?.({ completed, total });
return {
tick(current?: string): void {
completed += 1;
input.onProgress?.({ completed, total, current });
},
tickMany(count: number, current?: string): void {
if (count <= 0) {
return;
}
completed += count;
input.onProgress?.({ completed, total, current });
},
};
}
async function applyAction(item: PlannedFile, input: SyncOnceInput, manifest: RemoteManifest, result: SyncResult): Promise<void> {
if (item.action === "upload" && item.local) {
await writeBlob(input.store, manifest, item.local.hash, item.local.bytes, item.local.size, input.now());
const bytes = await ensureUploadBytes(input.vault, item.path, item.local);
await writeBlob(input.store, manifest, item.local.hash, 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;
@ -254,6 +289,18 @@ async function applyConflict(item: PlannedFile, input: SyncOnceInput, result: Sy
}
}
async function ensureUploadBytes(
vault: VaultIO,
path: string,
local: { bytes: Uint8Array; size: number },
): Promise<Uint8Array> {
if (local.bytes.byteLength > 0 || local.size === 0) {
return local.bytes;
}
return vault.read(path);
}
export interface ReleaseDeletedContentResult {
deletedTombstones: number;
deletedDirectoryTombstones: number;
@ -413,18 +460,21 @@ async function applyDirectoryActions(
actions: PlannedDirectory[],
input: SyncOnceInput,
manifest: RemoteManifest,
onComplete: (path: string) => void,
): Promise<void> {
for (const item of actions) {
if (item.action === "upload-directory") {
delete manifest.deletedDirectories[item.path];
manifest.directories[item.path] = directoryEntry(input.deviceId, input.now(), nextRevision(manifest));
input.state.directories![item.path] = { deleted: false, version: manifest.directories[item.path].version };
onComplete(item.path);
continue;
}
if (item.action === "create-local-directory") {
await input.vault.createDirectory?.(item.path);
input.state.directories![item.path] = { deleted: false, version: item.remote?.version ?? null };
onComplete(item.path);
continue;
}
@ -433,18 +483,21 @@ async function applyDirectoryActions(
delete manifest.directories[item.path];
manifest.deletedDirectories[item.path] = deletedDirectoryEntry(item, remote, input.deviceId, input.now(), nextRevision(manifest));
input.state.directories![item.path] = { deleted: true, version: manifest.deletedDirectories[item.path].previousVersion };
onComplete(item.path);
continue;
}
if (item.action === "delete-local-directory") {
await input.vault.deleteDirectory?.(item.path);
input.state.directories![item.path] = { deleted: true, version: item.deleted?.previousVersion ?? null };
onComplete(item.path);
continue;
}
if (item.action === "prune-directory") {
delete manifest.directories[item.path];
delete input.state.directories![item.path];
onComplete(item.path);
}
}
}

View file

@ -13,6 +13,10 @@ export class ObsidianVaultIO implements VaultIO {
const result: Array<{ path: string; mtime: number; size: number }> = [];
for (const file of files) {
if (this.isIgnoredVaultPath(file.path)) {
continue;
}
result.push({
path: file.path,
mtime: file.stat.mtime,
@ -117,7 +121,12 @@ export class ObsidianVaultIO implements VaultIO {
private isIgnoredVaultPath(path: string): boolean {
const configDir = this.app.vault.configDir;
return path === configDir || path.startsWith(`${configDir}/`) || path === ".trash" || path.startsWith(".trash/");
return path === configDir ||
path.startsWith(`${configDir}/`) ||
path === ".git" ||
path.startsWith(".git/") ||
path === ".trash" ||
path.startsWith(".trash/");
}
}

View file

@ -1,5 +1,6 @@
{
"0.1.1": "1.6.6",
"0.1.2": "1.6.6",
"0.1.3": "1.6.6"
"0.1.3": "1.6.6",
"0.1.4": "1.6.6"
}