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: true, syncIntervalSeconds: 120, syncHiddenFiles: false, conflictPolicy: "newest", repositoryRawUrl: "https://nnootteess.dinlo.ru/dimon/simple-couchdb-sync/raw/branch/main", settingsVersion: 2 }; 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", lastStats: null }; 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.statusBarEl = this.addStatusBarItem(); this.updateStatusBar(); 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.addCommand({ id: "update-plugin-from-gitea", name: "Update plugin from Gitea", callback: () => this.updatePluginFromGitea() }); this.addSettingTab(new SimpleCouchDbSyncSettingTab(this.app, this)); this.registerVaultEvents(); this.configureLiveSync(); if (this.settings.liveSync) { window.setTimeout(() => this.syncNow("startup").catch((error) => this.showError("Startup sync failed", error)), 5000); } } 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) { const settings = Object.assign({}, DEFAULT_SETTINGS, raw.settings || {}); if (!settings.settingsVersion || settings.settingsVersion < 2) { settings.liveSync = true; settings.settingsVersion = 2; } return { settings, state: Object.assign({ knownFiles: {}, deviceId: raw.settings && raw.settings.deviceId }, raw.state || {}) }; } const settings = Object.assign({}, DEFAULT_SETTINGS, raw); if (!settings.settingsVersion || settings.settingsVersion < 2) { settings.liveSync = true; settings.settingsVersion = 2; } return { settings, 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 (${source})...`; this.updateStatusBar(); try { await this.client.reload(this.settings); await this.client.ensureDatabase(); await this.client.ensureDesignDocument(); const localFiles = this.getSyncableFiles().length; const pulled = source === "push" ? 0 : await this.pullRemoteChanges(); const pushed = await this.pushPendingLocalChanges(source === "push"); const pulledAfterPush = pushed > 0 ? await this.pullRemoteChanges() : 0; this.syncState.lastSyncFinishedAt = new Date().toISOString(); this.syncState.lastStats = { source, localFiles, pushed, pulled: pulled + pulledAfterPush }; this.syncState.lastMessage = `Synced ${formatTime(this.syncState.lastSyncFinishedAt)}. Files ${localFiles}, pushed ${pushed}, pulled ${pulled + pulledAfterPush}.`; await this.savePluginData(); if (source === "manual" || pushed > 0 || pulled > 0) { new Notice(this.syncState.lastMessage); } } catch (error) { this.syncState.lastMessage = error.message || String(error); this.showError("CouchDB sync failed", error); } finally { this.syncInProgress = false; this.updateStatusBar(); } } 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("push"); } async pushPendingLocalChanges(forceAll) { let pushed = 0; const filesByPath = new Map(this.getSyncableFiles().map((file) => [file.path, file])); if (forceAll) { this.syncQueue.clear(); for (const file of filesByPath.values()) { this.syncQueue.set(file.path, { kind: "modify", path: file.path }); } } else 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 updatePluginFromGitea() { if (this.updateInProgress) { new Notice("Plugin update is already running"); return; } this.updateInProgress = true; this.syncState.lastMessage = "Checking plugin update..."; this.updateStatusBar(); try { const updater = new GiteaPluginUpdater(this); const result = await updater.update(); this.syncState.lastMessage = result.message; this.updateStatusBar(); new Notice(result.message, 10000); if (result.updated) { new Notice("Restart Obsidian or disable and enable the plugin to load the new version.", 12000); } } catch (error) { this.syncState.lastMessage = error.message || String(error); this.showError("Plugin update failed", error); } finally { this.updateInProgress = false; this.updateStatusBar(); } } updateStatusBar() { if (!this.statusBarEl) return; const live = this.settings && this.settings.liveSync ? "live on" : "live off"; const message = this.syncState ? this.syncState.lastMessage : "Loading"; this.statusBarEl.setText(`CouchDB Sync: ${live} | ${message}`); } 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 GiteaPluginUpdater { constructor(plugin) { this.plugin = plugin; this.rawUrl = trimRight(plugin.settings.repositoryRawUrl || DEFAULT_SETTINGS.repositoryRawUrl, "/"); } async update() { if (!this.rawUrl) throw new Error("Gitea raw repository URL is empty"); const remoteManifest = await this.fetchText("manifest.json"); const manifest = JSON.parse(remoteManifest); const currentVersion = this.plugin.manifest.version || "0.0.0"; const remoteVersion = manifest.version || "0.0.0"; if (compareVersions(remoteVersion, currentVersion) <= 0) { return { updated: false, message: `Plugin is already up to date (${currentVersion}).` }; } const files = { "manifest.json": remoteManifest, "main.js": await this.fetchText("main.js"), "styles.css": await this.fetchText("styles.css") }; for (const fileName of Object.keys(files)) { await this.plugin.app.vault.adapter.write(this.plugin.manifest.dir + "/" + fileName, files[fileName]); } return { updated: true, message: `Plugin updated from ${currentVersion} to ${remoteVersion}.` }; } async fetchText(fileName) { const response = await requestUrl({ url: `${this.rawUrl}/${fileName}`, method: "GET", throw: false }); if (response.status >= 400) { throw new Error(`Could not download ${fileName}: HTTP ${response.status}`); } return response.text; } } 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 pushes local changes and pulls remote changes on every device.") .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("How often this device checks CouchDB for changes made on other devices. 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.createEl("h3", { text: "Plugin updates" }); new Setting(containerEl) .setName("Gitea raw URL") .setDesc("Raw branch URL used by the one-button updater.") .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.repositoryRawUrl) .setValue(this.plugin.settings.repositoryRawUrl) .onChange(async (value) => { this.plugin.settings.repositoryRawUrl = trimRight(value.trim(), "/"); await this.plugin.saveSettings(); })); new Setting(containerEl) .setName("Update from Gitea") .setDesc("Downloads manifest.json, main.js, and styles.css from Gitea. Restart Obsidian after updating.") .addButton((button) => button .setButtonText("Check and update") .setCta() .onClick(() => this.plugin.updatePluginFromGitea())); containerEl.createDiv({ cls: "simple-couchdb-sync-status", text: `Status: ${this.plugin.syncState.lastMessage}` }); containerEl.createDiv({ cls: "simple-couchdb-sync-status", text: `Live sync: ${this.plugin.settings.liveSync ? "enabled" : "disabled"}, interval ${this.plugin.settings.syncIntervalSeconds}s` }); if (this.plugin.syncState.lastStats) { const stats = this.plugin.syncState.lastStats; containerEl.createDiv({ cls: "simple-couchdb-sync-status", text: `Last sync: ${stats.source}, files ${stats.localFiles}, pushed ${stats.pushed}, pulled ${stats.pulled}` }); } containerEl.createDiv({ cls: "simple-couchdb-sync-status", text: `Plugin version: ${this.plugin.manifest.version}` }); 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(); } function formatTime(iso) { if (!iso) return "never"; return new Date(iso).toLocaleString(); } function compareVersions(left, right) { const leftParts = String(left).split(".").map((part) => Number(part) || 0); const rightParts = String(right).split(".").map((part) => Number(part) || 0); const length = Math.max(leftParts.length, rightParts.length); for (let i = 0; i < length; i++) { const diff = (leftParts[i] || 0) - (rightParts[i] || 0); if (diff > 0) return 1; if (diff < 0) return -1; } return 0; }