mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2025-12-22 06:01:28 +00:00
Refactored and fixed:
- Refactored, linted, fixed potential problems, enabled 'use strict' Fixed: - Added "Enable plugin synchronization" option (Plugins and settings had been run always) Implemented: - Sync preset implemented. - "Check integrity on saving" implemented. - "Sanity check" implemented It's mainly for debugging.
This commit is contained in:
3
.eslintignore
Normal file
3
.eslintignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
npm node_modules
|
||||||
|
build
|
||||||
|
.eslintrc.js.bak
|
||||||
19
.eslintrc
Normal file
19
.eslintrc
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"root": true,
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"plugins": ["@typescript-eslint"],
|
||||||
|
"extends": ["eslint:recommended", "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended"],
|
||||||
|
"parserOptions": {
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
|
||||||
|
"@typescript-eslint/ban-ts-comment": "off",
|
||||||
|
"no-prototype-builtins": "off",
|
||||||
|
"@typescript-eslint/no-empty-function": "off",
|
||||||
|
"require-await": "warn",
|
||||||
|
"no-async-promise-executor": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "obsidian-livesync",
|
"id": "obsidian-livesync",
|
||||||
"name": "Self-hosted LiveSync",
|
"name": "Self-hosted LiveSync",
|
||||||
"version": "0.1.26",
|
"version": "0.1.27",
|
||||||
"minAppVersion": "0.9.12",
|
"minAppVersion": "0.9.12",
|
||||||
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||||
"author": "vorotamoroz",
|
"author": "vorotamoroz",
|
||||||
|
|||||||
4036
package-lock.json
generated
4036
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "obsidian-livesync",
|
"name": "obsidian-livesync",
|
||||||
"version": "0.1.26",
|
"version": "0.1.27",
|
||||||
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "rollup --config rollup.config.js -w",
|
"dev": "rollup --config rollup.config.js -w",
|
||||||
"build": "rollup --config rollup.config.js --environment BUILD:production"
|
"build": "rollup --config rollup.config.js --environment BUILD:production",
|
||||||
|
"lint": "eslint src"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "vorotamoroz",
|
"author": "vorotamoroz",
|
||||||
@@ -16,6 +17,11 @@
|
|||||||
"@rollup/plugin-typescript": "^8.2.1",
|
"@rollup/plugin-typescript": "^8.2.1",
|
||||||
"@types/diff-match-patch": "^1.0.32",
|
"@types/diff-match-patch": "^1.0.32",
|
||||||
"@types/pouchdb-browser": "^6.1.3",
|
"@types/pouchdb-browser": "^6.1.3",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.7.0",
|
||||||
|
"@typescript-eslint/parser": "^5.0.0",
|
||||||
|
"eslint": "^7.32.0",
|
||||||
|
"eslint-config-airbnb-base": "^14.2.1",
|
||||||
|
"eslint-plugin-import": "^2.25.2",
|
||||||
"obsidian": "^0.12.0",
|
"obsidian": "^0.12.0",
|
||||||
"rollup": "^2.32.1",
|
"rollup": "^2.32.1",
|
||||||
"tslib": "^2.2.0",
|
"tslib": "^2.2.0",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ if you want to view the source visit the plugins github repository
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
input: "main.ts",
|
input: "./src/main.ts",
|
||||||
output: {
|
output: {
|
||||||
dir: ".",
|
dir: ".",
|
||||||
sourcemap: "inline",
|
sourcemap: "inline",
|
||||||
|
|||||||
74
src/ConflictResolveModal.ts
Normal file
74
src/ConflictResolveModal.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { App, Modal } from "obsidian";
|
||||||
|
import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT } from "diff-match-patch";
|
||||||
|
import { diff_result } from "./types";
|
||||||
|
import { escapeStringToHTML } from "./utils";
|
||||||
|
|
||||||
|
export class ConflictResolveModal extends Modal {
|
||||||
|
// result: Array<[number, string]>;
|
||||||
|
result: diff_result;
|
||||||
|
callback: (remove_rev: string) => Promise<void>;
|
||||||
|
constructor(app: App, diff: diff_result, callback: (remove_rev: string) => Promise<void>) {
|
||||||
|
super(app);
|
||||||
|
this.result = diff;
|
||||||
|
this.callback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
|
||||||
|
contentEl.empty();
|
||||||
|
|
||||||
|
contentEl.createEl("h2", { text: "This document has conflicted changes." });
|
||||||
|
const div = contentEl.createDiv("");
|
||||||
|
div.addClass("op-scrollable");
|
||||||
|
let diff = "";
|
||||||
|
for (const v of this.result.diff) {
|
||||||
|
const x1 = v[0];
|
||||||
|
const x2 = v[1];
|
||||||
|
if (x1 == DIFF_DELETE) {
|
||||||
|
diff += "<span class='deleted'>" + escapeStringToHTML(x2) + "</span>";
|
||||||
|
} else if (x1 == DIFF_EQUAL) {
|
||||||
|
diff += "<span class='normal'>" + escapeStringToHTML(x2) + "</span>";
|
||||||
|
} else if (x1 == DIFF_INSERT) {
|
||||||
|
diff += "<span class='added'>" + escapeStringToHTML(x2) + "</span>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diff = diff.replace(/\n/g, "<br>");
|
||||||
|
div.innerHTML = diff;
|
||||||
|
const div2 = contentEl.createDiv("");
|
||||||
|
const date1 = new Date(this.result.left.mtime).toLocaleString();
|
||||||
|
const date2 = new Date(this.result.right.mtime).toLocaleString();
|
||||||
|
div2.innerHTML = `
|
||||||
|
<span class='deleted'>A:${date1}</span><br /><span class='added'>B:${date2}</span><br>
|
||||||
|
`;
|
||||||
|
contentEl.createEl("button", { text: "Keep A" }, (e) => {
|
||||||
|
e.addEventListener("click", async () => {
|
||||||
|
await this.callback(this.result.right.rev);
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
contentEl.createEl("button", { text: "Keep B" }, (e) => {
|
||||||
|
e.addEventListener("click", async () => {
|
||||||
|
await this.callback(this.result.left.rev);
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
contentEl.createEl("button", { text: "Concat both" }, (e) => {
|
||||||
|
e.addEventListener("click", async () => {
|
||||||
|
await this.callback(null);
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
contentEl.createEl("button", { text: "Not now" }, (e) => {
|
||||||
|
e.addEventListener("click", () => {
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
1229
src/LocalPouchDB.ts
Normal file
1229
src/LocalPouchDB.ts
Normal file
File diff suppressed because it is too large
Load Diff
37
src/LogDisplayModal.ts
Normal file
37
src/LogDisplayModal.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { App, Modal } from "obsidian";
|
||||||
|
import { escapeStringToHTML } from "./utils";
|
||||||
|
import ObsidianLiveSyncPlugin from "./main";
|
||||||
|
|
||||||
|
export class LogDisplayModal extends Modal {
|
||||||
|
plugin: ObsidianLiveSyncPlugin;
|
||||||
|
logEl: HTMLDivElement;
|
||||||
|
constructor(app: App, plugin: ObsidianLiveSyncPlugin) {
|
||||||
|
super(app);
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
updateLog() {
|
||||||
|
let msg = "";
|
||||||
|
for (const v of this.plugin.logMessage) {
|
||||||
|
msg += escapeStringToHTML(v) + "<br>";
|
||||||
|
}
|
||||||
|
this.logEl.innerHTML = msg;
|
||||||
|
}
|
||||||
|
onOpen() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
|
||||||
|
contentEl.empty();
|
||||||
|
contentEl.createEl("h2", { text: "Sync Status" });
|
||||||
|
const div = contentEl.createDiv("");
|
||||||
|
div.addClass("op-scrollable");
|
||||||
|
div.addClass("op-pre");
|
||||||
|
this.logEl = div;
|
||||||
|
this.updateLog = this.updateLog.bind(this);
|
||||||
|
this.plugin.addLogHook = this.updateLog;
|
||||||
|
this.updateLog();
|
||||||
|
}
|
||||||
|
onClose() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
this.plugin.addLogHook = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
1093
src/ObsidianLiveSyncSettingTab.ts
Normal file
1093
src/ObsidianLiveSyncSettingTab.ts
Normal file
File diff suppressed because it is too large
Load Diff
168
src/e2ee.ts
Normal file
168
src/e2ee.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { Logger } from "./logger";
|
||||||
|
import { LOG_LEVEL } from "./types";
|
||||||
|
|
||||||
|
export type encodedData = [encryptedData: string, iv: string, salt: string];
|
||||||
|
export type KeyBuffer = {
|
||||||
|
index: string;
|
||||||
|
key: CryptoKey;
|
||||||
|
salt: Uint8Array;
|
||||||
|
};
|
||||||
|
|
||||||
|
const KeyBuffs: KeyBuffer[] = [];
|
||||||
|
const decKeyBuffs: KeyBuffer[] = [];
|
||||||
|
|
||||||
|
const KEY_RECYCLE_COUNT = 100;
|
||||||
|
let recycleCount = KEY_RECYCLE_COUNT;
|
||||||
|
|
||||||
|
let semiStaticFieldBuffer: Uint8Array = null;
|
||||||
|
const nonceBuffer: Uint32Array = new Uint32Array(1);
|
||||||
|
|
||||||
|
export async function getKeyForEncrypt(passphrase: string): Promise<[CryptoKey, Uint8Array]> {
|
||||||
|
// For performance, the plugin reuses the key KEY_RECYCLE_COUNT times.
|
||||||
|
const f = KeyBuffs.find((e) => e.index == passphrase);
|
||||||
|
if (f) {
|
||||||
|
recycleCount--;
|
||||||
|
if (recycleCount > 0) {
|
||||||
|
return [f.key, f.salt];
|
||||||
|
}
|
||||||
|
KeyBuffs.remove(f);
|
||||||
|
recycleCount = KEY_RECYCLE_COUNT;
|
||||||
|
}
|
||||||
|
const xpassphrase = new TextEncoder().encode(passphrase);
|
||||||
|
const digest = await crypto.subtle.digest({ name: "SHA-256" }, xpassphrase);
|
||||||
|
const keyMaterial = await crypto.subtle.importKey("raw", digest, { name: "PBKDF2" }, false, ["deriveKey"]);
|
||||||
|
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const key = await crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: "PBKDF2",
|
||||||
|
salt,
|
||||||
|
iterations: 100000,
|
||||||
|
hash: "SHA-256",
|
||||||
|
},
|
||||||
|
keyMaterial,
|
||||||
|
{ name: "AES-GCM", length: 256 },
|
||||||
|
false,
|
||||||
|
["encrypt"]
|
||||||
|
);
|
||||||
|
KeyBuffs.push({
|
||||||
|
index: passphrase,
|
||||||
|
key,
|
||||||
|
salt,
|
||||||
|
});
|
||||||
|
while (KeyBuffs.length > 50) {
|
||||||
|
KeyBuffs.shift();
|
||||||
|
}
|
||||||
|
return [key, salt];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getKeyForDecryption(passphrase: string, salt: Uint8Array): Promise<[CryptoKey, Uint8Array]> {
|
||||||
|
const bufKey = passphrase + uint8ArrayToHexString(salt);
|
||||||
|
const f = decKeyBuffs.find((e) => e.index == bufKey);
|
||||||
|
if (f) {
|
||||||
|
return [f.key, f.salt];
|
||||||
|
}
|
||||||
|
const xpassphrase = new TextEncoder().encode(passphrase);
|
||||||
|
const digest = await crypto.subtle.digest({ name: "SHA-256" }, xpassphrase);
|
||||||
|
const keyMaterial = await crypto.subtle.importKey("raw", digest, { name: "PBKDF2" }, false, ["deriveKey"]);
|
||||||
|
const key = await crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: "PBKDF2",
|
||||||
|
salt,
|
||||||
|
iterations: 100000,
|
||||||
|
hash: "SHA-256",
|
||||||
|
},
|
||||||
|
keyMaterial,
|
||||||
|
{ name: "AES-GCM", length: 256 },
|
||||||
|
false,
|
||||||
|
["decrypt"]
|
||||||
|
);
|
||||||
|
decKeyBuffs.push({
|
||||||
|
index: bufKey,
|
||||||
|
key,
|
||||||
|
salt,
|
||||||
|
});
|
||||||
|
while (decKeyBuffs.length > 50) {
|
||||||
|
decKeyBuffs.shift();
|
||||||
|
}
|
||||||
|
return [key, salt];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSemiStaticField(reset?: boolean) {
|
||||||
|
// return fixed field of iv.
|
||||||
|
if (semiStaticFieldBuffer != null && !reset) {
|
||||||
|
return semiStaticFieldBuffer;
|
||||||
|
}
|
||||||
|
semiStaticFieldBuffer = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
return semiStaticFieldBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNonce() {
|
||||||
|
// This is nonce, so do not send same thing.
|
||||||
|
nonceBuffer[0]++;
|
||||||
|
if (nonceBuffer[0] > 10000) {
|
||||||
|
// reset semi-static field.
|
||||||
|
getSemiStaticField(true);
|
||||||
|
}
|
||||||
|
return nonceBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uint8ArrayToHexString(src: Uint8Array): string {
|
||||||
|
return Array.from(src)
|
||||||
|
.map((e: number): string => `00${e.toString(16)}`.slice(-2))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
function hexStringToUint8Array(src: string): Uint8Array {
|
||||||
|
const srcArr = [...src];
|
||||||
|
const arr = srcArr.reduce((acc, _, i) => (i % 2 ? acc : [...acc, srcArr.slice(i, i + 2).join("")]), []).map((e) => parseInt(e, 16));
|
||||||
|
return Uint8Array.from(arr);
|
||||||
|
}
|
||||||
|
export async function encrypt(input: string, passphrase: string) {
|
||||||
|
const [key, salt] = await getKeyForEncrypt(passphrase);
|
||||||
|
// Create initial vector with semifixed part and incremental part
|
||||||
|
// I think it's not good against related-key attacks.
|
||||||
|
const fixedPart = getSemiStaticField();
|
||||||
|
const invocationPart = getNonce();
|
||||||
|
const iv = Uint8Array.from([...fixedPart, ...new Uint8Array(invocationPart.buffer)]);
|
||||||
|
const plainStringified: string = JSON.stringify(input);
|
||||||
|
const plainStringBuffer: Uint8Array = new TextEncoder().encode(plainStringified);
|
||||||
|
const encryptedDataArrayBuffer = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plainStringBuffer);
|
||||||
|
|
||||||
|
const encryptedData = window.btoa(Array.from(new Uint8Array(encryptedDataArrayBuffer), (char) => String.fromCharCode(char)).join(""));
|
||||||
|
|
||||||
|
//return data with iv and salt.
|
||||||
|
const response: encodedData = [encryptedData, uint8ArrayToHexString(iv), uint8ArrayToHexString(salt)];
|
||||||
|
const ret = JSON.stringify(response);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decrypt(encryptedResult: string, passphrase: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const [encryptedData, ivString, salt]: encodedData = JSON.parse(encryptedResult);
|
||||||
|
const [key] = await getKeyForDecryption(passphrase, hexStringToUint8Array(salt));
|
||||||
|
const iv = hexStringToUint8Array(ivString);
|
||||||
|
// decode base 64, it should increase speed and i should with in MAX_DOC_SIZE_BIN, so it won't OOM.
|
||||||
|
const encryptedDataBin = window.atob(encryptedData);
|
||||||
|
const encryptedDataArrayBuffer = Uint8Array.from(encryptedDataBin.split(""), (char) => char.charCodeAt(0));
|
||||||
|
const plainStringBuffer: ArrayBuffer = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, encryptedDataArrayBuffer);
|
||||||
|
const plainStringified = new TextDecoder().decode(plainStringBuffer);
|
||||||
|
const plain = JSON.parse(plainStringified);
|
||||||
|
return plain;
|
||||||
|
} catch (ex) {
|
||||||
|
Logger("Couldn't decode! You should wrong the passphrases", LOG_LEVEL.VERBOSE);
|
||||||
|
Logger(ex, LOG_LEVEL.VERBOSE);
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testCrypt() {
|
||||||
|
const src = "supercalifragilisticexpialidocious";
|
||||||
|
const encoded = await encrypt(src, "passwordTest");
|
||||||
|
const decrypted = await decrypt(encoded, "passwordTest");
|
||||||
|
if (src != decrypted) {
|
||||||
|
Logger("WARNING! Your device would not support encryption.", LOG_LEVEL.VERBOSE);
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
Logger("CRYPT LOGIC OK", LOG_LEVEL.VERBOSE);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/logger.ts
Normal file
13
src/logger.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { LOG_LEVEL } from "./types";
|
||||||
|
|
||||||
|
// eslint-disable-next-line require-await
|
||||||
|
export let Logger: (message: any, levlel?: LOG_LEVEL) => Promise<void> = async (message, _) => {
|
||||||
|
const timestamp = new Date().toLocaleString();
|
||||||
|
const messagecontent = typeof message == "string" ? message : message instanceof Error ? `${message.name}:${message.message}` : JSON.stringify(message, null, 2);
|
||||||
|
const newmessage = timestamp + "->" + messagecontent;
|
||||||
|
console.log(newmessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function setLogger(loggerFun: (message: any, levlel?: LOG_LEVEL) => Promise<void>) {
|
||||||
|
Logger = loggerFun;
|
||||||
|
}
|
||||||
1300
src/main.ts
Normal file
1300
src/main.ts
Normal file
File diff suppressed because it is too large
Load Diff
224
src/types.ts
Normal file
224
src/types.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
// docs should be encoded as base64, so 1 char -> 1 bytes
|
||||||
|
// and cloudant limitation is 1MB , we use 900kb;
|
||||||
|
|
||||||
|
import { PluginManifest } from "obsidian";
|
||||||
|
|
||||||
|
export const MAX_DOC_SIZE = 1000; // for .md file, but if delimiters exists. use that before.
|
||||||
|
export const MAX_DOC_SIZE_BIN = 102400; // 100kb
|
||||||
|
export const VER = 10;
|
||||||
|
|
||||||
|
export const RECENT_MOFIDIED_DOCS_QTY = 30;
|
||||||
|
export const LEAF_WAIT_TIMEOUT = 90000; // in synchronization, waiting missing leaf time out.
|
||||||
|
export const LOG_LEVEL = {
|
||||||
|
VERBOSE: 1,
|
||||||
|
INFO: 10,
|
||||||
|
NOTICE: 100,
|
||||||
|
URGENT: 1000,
|
||||||
|
} as const;
|
||||||
|
export type LOG_LEVEL = typeof LOG_LEVEL[keyof typeof LOG_LEVEL];
|
||||||
|
export const VERSIONINFO_DOCID = "obsydian_livesync_version";
|
||||||
|
export const MILSTONE_DOCID = "_local/obsydian_livesync_milestone";
|
||||||
|
export const NODEINFO_DOCID = "_local/obsydian_livesync_nodeinfo";
|
||||||
|
|
||||||
|
export interface ObsidianLiveSyncSettings {
|
||||||
|
couchDB_URI: string;
|
||||||
|
couchDB_USER: string;
|
||||||
|
couchDB_PASSWORD: string;
|
||||||
|
couchDB_DBNAME: string;
|
||||||
|
liveSync: boolean;
|
||||||
|
syncOnSave: boolean;
|
||||||
|
syncOnStart: boolean;
|
||||||
|
syncOnFileOpen: boolean;
|
||||||
|
savingDelay: number;
|
||||||
|
lessInformationInLog: boolean;
|
||||||
|
gcDelay: number;
|
||||||
|
versionUpFlash: string;
|
||||||
|
minimumChunkSize: number;
|
||||||
|
longLineThreshold: number;
|
||||||
|
showVerboseLog: boolean;
|
||||||
|
suspendFileWatching: boolean;
|
||||||
|
trashInsteadDelete: boolean;
|
||||||
|
periodicReplication: boolean;
|
||||||
|
periodicReplicationInterval: number;
|
||||||
|
encrypt: boolean;
|
||||||
|
passphrase: string;
|
||||||
|
workingEncrypt: boolean;
|
||||||
|
workingPassphrase: string;
|
||||||
|
doNotDeleteFolder: boolean;
|
||||||
|
resolveConflictsByNewerFile: boolean;
|
||||||
|
batchSave: boolean;
|
||||||
|
deviceAndVaultName: string;
|
||||||
|
usePluginSettings: boolean;
|
||||||
|
showOwnPlugins: boolean;
|
||||||
|
showStatusOnEditor: boolean;
|
||||||
|
usePluginSync: boolean;
|
||||||
|
autoSweepPlugins: boolean;
|
||||||
|
autoSweepPluginsPeriodic: boolean;
|
||||||
|
notifyPluginOrSettingUpdated: boolean;
|
||||||
|
checkIntegrityOnSave: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: ObsidianLiveSyncSettings = {
|
||||||
|
couchDB_URI: "",
|
||||||
|
couchDB_USER: "",
|
||||||
|
couchDB_PASSWORD: "",
|
||||||
|
couchDB_DBNAME: "",
|
||||||
|
liveSync: false,
|
||||||
|
syncOnSave: false,
|
||||||
|
syncOnStart: false,
|
||||||
|
savingDelay: 200,
|
||||||
|
lessInformationInLog: false,
|
||||||
|
gcDelay: 300,
|
||||||
|
versionUpFlash: "",
|
||||||
|
minimumChunkSize: 20,
|
||||||
|
longLineThreshold: 250,
|
||||||
|
showVerboseLog: false,
|
||||||
|
suspendFileWatching: false,
|
||||||
|
trashInsteadDelete: true,
|
||||||
|
periodicReplication: false,
|
||||||
|
periodicReplicationInterval: 60,
|
||||||
|
syncOnFileOpen: false,
|
||||||
|
encrypt: false,
|
||||||
|
passphrase: "",
|
||||||
|
workingEncrypt: false,
|
||||||
|
workingPassphrase: "",
|
||||||
|
doNotDeleteFolder: false,
|
||||||
|
resolveConflictsByNewerFile: false,
|
||||||
|
batchSave: false,
|
||||||
|
deviceAndVaultName: "",
|
||||||
|
usePluginSettings: false,
|
||||||
|
showOwnPlugins: false,
|
||||||
|
showStatusOnEditor: false,
|
||||||
|
usePluginSync: false,
|
||||||
|
autoSweepPlugins: false,
|
||||||
|
autoSweepPluginsPeriodic: false,
|
||||||
|
notifyPluginOrSettingUpdated: false,
|
||||||
|
checkIntegrityOnSave: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PERIODIC_PLUGIN_SWEEP = 60;
|
||||||
|
|
||||||
|
export interface Entry {
|
||||||
|
_id: string;
|
||||||
|
data: string;
|
||||||
|
_rev?: string;
|
||||||
|
ctime: number;
|
||||||
|
mtime: number;
|
||||||
|
size: number;
|
||||||
|
_deleted?: boolean;
|
||||||
|
_conflicts?: string[];
|
||||||
|
type?: "notes";
|
||||||
|
}
|
||||||
|
export interface NewEntry {
|
||||||
|
_id: string;
|
||||||
|
children: string[];
|
||||||
|
_rev?: string;
|
||||||
|
ctime: number;
|
||||||
|
mtime: number;
|
||||||
|
size: number;
|
||||||
|
_deleted?: boolean;
|
||||||
|
_conflicts?: string[];
|
||||||
|
NewNote: true;
|
||||||
|
type: "newnote";
|
||||||
|
}
|
||||||
|
export interface PlainEntry {
|
||||||
|
_id: string;
|
||||||
|
children: string[];
|
||||||
|
_rev?: string;
|
||||||
|
ctime: number;
|
||||||
|
mtime: number;
|
||||||
|
size: number;
|
||||||
|
_deleted?: boolean;
|
||||||
|
NewNote: true;
|
||||||
|
_conflicts?: string[];
|
||||||
|
type: "plain";
|
||||||
|
}
|
||||||
|
export type LoadedEntry = Entry & {
|
||||||
|
children: string[];
|
||||||
|
datatype: "plain" | "newnote";
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PluginDataEntry {
|
||||||
|
_id: string;
|
||||||
|
deviceVaultName: string;
|
||||||
|
mtime: number;
|
||||||
|
manifest: PluginManifest;
|
||||||
|
mainJs: string;
|
||||||
|
manifestJson: string;
|
||||||
|
styleCss?: string;
|
||||||
|
// it must be encrypted.
|
||||||
|
dataJson?: string;
|
||||||
|
_rev?: string;
|
||||||
|
_deleted?: boolean;
|
||||||
|
_conflicts?: string[];
|
||||||
|
type: "plugin";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryLeaf {
|
||||||
|
_id: string;
|
||||||
|
data: string;
|
||||||
|
_deleted?: boolean;
|
||||||
|
type: "leaf";
|
||||||
|
_rev?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryVersionInfo {
|
||||||
|
_id: typeof VERSIONINFO_DOCID;
|
||||||
|
_rev?: string;
|
||||||
|
type: "versioninfo";
|
||||||
|
version: number;
|
||||||
|
_deleted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryMilestoneInfo {
|
||||||
|
_id: typeof MILSTONE_DOCID;
|
||||||
|
_rev?: string;
|
||||||
|
type: "milestoneinfo";
|
||||||
|
_deleted?: boolean;
|
||||||
|
created: number;
|
||||||
|
accepted_nodes: string[];
|
||||||
|
locked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryNodeInfo {
|
||||||
|
_id: typeof NODEINFO_DOCID;
|
||||||
|
_rev?: string;
|
||||||
|
_deleted?: boolean;
|
||||||
|
type: "nodeinfo";
|
||||||
|
nodeid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EntryBody = Entry | NewEntry | PlainEntry;
|
||||||
|
export type EntryDoc = EntryBody | LoadedEntry | EntryLeaf | EntryVersionInfo | EntryMilestoneInfo | EntryNodeInfo;
|
||||||
|
|
||||||
|
export type diff_result_leaf = {
|
||||||
|
rev: string;
|
||||||
|
data: string;
|
||||||
|
ctime: number;
|
||||||
|
mtime: number;
|
||||||
|
};
|
||||||
|
export type dmp_result = Array<[number, string]>;
|
||||||
|
|
||||||
|
export type diff_result = {
|
||||||
|
left: diff_result_leaf;
|
||||||
|
right: diff_result_leaf;
|
||||||
|
diff: dmp_result;
|
||||||
|
};
|
||||||
|
export type diff_check_result = boolean | diff_result;
|
||||||
|
|
||||||
|
export type Credential = {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EntryDocResponse = EntryDoc & PouchDB.Core.IdMeta & PouchDB.Core.GetMeta;
|
||||||
|
|
||||||
|
export type DatabaseConnectingStatus = "STARTED" | "NOT_CONNECTED" | "PAUSED" | "CONNECTED" | "COMPLETED" | "CLOSED" | "ERRORED";
|
||||||
|
|
||||||
|
export interface PluginList {
|
||||||
|
[key: string]: PluginDataEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DevicePluginList {
|
||||||
|
[key: string]: PluginDataEntry;
|
||||||
|
}
|
||||||
197
src/utils.ts
Normal file
197
src/utils.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { normalizePath } from "obsidian";
|
||||||
|
import { Logger } from "./logger";
|
||||||
|
import { LOG_LEVEL } from "./types";
|
||||||
|
|
||||||
|
export function arrayBufferToBase64(buffer: ArrayBuffer): Promise<string> {
|
||||||
|
return new Promise((res) => {
|
||||||
|
const blob = new Blob([buffer], { type: "application/octet-binary" });
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = function (evt) {
|
||||||
|
const dataurl = evt.target.result.toString();
|
||||||
|
res(dataurl.substr(dataurl.indexOf(",") + 1));
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function base64ToString(base64: string): string {
|
||||||
|
try {
|
||||||
|
const binary_string = window.atob(base64);
|
||||||
|
const len = binary_string.length;
|
||||||
|
const bytes = new Uint8Array(len);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
bytes[i] = binary_string.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
} catch (ex) {
|
||||||
|
return base64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||||
|
try {
|
||||||
|
const binary_string = window.atob(base64);
|
||||||
|
const len = binary_string.length;
|
||||||
|
const bytes = new Uint8Array(len);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
bytes[i] = binary_string.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
} catch (ex) {
|
||||||
|
try {
|
||||||
|
return new Uint16Array(
|
||||||
|
[].map.call(base64, function (c: string) {
|
||||||
|
return c.charCodeAt(0);
|
||||||
|
})
|
||||||
|
).buffer;
|
||||||
|
} catch (ex2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const escapeStringToHTML = (str: string) => {
|
||||||
|
if (!str) return "";
|
||||||
|
return str.replace(/[<>&"'`]/g, (match) => {
|
||||||
|
const escape: any = {
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
"&": "&",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
"`": "`",
|
||||||
|
};
|
||||||
|
return escape[match];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveWithIgnoreKnownError<T>(p: Promise<T>, def: T): Promise<T> {
|
||||||
|
return new Promise((res, rej) => {
|
||||||
|
p.then(res).catch((ex) => (ex.status && ex.status == 404 ? res(def) : rej(ex)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidPath(filename: string): boolean {
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const regex = /[\u0000-\u001f]|[\\"':?<>|*]/g;
|
||||||
|
let x = filename.replace(regex, "_");
|
||||||
|
const win = /(\\|\/)(COM\d|LPT\d|CON|PRN|AUX|NUL|CLOCK$)($|\.)/gi;
|
||||||
|
const sx = (x = x.replace(win, "/_"));
|
||||||
|
return sx == filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function versionNumberString2Number(version: string): number {
|
||||||
|
return version // "1.23.45"
|
||||||
|
.split(".") // 1 23 45
|
||||||
|
.reverse() // 45 23 1
|
||||||
|
.map((e, i) => ((e as any) / 1) * 1000 ** i) // 45 23000 1000000
|
||||||
|
.reduce((prev, current) => prev + current, 0); // 1023045
|
||||||
|
}
|
||||||
|
|
||||||
|
export const delay = (ms: number): Promise<void> => {
|
||||||
|
return new Promise((res) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
res();
|
||||||
|
}, ms);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// For backward compatibility, using the path for determining id.
|
||||||
|
// Only CouchDB nonacceptable ID (that starts with an underscore) has been prefixed with "/".
|
||||||
|
// The first slash will be deleted when the path is normalized.
|
||||||
|
export function path2id(filename: string): string {
|
||||||
|
let x = normalizePath(filename);
|
||||||
|
if (x.startsWith("_")) x = "/" + x;
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
export function id2path(filename: string): string {
|
||||||
|
return normalizePath(filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
const runningProcs: string[] = [];
|
||||||
|
const pendingProcs: { [key: string]: (() => Promise<void>)[] } = {};
|
||||||
|
function objectToKey(key: any): string {
|
||||||
|
if (typeof key === "string") return key;
|
||||||
|
const keys = Object.keys(key).sort((a, b) => a.localeCompare(b));
|
||||||
|
return keys.map((e) => e + objectToKey(key[e])).join(":");
|
||||||
|
}
|
||||||
|
// Just run some async/await as like transacion SERIALIZABLE
|
||||||
|
|
||||||
|
export function runWithLock<T>(key: unknown, ignoreWhenRunning: boolean, proc: () => Promise<T>): Promise<T> {
|
||||||
|
Logger(`Lock:${key}:enter`, LOG_LEVEL.VERBOSE);
|
||||||
|
const lockKey = typeof key === "string" ? key : objectToKey(key);
|
||||||
|
const handleNextProcs = () => {
|
||||||
|
if (typeof pendingProcs[lockKey] === "undefined") {
|
||||||
|
//simply unlock
|
||||||
|
runningProcs.remove(lockKey);
|
||||||
|
Logger(`Lock:${lockKey}:released`, LOG_LEVEL.VERBOSE);
|
||||||
|
} else {
|
||||||
|
Logger(`Lock:${lockKey}:left ${pendingProcs[lockKey].length}`, LOG_LEVEL.VERBOSE);
|
||||||
|
let nextProc = null;
|
||||||
|
nextProc = pendingProcs[lockKey].shift();
|
||||||
|
if (nextProc) {
|
||||||
|
// left some
|
||||||
|
nextProc()
|
||||||
|
.then()
|
||||||
|
.catch((err) => {
|
||||||
|
Logger(err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (pendingProcs && lockKey in pendingProcs && pendingProcs[lockKey].length == 0) {
|
||||||
|
delete pendingProcs[lockKey];
|
||||||
|
}
|
||||||
|
queueMicrotask(() => {
|
||||||
|
handleNextProcs();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (runningProcs.contains(lockKey)) {
|
||||||
|
if (ignoreWhenRunning) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof pendingProcs[lockKey] === "undefined") {
|
||||||
|
pendingProcs[lockKey] = [];
|
||||||
|
}
|
||||||
|
let responderRes: (value: T | PromiseLike<T>) => void;
|
||||||
|
let responderRej: (reason?: unknown) => void;
|
||||||
|
const responder = new Promise<T>((res, rej) => {
|
||||||
|
responderRes = res;
|
||||||
|
responderRej = rej;
|
||||||
|
//wait for subproc resolved
|
||||||
|
});
|
||||||
|
const subproc = () =>
|
||||||
|
new Promise<void>((res, rej) => {
|
||||||
|
proc()
|
||||||
|
.then((v) => {
|
||||||
|
Logger(`Lock:${key}:processed`, LOG_LEVEL.VERBOSE);
|
||||||
|
handleNextProcs();
|
||||||
|
responderRes(v);
|
||||||
|
res();
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
Logger(`Lock:${key}:rejected`, LOG_LEVEL.VERBOSE);
|
||||||
|
handleNextProcs();
|
||||||
|
rej(reason);
|
||||||
|
responderRej(reason);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
pendingProcs[lockKey].push(subproc);
|
||||||
|
return responder;
|
||||||
|
} else {
|
||||||
|
runningProcs.push(lockKey);
|
||||||
|
Logger(`Lock:${lockKey}:aqquired`, LOG_LEVEL.VERBOSE);
|
||||||
|
return new Promise((res, rej) => {
|
||||||
|
proc()
|
||||||
|
.then((v) => {
|
||||||
|
handleNextProcs();
|
||||||
|
res(v);
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
handleNextProcs();
|
||||||
|
rej(reason);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/utils_couchdb.ts
Normal file
70
src/utils_couchdb.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { Logger } from "./logger";
|
||||||
|
import { LOG_LEVEL, VER, VERSIONINFO_DOCID, EntryVersionInfo, EntryDoc } from "./types";
|
||||||
|
import { resolveWithIgnoreKnownError } from "./utils";
|
||||||
|
import { PouchDB } from "../pouchdb-browser-webpack/dist/pouchdb-browser.js";
|
||||||
|
|
||||||
|
export const isValidRemoteCouchDBURI = (uri: string): boolean => {
|
||||||
|
if (uri.startsWith("https://")) return true;
|
||||||
|
if (uri.startsWith("http://")) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
export const connectRemoteCouchDB = async (uri: string, auth: { username: string; password: string }): Promise<string | { db: PouchDB.Database<EntryDoc>; info: PouchDB.Core.DatabaseInfo }> => {
|
||||||
|
if (!isValidRemoteCouchDBURI(uri)) return "Remote URI is not valid";
|
||||||
|
const db: PouchDB.Database<EntryDoc> = new PouchDB<EntryDoc>(uri, {
|
||||||
|
auth,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const info = await db.info();
|
||||||
|
return { db: db, info: info };
|
||||||
|
} catch (ex) {
|
||||||
|
let msg = `${ex.name}:${ex.message}`;
|
||||||
|
if (ex.name == "TypeError" && ex.message == "Failed to fetch") {
|
||||||
|
msg += "\n**Note** This error caused by many reasons. The only sure thing is you didn't touch the server.\nTo check details, open inspector.";
|
||||||
|
}
|
||||||
|
Logger(ex, LOG_LEVEL.VERBOSE);
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// check the version of remote.
|
||||||
|
// if remote is higher than current(or specified) version, return false.
|
||||||
|
export const checkRemoteVersion = async (db: PouchDB.Database, migrate: (from: number, to: number) => Promise<boolean>, barrier: number = VER): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const versionInfo = (await db.get(VERSIONINFO_DOCID)) as EntryVersionInfo;
|
||||||
|
if (versionInfo.type != "versioninfo") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = versionInfo.version;
|
||||||
|
if (version < barrier) {
|
||||||
|
const versionUpResult = await migrate(version, barrier);
|
||||||
|
if (versionUpResult) {
|
||||||
|
await bumpRemoteVersion(db);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (version == barrier) return true;
|
||||||
|
return false;
|
||||||
|
} catch (ex) {
|
||||||
|
if (ex.status && ex.status == 404) {
|
||||||
|
if (await bumpRemoteVersion(db)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export const bumpRemoteVersion = async (db: PouchDB.Database, barrier: number = VER): Promise<boolean> => {
|
||||||
|
const vi: EntryVersionInfo = {
|
||||||
|
_id: VERSIONINFO_DOCID,
|
||||||
|
version: barrier,
|
||||||
|
type: "versioninfo",
|
||||||
|
};
|
||||||
|
const versionInfo = (await resolveWithIgnoreKnownError(db.get(VERSIONINFO_DOCID), vi)) as EntryVersionInfo;
|
||||||
|
if (versionInfo.type != "versioninfo") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
vi._rev = versionInfo._rev;
|
||||||
|
await db.put(vi);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
@@ -136,3 +136,7 @@ div.sls-setting-menu-btn {
|
|||||||
flex-grow: 0;
|
flex-grow: 0;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
}
|
}
|
||||||
|
.sls-plugins-tbl-device-head {
|
||||||
|
background-color: var(--background-secondary-alt);
|
||||||
|
color: var(--text-accent);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,9 +9,13 @@
|
|||||||
"noImplicitAny": true,
|
"noImplicitAny": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"importHelpers": true,
|
"importHelpers": true,
|
||||||
"lib": ["dom", "es5", "scripthost", "es2015"]
|
"noImplicitReturns": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"strictFunctionTypes": true,
|
||||||
|
"alwaysStrict": true,
|
||||||
|
"lib": ["dom", "es5", "ES6", "ES7", "es2020"]
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts"],
|
"include": ["./src/*.ts"],
|
||||||
"files": ["./main.ts"],
|
// "files": ["./src/main.ts"],
|
||||||
"exclude": ["pouchdb-browser-webpack"]
|
"exclude": ["pouchdb-browser-webpack"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user