Initial Obsidian CouchDB sync plugin
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user