From 621b25c39fd6996836acdd11554863ac51a4e671 Mon Sep 17 00:00:00 2001 From: dinlo Date: Sat, 20 Jun 2026 09:58:46 +0800 Subject: [PATCH] Initial Obsidian CouchDB sync plugin --- .gitignore | 4 + README.md | 116 ++++++++ main.js | 714 ++++++++++++++++++++++++++++++++++++++++++++++++++ manifest.json | 10 + package.json | 16 ++ styles.css | 9 + 6 files changed, 869 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 main.js create mode 100644 manifest.json create mode 100644 package.json create mode 100644 styles.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a7e9b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +data.json +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a6d44c --- /dev/null +++ b/README.md @@ -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 +/.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 +/.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 +/.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. diff --git a/main.js b/main.js new file mode 100644 index 0000000..01b0212 --- /dev/null +++ b/main.js @@ -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(); +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..3ff46b5 --- /dev/null +++ b/manifest.json @@ -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 +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f7bd889 --- /dev/null +++ b/package.json @@ -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": {} +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..0a6264d --- /dev/null +++ b/styles.css @@ -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); +}