Initial Obsidian CouchDB sync plugin

This commit is contained in:
dinlo
2026-06-20 09:58:46 +08:00
commit 621b25c39f
6 changed files with 869 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
data.json
.DS_Store
Thumbs.db
+116
View File
@@ -0,0 +1,116 @@
# Simple CouchDB Sync
Simple CouchDB Sync is an Obsidian plugin that synchronizes vault files through a CouchDB database.
## Features
- Manual synchronization command and ribbon action.
- Optional live synchronization for file create, modify, delete, and rename events.
- 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.
## Default Test Configuration
- Server: `https://obsidian.dinlo.ru`
- Database: `obsidian-test`
- User: `test`
- Password: `testpassword`
You can change these values in Obsidian under Settings -> Community plugins -> Simple CouchDB Sync.
## Installation From Gitea On Desktop
The repository contains a ready-to-load Obsidian plugin. No build step is required.
1. Open the Obsidian vault folder on your computer.
2. Create this folder if it does not exist:
```text
<vault>/.obsidian/plugins/simple-couchdb-sync/
```
3. Download these files from the Gitea repository and place them into that folder:
```text
manifest.json
main.js
styles.css
```
4. Restart Obsidian, or run "Reload app without saving" from the command palette.
5. Open Settings -> Community plugins.
6. Disable Safe mode or Restricted mode if Obsidian asks for it.
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`.
For your test vault, the target folder is:
```text
C:\Users\dimir\Documents\ObsidianTest\Test\.obsidian\plugins\simple-couchdb-sync
```
If Git is installed, you can clone the repository directly into the plugin folder:
```powershell
cd "C:\Users\dimir\Documents\ObsidianTest\Test\.obsidian\plugins"
git clone https://nnootteess.dinlo.ru/dimon/simple-couchdb-sync.git simple-couchdb-sync
```
Repository URL:
```text
https://nnootteess.dinlo.ru/dimon/simple-couchdb-sync
```
## Installation From Gitea On Android
Android Obsidian can load the same plugin files, but the folder must be copied into the mobile vault.
1. Enable Community plugins in Obsidian for Android.
2. Using a file manager, open your vault folder on the phone.
3. Create this folder:
```text
<vault>/.obsidian/plugins/simple-couchdb-sync/
```
4. From the Gitea repository, download these files:
```text
manifest.json
main.js
styles.css
```
5. Copy the three files into:
```text
<vault>/.obsidian/plugins/simple-couchdb-sync/
```
6. Fully close and reopen Obsidian on Android.
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`.
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:
```text
manifest.json
main.js
styles.css
```
Then restart Obsidian or disable and re-enable the plugin.
## 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 CouchDB user needs read and write access to the database. If the database does not exist, the plugin will try to create it.
+714
View File
@@ -0,0 +1,714 @@
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",
liveSync: false,
syncIntervalSeconds: 120,
syncHiddenFiles: false,
conflictPolicy: "newest"
};
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,
lastMessage: "Not synced yet"
};
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);
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()
});
this.addSettingTab(new SimpleCouchDbSyncSettingTab(this.app, this));
this.registerVaultEvents();
this.configureLiveSync();
}
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) {
return {
settings: Object.assign({}, DEFAULT_SETTINGS, raw.settings || {}),
state: Object.assign({ knownFiles: {}, deviceId: raw.settings && raw.settings.deviceId }, raw.state || {})
};
}
return {
settings: Object.assign({}, DEFAULT_SETTINGS, raw),
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();
this.syncState.lastMessage = "Syncing...";
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();
this.syncState.lastSyncFinishedAt = new Date().toISOString();
this.syncState.lastMessage = `Synced. Pushed ${pushed}, pulled ${pulled}.`;
await this.savePluginData();
new Notice(source === "manual" ? this.syncState.lastMessage : "CouchDB live sync completed");
} catch (error) {
this.syncState.lastMessage = error.message || String(error);
this.showError("CouchDB sync failed", error);
} finally {
this.syncInProgress = false;
}
}
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 });
}
await this.syncNow("manual");
}
async pushPendingLocalChanges() {
let pushed = 0;
const filesByPath = new Map(this.getSyncableFiles().map((file) => [file.path, file]));
if (this.syncQueue.size === 0) {
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;
}
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}`;
}
}
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")
.setDesc("Automatically sync after vault changes and on the interval below.")
.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")
.setDesc("Minimum 30 seconds.")
.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()));
containerEl.createDiv({
cls: "simple-couchdb-sync-status",
text: `Status: ${this.plugin.syncState.lastMessage}`
});
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();
}
+10
View File
@@ -0,0 +1,10 @@
{
"id": "simple-couchdb-sync",
"name": "Simple CouchDB Sync",
"version": "0.1.0",
"minAppVersion": "1.5.0",
"description": "Synchronize Obsidian vault files with a CouchDB database.",
"author": "Codex",
"authorUrl": "",
"isDesktopOnly": false
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "simple-couchdb-sync",
"version": "0.1.0",
"description": "Obsidian plugin for simple vault synchronization through CouchDB.",
"main": "main.js",
"scripts": {
"check": "node --check main.js"
},
"keywords": [
"obsidian",
"couchdb",
"sync"
],
"license": "MIT",
"devDependencies": {}
}
+9
View File
@@ -0,0 +1,9 @@
.simple-couchdb-sync-status {
margin-top: 8px;
color: var(--text-muted);
font-size: var(--font-ui-small);
}
.simple-couchdb-sync-danger {
color: var(--text-error);
}