2026-06-20 09:58:46 +08:00
|
|
|
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",
|
2026-06-20 10:24:42 +08:00
|
|
|
liveSync: true,
|
2026-06-20 09:58:46 +08:00
|
|
|
syncIntervalSeconds: 120,
|
|
|
|
|
syncHiddenFiles: false,
|
2026-06-20 10:24:42 +08:00
|
|
|
conflictPolicy: "newest",
|
|
|
|
|
repositoryRawUrl: "https://nnootteess.dinlo.ru/dimon/simple-couchdb-sync/raw/branch/main",
|
|
|
|
|
settingsVersion: 2
|
2026-06-20 09:58:46 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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,
|
2026-06-20 10:24:42 +08:00
|
|
|
lastMessage: "Not synced yet",
|
|
|
|
|
lastStats: null
|
2026-06-20 09:58:46 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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);
|
2026-06-20 10:24:42 +08:00
|
|
|
this.statusBarEl = this.addStatusBarItem();
|
|
|
|
|
this.updateStatusBar();
|
2026-06-20 09:58:46 +08:00
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
});
|
2026-06-20 10:24:42 +08:00
|
|
|
this.addCommand({
|
|
|
|
|
id: "update-plugin-from-gitea",
|
|
|
|
|
name: "Update plugin from Gitea",
|
|
|
|
|
callback: () => this.updatePluginFromGitea()
|
|
|
|
|
});
|
2026-06-20 09:58:46 +08:00
|
|
|
|
|
|
|
|
this.addSettingTab(new SimpleCouchDbSyncSettingTab(this.app, this));
|
|
|
|
|
this.registerVaultEvents();
|
|
|
|
|
this.configureLiveSync();
|
2026-06-20 10:24:42 +08:00
|
|
|
if (this.settings.liveSync) {
|
|
|
|
|
window.setTimeout(() => this.syncNow("startup").catch((error) => this.showError("Startup sync failed", error)), 5000);
|
|
|
|
|
}
|
2026-06-20 09:58:46 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-06-20 10:24:42 +08:00
|
|
|
const settings = Object.assign({}, DEFAULT_SETTINGS, raw.settings || {});
|
|
|
|
|
if (!settings.settingsVersion || settings.settingsVersion < 2) {
|
|
|
|
|
settings.liveSync = true;
|
|
|
|
|
settings.settingsVersion = 2;
|
|
|
|
|
}
|
2026-06-20 09:58:46 +08:00
|
|
|
return {
|
2026-06-20 10:24:42 +08:00
|
|
|
settings,
|
2026-06-20 09:58:46 +08:00
|
|
|
state: Object.assign({ knownFiles: {}, deviceId: raw.settings && raw.settings.deviceId }, raw.state || {})
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 10:24:42 +08:00
|
|
|
const settings = Object.assign({}, DEFAULT_SETTINGS, raw);
|
|
|
|
|
if (!settings.settingsVersion || settings.settingsVersion < 2) {
|
|
|
|
|
settings.liveSync = true;
|
|
|
|
|
settings.settingsVersion = 2;
|
|
|
|
|
}
|
2026-06-20 09:58:46 +08:00
|
|
|
return {
|
2026-06-20 10:24:42 +08:00
|
|
|
settings,
|
2026-06-20 09:58:46 +08:00
|
|
|
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();
|
2026-06-20 10:24:42 +08:00
|
|
|
this.syncState.lastMessage = `Syncing (${source})...`;
|
|
|
|
|
this.updateStatusBar();
|
2026-06-20 09:58:46 +08:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await this.client.reload(this.settings);
|
|
|
|
|
await this.client.ensureDatabase();
|
|
|
|
|
await this.client.ensureDesignDocument();
|
|
|
|
|
|
2026-06-20 10:24:42 +08:00
|
|
|
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;
|
2026-06-20 09:58:46 +08:00
|
|
|
|
|
|
|
|
this.syncState.lastSyncFinishedAt = new Date().toISOString();
|
2026-06-20 10:24:42 +08:00
|
|
|
this.syncState.lastStats = { source, localFiles, pushed, pulled: pulled + pulledAfterPush };
|
|
|
|
|
this.syncState.lastMessage = `Synced ${formatTime(this.syncState.lastSyncFinishedAt)}. Files ${localFiles}, pushed ${pushed}, pulled ${pulled + pulledAfterPush}.`;
|
2026-06-20 09:58:46 +08:00
|
|
|
await this.savePluginData();
|
2026-06-20 10:24:42 +08:00
|
|
|
if (source === "manual" || pushed > 0 || pulled > 0) {
|
|
|
|
|
new Notice(this.syncState.lastMessage);
|
|
|
|
|
}
|
2026-06-20 09:58:46 +08:00
|
|
|
} catch (error) {
|
|
|
|
|
this.syncState.lastMessage = error.message || String(error);
|
|
|
|
|
this.showError("CouchDB sync failed", error);
|
|
|
|
|
} finally {
|
|
|
|
|
this.syncInProgress = false;
|
2026-06-20 10:24:42 +08:00
|
|
|
this.updateStatusBar();
|
2026-06-20 09:58:46 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 });
|
|
|
|
|
}
|
2026-06-20 10:24:42 +08:00
|
|
|
await this.syncNow("push");
|
2026-06-20 09:58:46 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-20 10:24:42 +08:00
|
|
|
async pushPendingLocalChanges(forceAll) {
|
2026-06-20 09:58:46 +08:00
|
|
|
let pushed = 0;
|
|
|
|
|
const filesByPath = new Map(this.getSyncableFiles().map((file) => [file.path, file]));
|
|
|
|
|
|
2026-06-20 10:24:42 +08:00
|
|
|
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) {
|
2026-06-20 09:58:46 +08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 10:24:42 +08:00
|
|
|
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}`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 09:58:46 +08:00
|
|
|
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}`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 10:24:42 +08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 09:58:46 +08:00
|
|
|
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")
|
2026-06-20 10:24:42 +08:00
|
|
|
.setDesc("Automatically pushes local changes and pulls remote changes on every device.")
|
2026-06-20 09:58:46 +08:00
|
|
|
.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")
|
2026-06-20 10:24:42 +08:00
|
|
|
.setDesc("How often this device checks CouchDB for changes made on other devices. Minimum 30 seconds.")
|
2026-06-20 09:58:46 +08:00
|
|
|
.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()));
|
|
|
|
|
|
2026-06-20 10:24:42 +08:00
|
|
|
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()));
|
|
|
|
|
|
2026-06-20 09:58:46 +08:00
|
|
|
containerEl.createDiv({
|
|
|
|
|
cls: "simple-couchdb-sync-status",
|
|
|
|
|
text: `Status: ${this.plugin.syncState.lastMessage}`
|
|
|
|
|
});
|
2026-06-20 10:24:42 +08:00
|
|
|
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}`
|
|
|
|
|
});
|
2026-06-20 09:58:46 +08:00
|
|
|
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();
|
|
|
|
|
}
|
2026-06-20 10:24:42 +08:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|