const { Notice, Plugin, PluginSettingTab, Setting, TFile, requestUrl, normalizePath } = require("obsidian"); const DEFAULT_SETTINGS = { serverUrl: "https://obsidian.dinlo.ru", database: "obsidian-test", username: "test", password: "testpassword", liveSync: false, syncIntervalSeconds: 120, syncHiddenFiles: false, conflictPolicy: "newest" }; const PLUGIN_ID = "simple-couchdb-sync"; const DOC_PREFIX = "file:"; const DESIGN_DOC_ID = "_design/simple-couchdb-sync"; const MIN_INTERVAL_SECONDS = 30; module.exports = class SimpleCouchDbSyncPlugin extends Plugin { async onload() { const data = await this.loadPluginData(); this.settings = data.settings; this.syncState = { knownFiles: data.state.knownFiles || {}, deviceId: data.state.deviceId || this.settings.deviceId || makeId(), lastSyncStartedAt: null, lastSyncFinishedAt: null, lastMessage: "Not synced yet" }; if (!this.settings.deviceId) { this.settings.deviceId = this.syncState.deviceId; await this.saveSettings(); } this.syncQueue = new Map(); this.syncInProgress = false; this.ignoreVaultEventsUntil = 0; this.client = new CouchDbClient(this.settings); this.addRibbonIcon("refresh-cw", "Sync with CouchDB", () => this.syncNow("manual")); this.addCommand({ id: "sync-now", name: "Sync vault with CouchDB", callback: () => this.syncNow("manual") }); this.addCommand({ id: "push-local-vault", name: "Push local vault to CouchDB", callback: () => this.pushLocalVault() }); this.addSettingTab(new SimpleCouchDbSyncSettingTab(this.app, this)); this.registerVaultEvents(); this.configureLiveSync(); } async saveSettings() { await this.savePluginData(); this.client = new CouchDbClient(this.settings); this.configureLiveSync(); } async loadPluginData() { const raw = await this.loadData(); if (!raw) { return { settings: Object.assign({}, DEFAULT_SETTINGS), state: { knownFiles: {}, deviceId: makeId() } }; } if (raw.settings || raw.state) { return { settings: Object.assign({}, DEFAULT_SETTINGS, raw.settings || {}), state: Object.assign({ knownFiles: {}, deviceId: raw.settings && raw.settings.deviceId }, raw.state || {}) }; } return { settings: Object.assign({}, DEFAULT_SETTINGS, raw), state: { knownFiles: raw.knownFiles || {}, deviceId: raw.deviceId || makeId() } }; } async savePluginData() { await this.saveData({ settings: this.settings, state: { knownFiles: this.syncState.knownFiles, deviceId: this.syncState.deviceId } }); } registerVaultEvents() { this.registerEvent(this.app.vault.on("create", (file) => this.handleLocalChange("create", file))); this.registerEvent(this.app.vault.on("modify", (file) => this.handleLocalChange("modify", file))); this.registerEvent(this.app.vault.on("delete", (file) => this.handleLocalChange("delete", file))); this.registerEvent(this.app.vault.on("rename", (file, oldPath) => this.handleRename(file, oldPath))); } configureLiveSync() { if (this.liveIntervalId) { window.clearInterval(this.liveIntervalId); this.liveIntervalId = null; } if (!this.settings.liveSync) return; const seconds = Math.max(MIN_INTERVAL_SECONDS, Number(this.settings.syncIntervalSeconds) || DEFAULT_SETTINGS.syncIntervalSeconds); this.liveIntervalId = window.setInterval(() => { this.syncNow("live").catch((error) => this.showError("Live sync failed", error)); }, seconds * 1000); this.registerInterval(this.liveIntervalId); } async handleLocalChange(kind, file) { if (!this.settings.liveSync || Date.now() < this.ignoreVaultEventsUntil) return; if (!this.shouldSyncAbstractFile(file)) return; this.syncQueue.set(file.path, { kind, path: file.path }); this.debouncedLiveSync(); } async handleRename(file, oldPath) { if (!this.settings.liveSync || Date.now() < this.ignoreVaultEventsUntil) return; if (oldPath && this.shouldSyncPath(oldPath)) { this.syncQueue.set(oldPath, { kind: "delete", path: oldPath }); } if (this.shouldSyncAbstractFile(file)) { this.syncQueue.set(file.path, { kind: "create", path: file.path }); } this.debouncedLiveSync(); } debouncedLiveSync() { if (this.liveSyncTimeoutId) { window.clearTimeout(this.liveSyncTimeoutId); } this.liveSyncTimeoutId = window.setTimeout(() => { this.syncNow("live").catch((error) => this.showError("Live sync failed", error)); }, 1500); } async syncNow(source) { if (this.syncInProgress) { new Notice("CouchDB sync is already running"); return; } this.syncInProgress = true; this.syncState.lastSyncStartedAt = new Date().toISOString(); this.syncState.lastMessage = "Syncing..."; try { await this.client.reload(this.settings); await this.client.ensureDatabase(); await this.client.ensureDesignDocument(); const pushed = await this.pushPendingLocalChanges(); const pulled = await this.pullRemoteChanges(); this.syncState.lastSyncFinishedAt = new Date().toISOString(); this.syncState.lastMessage = `Synced. Pushed ${pushed}, pulled ${pulled}.`; await this.savePluginData(); new Notice(source === "manual" ? this.syncState.lastMessage : "CouchDB live sync completed"); } catch (error) { this.syncState.lastMessage = error.message || String(error); this.showError("CouchDB sync failed", error); } finally { this.syncInProgress = false; } } async pushLocalVault() { if (this.syncInProgress) { new Notice("CouchDB sync is already running"); return; } this.syncQueue.clear(); for (const file of this.getSyncableFiles()) { this.syncQueue.set(file.path, { kind: "modify", path: file.path }); } await this.syncNow("manual"); } async pushPendingLocalChanges() { let pushed = 0; const filesByPath = new Map(this.getSyncableFiles().map((file) => [file.path, file])); if (this.syncQueue.size === 0) { for (const file of filesByPath.values()) { const localDoc = await this.buildFileDocument(file); const known = this.syncState.knownFiles[file.path]; if (!known || known.hash !== localDoc.hash || known.deleted) { this.syncQueue.set(file.path, { kind: "modify", path: file.path }); } } for (const knownPath of Object.keys(this.syncState.knownFiles)) { if (!filesByPath.has(knownPath) && this.shouldSyncPath(knownPath)) { this.syncQueue.set(knownPath, { kind: "delete", path: knownPath }); } } } const pending = Array.from(this.syncQueue.values()); this.syncQueue.clear(); for (const change of pending) { const file = filesByPath.get(change.path); if (change.kind === "delete" || !file) { await this.putTombstone(change.path); pushed++; continue; } const doc = await this.buildFileDocument(file); await this.putFileDocument(doc); pushed++; } return pushed; } async putFileDocument(doc) { const existing = await this.client.getDocument(doc._id); if (existing && existing._rev) doc._rev = existing._rev; const response = await this.client.putDocument(doc._id, doc); this.syncState.knownFiles[doc.path] = { rev: response.rev, hash: doc.hash, updatedAt: doc.updatedAt, deleted: false }; } async putTombstone(path) { const id = docIdForPath(path); const existing = await this.client.getDocument(id); const tombstone = { _id: id, type: "obsidian-file", path, deleted: true, hash: "", mtime: 0, size: 0, contentType: "text", data: "", updatedAt: new Date().toISOString(), deviceId: this.syncState.deviceId }; if (existing && existing._rev) tombstone._rev = existing._rev; const response = await this.client.putDocument(id, tombstone); this.syncState.knownFiles[path] = { rev: response.rev, hash: "", updatedAt: tombstone.updatedAt, deleted: true }; } async pullRemoteChanges() { const docs = await this.client.getAllFileDocuments(); let pulled = 0; for (const remote of docs) { if (!remote || !remote.path || remote.type !== "obsidian-file") continue; if (!this.shouldSyncPath(remote.path)) continue; const known = this.syncState.knownFiles[remote.path]; if (known && known.rev === remote._rev) continue; if (remote.deviceId === this.syncState.deviceId && known && known.updatedAt === remote.updatedAt) continue; const localFile = this.app.vault.getFileByPath(remote.path); if (remote.deleted) { if (localFile) { await this.withIgnoredVaultEvents(() => this.app.vault.delete(localFile)); } this.syncState.knownFiles[remote.path] = { rev: remote._rev, hash: "", updatedAt: remote.updatedAt, deleted: true }; pulled++; continue; } if (localFile) { const localDoc = await this.buildFileDocument(localFile); if (known && known.hash !== localDoc.hash && isNewer(localDoc.updatedAt, remote.updatedAt)) { await this.putFileDocument(localDoc); continue; } } await this.applyRemoteFile(remote, localFile); this.syncState.knownFiles[remote.path] = { rev: remote._rev, hash: remote.hash, updatedAt: remote.updatedAt, deleted: false }; pulled++; } return pulled; } async applyRemoteFile(remote, localFile) { await this.ensureParentFolders(remote.path); const data = remote.contentType === "binary" ? base64ToArrayBuffer(remote.data || "") : remote.data || ""; await this.withIgnoredVaultEvents(async () => { if (localFile) { if (remote.contentType === "binary") { await this.app.vault.modifyBinary(localFile, data); } else { await this.app.vault.modify(localFile, data); } } else if (remote.contentType === "binary") { await this.app.vault.createBinary(remote.path, data); } else { await this.app.vault.create(remote.path, data); } }); } async ensureParentFolders(path) { const parts = normalizePath(path).split("/"); parts.pop(); let current = ""; for (const part of parts) { current = current ? `${current}/${part}` : part; if (!this.app.vault.getFolderByPath(current)) { await this.app.vault.createFolder(current); } } } async buildFileDocument(file) { const isText = isTextFile(file); const raw = isText ? await this.app.vault.read(file) : await this.app.vault.readBinary(file); const data = isText ? raw : arrayBufferToBase64(raw); const hash = await sha256(`${file.path}\n${data}`); return { _id: docIdForPath(file.path), type: "obsidian-file", path: file.path, deleted: false, contentType: isText ? "text" : "binary", data, hash, mtime: file.stat.mtime, size: file.stat.size, updatedAt: new Date(file.stat.mtime || Date.now()).toISOString(), deviceId: this.syncState.deviceId }; } getSyncableFiles() { const files = []; this.collectSyncableFiles(this.app.vault.getRoot(), files); return files; } collectSyncableFiles(node, files) { if (!node) return; if (this.shouldSyncAbstractFile(node)) { files.push(node); return; } if (!node.children) return; for (const child of node.children) { this.collectSyncableFiles(child, files); } } shouldSyncAbstractFile(file) { return file instanceof TFile && this.shouldSyncPath(file.path); } shouldSyncPath(path) { const normalized = normalizePath(path); if (!this.settings.syncHiddenFiles && normalized.startsWith(".")) return false; if (normalized.startsWith(`.obsidian/plugins/${PLUGIN_ID}/`)) return false; if (normalized === `.obsidian/plugins/${PLUGIN_ID}/data.json`) return false; return true; } async withIgnoredVaultEvents(fn) { this.ignoreVaultEventsUntil = Date.now() + 5000; try { return await fn(); } finally { this.ignoreVaultEventsUntil = Date.now() + 500; } } showError(prefix, error) { const message = error && error.message ? error.message : String(error); console.error(prefix, error); new Notice(`${prefix}: ${message}`, 8000); } }; class CouchDbClient { constructor(settings) { this.reload(settings); } async reload(settings) { this.settings = settings; this.serverUrl = trimRight(settings.serverUrl || "", "/"); this.database = encodeURIComponent(settings.database || ""); this.authHeader = makeAuthHeader(settings.username || "", settings.password || ""); } async ensureDatabase() { const existing = await this.request({ url: this.dbUrl(), method: "GET", throw: false }); if (existing.status === 200) return; if (existing.status !== 404) throw new Error(`CouchDB database check failed: HTTP ${existing.status}`); const created = await this.request({ url: this.dbUrl(), method: "PUT", throw: false }); if (created.status >= 400 && created.status !== 412) { throw new Error(`Could not create database: HTTP ${created.status} ${created.text}`); } } async ensureDesignDocument() { const existing = await this.getDocument(DESIGN_DOC_ID); const designDoc = { _id: DESIGN_DOC_ID, language: "javascript", views: { files: { map: "function (doc) { if (doc.type === 'obsidian-file' && doc.path) emit(doc.path, null); }" } } }; if (existing && existing._rev) designDoc._rev = existing._rev; await this.putDocument(DESIGN_DOC_ID, designDoc); } async getAllFileDocuments() { const response = await this.requestJson({ url: `${this.dbUrl()}/_design/simple-couchdb-sync/_view/files?include_docs=true`, method: "GET" }); return (response.rows || []).map((row) => row.doc).filter(Boolean); } async getDocument(id) { const response = await this.request({ url: `${this.dbUrl()}/${encodeDocId(id)}`, method: "GET", throw: false }); if (response.status === 404) return null; if (response.status >= 400) throw new Error(`CouchDB GET ${id} failed: HTTP ${response.status}`); return response.json; } async putDocument(id, doc) { return await this.requestJson({ url: `${this.dbUrl()}/${encodeDocId(id)}`, method: "PUT", contentType: "application/json", body: JSON.stringify(doc) }); } async requestJson(options) { const response = await this.request(options); if (response.status >= 400) { throw new Error(`CouchDB request failed: HTTP ${response.status} ${response.text}`); } return response.json; } async request(options) { if (!this.serverUrl) throw new Error("CouchDB server URL is empty"); if (!this.database) throw new Error("CouchDB database is empty"); const headers = Object.assign({}, options.headers || {}); if (this.authHeader) headers.Authorization = this.authHeader; return await requestUrl(Object.assign({}, options, { headers })); } dbUrl() { return `${this.serverUrl}/${this.database}`; } } class SimpleCouchDbSyncSettingTab extends PluginSettingTab { constructor(app, plugin) { super(app, plugin); this.plugin = plugin; } display() { const { containerEl } = this; containerEl.empty(); containerEl.createEl("h2", { text: "Simple CouchDB Sync" }); new Setting(containerEl) .setName("CouchDB server") .setDesc("Use a full URL, for example https://obsidian.dinlo.ru.") .addText((text) => text .setPlaceholder("https://example.com") .setValue(this.plugin.settings.serverUrl) .onChange(async (value) => { this.plugin.settings.serverUrl = trimRight(value.trim(), "/"); await this.plugin.saveSettings(); })); new Setting(containerEl) .setName("Database") .addText((text) => text .setPlaceholder("obsidian-test") .setValue(this.plugin.settings.database) .onChange(async (value) => { this.plugin.settings.database = value.trim(); await this.plugin.saveSettings(); })); new Setting(containerEl) .setName("Username") .addText((text) => text .setPlaceholder("test") .setValue(this.plugin.settings.username) .onChange(async (value) => { this.plugin.settings.username = value; await this.plugin.saveSettings(); })); new Setting(containerEl) .setName("Password") .addText((text) => { text.inputEl.type = "password"; text .setPlaceholder("Password") .setValue(this.plugin.settings.password) .onChange(async (value) => { this.plugin.settings.password = value; await this.plugin.saveSettings(); }); }); new Setting(containerEl) .setName("Live sync") .setDesc("Automatically sync after vault changes and on the interval below.") .addToggle((toggle) => toggle .setValue(this.plugin.settings.liveSync) .onChange(async (value) => { this.plugin.settings.liveSync = value; await this.plugin.saveSettings(); })); new Setting(containerEl) .setName("Live sync interval") .setDesc("Minimum 30 seconds.") .addText((text) => text .setPlaceholder("120") .setValue(String(this.plugin.settings.syncIntervalSeconds)) .onChange(async (value) => { const seconds = Math.max(MIN_INTERVAL_SECONDS, Number(value) || DEFAULT_SETTINGS.syncIntervalSeconds); this.plugin.settings.syncIntervalSeconds = seconds; await this.plugin.saveSettings(); })); new Setting(containerEl) .setName("Sync hidden files") .setDesc("Includes dot folders such as .obsidian, except this plugin's own data.") .addToggle((toggle) => toggle .setValue(this.plugin.settings.syncHiddenFiles) .onChange(async (value) => { this.plugin.settings.syncHiddenFiles = value; await this.plugin.saveSettings(); })); new Setting(containerEl) .setName("Sync now") .addButton((button) => button .setButtonText("Sync") .setCta() .onClick(() => this.plugin.syncNow("manual"))); new Setting(containerEl) .setName("Push local vault") .setDesc("Queues every local file for upload before pulling remote changes.") .addButton((button) => button .setButtonText("Push") .onClick(() => this.plugin.pushLocalVault())); containerEl.createDiv({ cls: "simple-couchdb-sync-status", text: `Status: ${this.plugin.syncState.lastMessage}` }); containerEl.createDiv({ cls: "simple-couchdb-sync-status", text: `Device id: ${this.plugin.syncState.deviceId}` }); } } function docIdForPath(path) { return `${DOC_PREFIX}${base64UrlEncode(path)}`; } function encodeDocId(id) { return id.split("/").map(encodeURIComponent).join("/"); } function base64UrlEncode(value) { return bytesToBase64(new TextEncoder().encode(value)) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/g, ""); } function arrayBufferToBase64(buffer) { return bytesToBase64(new Uint8Array(buffer)); } function bytesToBase64(bytes) { let binary = ""; const chunkSize = 0x8000; for (let i = 0; i < bytes.length; i += chunkSize) { binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize)); } return btoa(binary); } function base64ToArrayBuffer(base64) { const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes.buffer; } async function sha256(value) { const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, "0")).join(""); } function isTextFile(file) { const ext = (file.extension || "").toLowerCase(); return [ "md", "txt", "json", "canvas", "css", "js", "ts", "html", "csv", "yml", "yaml", "xml" ].includes(ext); } function trimRight(value, char) { let result = value || ""; while (result.endsWith(char)) result = result.slice(0, -1); return result; } function makeAuthHeader(username, password) { if (!username && !password) return ""; return `Basic ${btoa(`${username}:${password}`)}`; } function makeId() { return `device-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } function isNewer(leftIso, rightIso) { return new Date(leftIso || 0).getTime() > new Date(rightIso || 0).getTime(); }