mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2025-02-10 19:41:57 +02: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:
parent
eef2281ae3
commit
1b2f9dd171
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",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "0.1.26",
|
||||
"version": "0.1.27",
|
||||
"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.",
|
||||
"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",
|
||||
"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.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"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": [],
|
||||
"author": "vorotamoroz",
|
||||
@ -16,6 +17,11 @@
|
||||
"@rollup/plugin-typescript": "^8.2.1",
|
||||
"@types/diff-match-patch": "^1.0.32",
|
||||
"@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",
|
||||
"rollup": "^2.32.1",
|
||||
"tslib": "^2.2.0",
|
||||
|
@ -11,7 +11,7 @@ if you want to view the source visit the plugins github repository
|
||||
`;
|
||||
|
||||
export default {
|
||||
input: "main.ts",
|
||||
input: "./src/main.ts",
|
||||
output: {
|
||||
dir: ".",
|
||||
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;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.sls-plugins-tbl-device-head {
|
||||
background-color: var(--background-secondary-alt);
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
@ -9,9 +9,13 @@
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"lib": ["dom", "es5", "scripthost", "es2015"]
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"strictFunctionTypes": true,
|
||||
"alwaysStrict": true,
|
||||
"lib": ["dom", "es5", "ES6", "ES7", "es2020"]
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"files": ["./main.ts"],
|
||||
"include": ["./src/*.ts"],
|
||||
// "files": ["./src/main.ts"],
|
||||
"exclude": ["pouchdb-browser-webpack"]
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user