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
+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;
}