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
- 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.
+190 -15
View File
@@ -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;
}
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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": {