From e5fd9a612703b1372da38844ef5f9de01d1dc86f Mon Sep 17 00:00:00 2001 From: dinlo Date: Sat, 20 Jun 2026 10:24:42 +0800 Subject: [PATCH] Add live sync visibility and Gitea updater --- README.md | 29 ++++++- main.js | 205 ++++++++++++++++++++++++++++++++++++++++++++++---- manifest.json | 2 +- package.json | 2 +- 4 files changed, 219 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 9a6d44c..064a6f9 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,12 @@ Simple CouchDB Sync is an Obsidian plugin that synchronizes vault files through ## Features - Manual synchronization command and ribbon action. -- Optional live synchronization for file create, modify, delete, and rename events. +- Live synchronization for file create, modify, delete, rename events, and periodic remote pulls. +- Visible Obsidian status bar text with the latest sync result. - Syncs Markdown notes and binary attachments. - Uses Obsidian `requestUrl`, so it can work on desktop and mobile, including Android. - Stores CouchDB connection settings inside the plugin settings tab. +- One-button plugin update from the Gitea repository. ## Default Test Configuration @@ -44,6 +46,7 @@ styles.css 7. Enable `Simple CouchDB Sync`. 8. Open the plugin settings and verify the CouchDB server, database, username, and password. 9. Press `Sync` in the plugin settings, or use the command `Sync vault with CouchDB`. +10. Keep `Live sync` enabled on every device that should receive remote changes automatically. For your test vault, the target folder is: @@ -94,12 +97,25 @@ styles.css 7. Go to Settings -> Community plugins and enable `Simple CouchDB Sync`. 8. Open the plugin settings and enter the same CouchDB connection settings as on desktop. 9. Run `Sync vault with CouchDB`. +10. Keep `Live sync` enabled if the phone should pull changes from CouchDB automatically. For easier Android updates, install an Android Git client such as Termux or a file manager with Git support, then clone the repository into the same plugin folder. After updating files, restart Obsidian so it reloads `main.js`. ## Updating The Plugin -When the repository changes, update these three files in the plugin folder: +The plugin includes a one-button updater. + +1. Open Obsidian Settings -> Community plugins -> Simple CouchDB Sync. +2. In `Plugin updates`, keep the Gitea raw URL as: + +```text +https://nnootteess.dinlo.ru/dimon/simple-couchdb-sync/raw/branch/main +``` + +3. Press `Check and update`. +4. Restart Obsidian, or disable and re-enable the plugin. + +Manual update is also possible. When the repository changes, update these three files in the plugin folder: ```text manifest.json @@ -109,6 +125,15 @@ styles.css Then restart Obsidian or disable and re-enable the plugin. +## Live Sync Behavior + +Live sync must be enabled on every device where automatic updates are expected. + +- When a local file changes, the plugin queues it and syncs after a short delay. +- On the configured interval, the plugin checks CouchDB for changes created on other devices. +- The status bar shows the latest result, including pushed and pulled file counts. +- If live sync is disabled, use the `Sync` button manually on each device. + ## CouchDB Notes The plugin stores one CouchDB document per vault file. Document ids are derived from file paths. Deleted files are represented by tombstone documents so deletion can propagate between devices. diff --git a/main.js b/main.js index 01b0212..769d4f3 100644 --- a/main.js +++ b/main.js @@ -13,10 +13,12 @@ const DEFAULT_SETTINGS = { database: "obsidian-test", username: "test", password: "testpassword", - liveSync: false, + liveSync: true, syncIntervalSeconds: 120, syncHiddenFiles: false, - conflictPolicy: "newest" + conflictPolicy: "newest", + repositoryRawUrl: "https://nnootteess.dinlo.ru/dimon/simple-couchdb-sync/raw/branch/main", + settingsVersion: 2 }; const PLUGIN_ID = "simple-couchdb-sync"; @@ -33,7 +35,8 @@ module.exports = class SimpleCouchDbSyncPlugin extends Plugin { deviceId: data.state.deviceId || this.settings.deviceId || makeId(), lastSyncStartedAt: null, lastSyncFinishedAt: null, - lastMessage: "Not synced yet" + lastMessage: "Not synced yet", + lastStats: null }; if (!this.settings.deviceId) { @@ -45,6 +48,8 @@ module.exports = class SimpleCouchDbSyncPlugin extends Plugin { 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({ @@ -57,10 +62,18 @@ module.exports = class SimpleCouchDbSyncPlugin extends Plugin { 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() { @@ -79,14 +92,24 @@ module.exports = class SimpleCouchDbSyncPlugin extends Plugin { } 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: Object.assign({}, DEFAULT_SETTINGS, raw.settings || {}), + 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: Object.assign({}, DEFAULT_SETTINGS, raw), + settings, state: { knownFiles: raw.knownFiles || {}, deviceId: raw.deviceId || makeId() @@ -163,25 +186,32 @@ module.exports = class SimpleCouchDbSyncPlugin extends Plugin { this.syncInProgress = true; this.syncState.lastSyncStartedAt = new Date().toISOString(); - this.syncState.lastMessage = "Syncing..."; + this.syncState.lastMessage = `Syncing (${source})...`; + this.updateStatusBar(); 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(); + 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.lastMessage = `Synced. Pushed ${pushed}, pulled ${pulled}.`; + 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(); - new Notice(source === "manual" ? this.syncState.lastMessage : "CouchDB live sync completed"); + 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(); } } @@ -195,14 +225,19 @@ module.exports = class SimpleCouchDbSyncPlugin extends Plugin { for (const file of this.getSyncableFiles()) { this.syncQueue.set(file.path, { kind: "modify", path: file.path }); } - await this.syncNow("manual"); + await this.syncNow("push"); } - async pushPendingLocalChanges() { + async pushPendingLocalChanges(forceAll) { let pushed = 0; const filesByPath = new Map(this.getSyncableFiles().map((file) => [file.path, file])); - if (this.syncQueue.size === 0) { + 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]; @@ -237,6 +272,41 @@ module.exports = class SimpleCouchDbSyncPlugin extends Plugin { 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; @@ -522,6 +592,56 @@ class CouchDbClient { } } +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); @@ -579,7 +699,7 @@ class SimpleCouchDbSyncSettingTab extends PluginSettingTab { new Setting(containerEl) .setName("Live sync") - .setDesc("Automatically sync after vault changes and on the interval below.") + .setDesc("Automatically pushes local changes and pulls remote changes on every device.") .addToggle((toggle) => toggle .setValue(this.plugin.settings.liveSync) .onChange(async (value) => { @@ -589,7 +709,7 @@ class SimpleCouchDbSyncSettingTab extends PluginSettingTab { new Setting(containerEl) .setName("Live sync interval") - .setDesc("Minimum 30 seconds.") + .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)) @@ -623,10 +743,46 @@ class SimpleCouchDbSyncSettingTab extends PluginSettingTab { .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}` @@ -712,3 +868,22 @@ function makeId() { 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; +} diff --git a/manifest.json b/manifest.json index 3ff46b5..f2a194b 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "simple-couchdb-sync", "name": "Simple CouchDB Sync", - "version": "0.1.0", + "version": "0.2.0", "minAppVersion": "1.5.0", "description": "Synchronize Obsidian vault files with a CouchDB database.", "author": "Codex", diff --git a/package.json b/package.json index f7bd889..8af7b7c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simple-couchdb-sync", - "version": "0.1.0", + "version": "0.2.0", "description": "Obsidian plugin for simple vault synchronization through CouchDB.", "main": "main.js", "scripts": {