Add live sync visibility and Gitea updater

This commit is contained in:
dinlo
2026-06-20 10:24:42 +08:00
parent 621b25c39f
commit e5fd9a6127
4 changed files with 219 additions and 19 deletions
+27 -2
View File
@@ -5,10 +5,12 @@ Simple CouchDB Sync is an Obsidian plugin that synchronizes vault files through
## Features ## Features
- Manual synchronization command and ribbon action. - 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. - Syncs Markdown notes and binary attachments.
- Uses Obsidian `requestUrl`, so it can work on desktop and mobile, including Android. - Uses Obsidian `requestUrl`, so it can work on desktop and mobile, including Android.
- Stores CouchDB connection settings inside the plugin settings tab. - Stores CouchDB connection settings inside the plugin settings tab.
- One-button plugin update from the Gitea repository.
## Default Test Configuration ## Default Test Configuration
@@ -44,6 +46,7 @@ styles.css
7. Enable `Simple CouchDB Sync`. 7. Enable `Simple CouchDB Sync`.
8. Open the plugin settings and verify the CouchDB server, database, username, and password. 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`. 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: 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`. 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. 8. Open the plugin settings and enter the same CouchDB connection settings as on desktop.
9. Run `Sync vault with CouchDB`. 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`. 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 ## 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 ```text
manifest.json manifest.json
@@ -109,6 +125,15 @@ styles.css
Then restart Obsidian or disable and re-enable the plugin. 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 ## 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. 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.
+190 -15
View File
@@ -13,10 +13,12 @@ const DEFAULT_SETTINGS = {
database: "obsidian-test", database: "obsidian-test",
username: "test", username: "test",
password: "testpassword", password: "testpassword",
liveSync: false, liveSync: true,
syncIntervalSeconds: 120, syncIntervalSeconds: 120,
syncHiddenFiles: false, 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"; const PLUGIN_ID = "simple-couchdb-sync";
@@ -33,7 +35,8 @@ module.exports = class SimpleCouchDbSyncPlugin extends Plugin {
deviceId: data.state.deviceId || this.settings.deviceId || makeId(), deviceId: data.state.deviceId || this.settings.deviceId || makeId(),
lastSyncStartedAt: null, lastSyncStartedAt: null,
lastSyncFinishedAt: null, lastSyncFinishedAt: null,
lastMessage: "Not synced yet" lastMessage: "Not synced yet",
lastStats: null
}; };
if (!this.settings.deviceId) { if (!this.settings.deviceId) {
@@ -45,6 +48,8 @@ module.exports = class SimpleCouchDbSyncPlugin extends Plugin {
this.syncInProgress = false; this.syncInProgress = false;
this.ignoreVaultEventsUntil = 0; this.ignoreVaultEventsUntil = 0;
this.client = new CouchDbClient(this.settings); this.client = new CouchDbClient(this.settings);
this.statusBarEl = this.addStatusBarItem();
this.updateStatusBar();
this.addRibbonIcon("refresh-cw", "Sync with CouchDB", () => this.syncNow("manual")); this.addRibbonIcon("refresh-cw", "Sync with CouchDB", () => this.syncNow("manual"));
this.addCommand({ this.addCommand({
@@ -57,10 +62,18 @@ module.exports = class SimpleCouchDbSyncPlugin extends Plugin {
name: "Push local vault to CouchDB", name: "Push local vault to CouchDB",
callback: () => this.pushLocalVault() 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.addSettingTab(new SimpleCouchDbSyncSettingTab(this.app, this));
this.registerVaultEvents(); this.registerVaultEvents();
this.configureLiveSync(); this.configureLiveSync();
if (this.settings.liveSync) {
window.setTimeout(() => this.syncNow("startup").catch((error) => this.showError("Startup sync failed", error)), 5000);
}
} }
async saveSettings() { async saveSettings() {
@@ -79,14 +92,24 @@ module.exports = class SimpleCouchDbSyncPlugin extends Plugin {
} }
if (raw.settings || raw.state) { 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 { return {
settings: Object.assign({}, DEFAULT_SETTINGS, raw.settings || {}), settings,
state: Object.assign({ knownFiles: {}, deviceId: raw.settings && raw.settings.deviceId }, raw.state || {}) 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 { return {
settings: Object.assign({}, DEFAULT_SETTINGS, raw), settings,
state: { state: {
knownFiles: raw.knownFiles || {}, knownFiles: raw.knownFiles || {},
deviceId: raw.deviceId || makeId() deviceId: raw.deviceId || makeId()
@@ -163,25 +186,32 @@ module.exports = class SimpleCouchDbSyncPlugin extends Plugin {
this.syncInProgress = true; this.syncInProgress = true;
this.syncState.lastSyncStartedAt = new Date().toISOString(); this.syncState.lastSyncStartedAt = new Date().toISOString();
this.syncState.lastMessage = "Syncing..."; this.syncState.lastMessage = `Syncing (${source})...`;
this.updateStatusBar();
try { try {
await this.client.reload(this.settings); await this.client.reload(this.settings);
await this.client.ensureDatabase(); await this.client.ensureDatabase();
await this.client.ensureDesignDocument(); await this.client.ensureDesignDocument();
const pushed = await this.pushPendingLocalChanges(); const localFiles = this.getSyncableFiles().length;
const pulled = await this.pullRemoteChanges(); 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.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(); 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) { } catch (error) {
this.syncState.lastMessage = error.message || String(error); this.syncState.lastMessage = error.message || String(error);
this.showError("CouchDB sync failed", error); this.showError("CouchDB sync failed", error);
} finally { } finally {
this.syncInProgress = false; this.syncInProgress = false;
this.updateStatusBar();
} }
} }
@@ -195,14 +225,19 @@ module.exports = class SimpleCouchDbSyncPlugin extends Plugin {
for (const file of this.getSyncableFiles()) { for (const file of this.getSyncableFiles()) {
this.syncQueue.set(file.path, { kind: "modify", path: file.path }); 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; let pushed = 0;
const filesByPath = new Map(this.getSyncableFiles().map((file) => [file.path, file])); 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()) { for (const file of filesByPath.values()) {
const localDoc = await this.buildFileDocument(file); const localDoc = await this.buildFileDocument(file);
const known = this.syncState.knownFiles[file.path]; const known = this.syncState.knownFiles[file.path];
@@ -237,6 +272,41 @@ module.exports = class SimpleCouchDbSyncPlugin extends Plugin {
return 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) { async putFileDocument(doc) {
const existing = await this.client.getDocument(doc._id); const existing = await this.client.getDocument(doc._id);
if (existing && existing._rev) doc._rev = existing._rev; 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 { class SimpleCouchDbSyncSettingTab extends PluginSettingTab {
constructor(app, plugin) { constructor(app, plugin) {
super(app, plugin); super(app, plugin);
@@ -579,7 +699,7 @@ class SimpleCouchDbSyncSettingTab extends PluginSettingTab {
new Setting(containerEl) new Setting(containerEl)
.setName("Live sync") .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 .addToggle((toggle) => toggle
.setValue(this.plugin.settings.liveSync) .setValue(this.plugin.settings.liveSync)
.onChange(async (value) => { .onChange(async (value) => {
@@ -589,7 +709,7 @@ class SimpleCouchDbSyncSettingTab extends PluginSettingTab {
new Setting(containerEl) new Setting(containerEl)
.setName("Live sync interval") .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 .addText((text) => text
.setPlaceholder("120") .setPlaceholder("120")
.setValue(String(this.plugin.settings.syncIntervalSeconds)) .setValue(String(this.plugin.settings.syncIntervalSeconds))
@@ -623,10 +743,46 @@ class SimpleCouchDbSyncSettingTab extends PluginSettingTab {
.setButtonText("Push") .setButtonText("Push")
.onClick(() => this.plugin.pushLocalVault())); .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({ containerEl.createDiv({
cls: "simple-couchdb-sync-status", cls: "simple-couchdb-sync-status",
text: `Status: ${this.plugin.syncState.lastMessage}` 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({ containerEl.createDiv({
cls: "simple-couchdb-sync-status", cls: "simple-couchdb-sync-status",
text: `Device id: ${this.plugin.syncState.deviceId}` text: `Device id: ${this.plugin.syncState.deviceId}`
@@ -712,3 +868,22 @@ function makeId() {
function isNewer(leftIso, rightIso) { function isNewer(leftIso, rightIso) {
return new Date(leftIso || 0).getTime() > new Date(rightIso || 0).getTime(); 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;
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "simple-couchdb-sync", "id": "simple-couchdb-sync",
"name": "Simple CouchDB Sync", "name": "Simple CouchDB Sync",
"version": "0.1.0", "version": "0.2.0",
"minAppVersion": "1.5.0", "minAppVersion": "1.5.0",
"description": "Synchronize Obsidian vault files with a CouchDB database.", "description": "Synchronize Obsidian vault files with a CouchDB database.",
"author": "Codex", "author": "Codex",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "simple-couchdb-sync", "name": "simple-couchdb-sync",
"version": "0.1.0", "version": "0.2.0",
"description": "Obsidian plugin for simple vault synchronization through CouchDB.", "description": "Obsidian plugin for simple vault synchronization through CouchDB.",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {