|
|
|
@@ -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();
|
|
|
|
|
}
|