Files

890 lines
27 KiB
JavaScript
Raw Permalink Normal View History

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