Compare commits

...

21 Commits
0.4.1 ... 0.8.8

Author SHA1 Message Date
vorotamoroz
88ef7c316a Fixed:
- Do not show error message when synchronization run automatically .
2022-05-09 11:08:10 +09:00
vorotamoroz
3fbecdf567 Fixed:
- Newly created files could not be synchronized.
2022-05-08 00:02:34 +09:00
vorotamoroz
5db3a374a9 Fixed:
- Freezing LiveSync on mobile devices.
2022-05-06 18:14:45 +09:00
vorotamoroz
6f76f90075 - Reverted PouchDB direct importing.
(I completely forgot why I webpacked.)
- Submodule re-init
2022-04-30 01:11:17 +09:00
vorotamoroz
9acf9fe093 remove wrong submodule 2022-04-30 00:46:14 +09:00
vorotamoroz
1e3de47d92 Update manifest.json 2022-04-28 18:43:46 +09:00
vorotamoroz
a50f0965f6 Refactored and touched up some.
Not available on iOS yet, be careful!
2022-04-28 18:24:48 +09:00
vorotamoroz
9d3aa35b0b Fixed:
- Problems around new request API's
2022-04-20 15:02:06 +09:00
vorotamoroz
b4b9684a55 Fixed:
- Failure on the first sync
2022-04-07 16:17:20 +09:00
vorotamoroz
221cccb845 bumped 2022-04-04 20:01:50 +09:00
vorotamoroz
801500f924 Fixed:
- Fixed merging issue (Concat both)
- Overdetection of file change after the replication
2022-04-04 19:58:44 +09:00
vorotamoroz
3545ae9690 Implemented:
- using Obsidian API to synchronize.
- Copy button on history dialog.

Documented:
- Document improved.
2022-04-01 17:57:14 +09:00
vorotamoroz
255e7bf828 bumped 2022-03-08 10:40:11 +09:00
vorotamoroz
6f9e7bbcf4 Merge pull request #49 from banool/main
Print exception on failure in certain cases
2022-03-08 10:31:25 +09:00
Daniel Porteous
ce1c94a814 Print exception on failure in certain cases 2022-03-06 16:13:57 -08:00
vorotamoroz
caf7934f28 Create FUNDING.yml 2022-02-25 13:14:24 +09:00
vorotamoroz
31ab0e90f6 Fixed:
- Device and vault name is now not stored in the data.json.
You can synchronize LiveSync's configuration!
2022-02-18 20:10:43 +09:00
vorotamoroz
43fba807c3 Implemented: New "plugins and their settings"
Fixed: some plugin synchronization bugs.
2022-02-16 18:26:13 +09:00
vorotamoroz
3a8e52425e Fixed:
- Some extensions are encoded incorrectly.
2022-01-27 12:15:23 +09:00
vorotamoroz
15b580aa9a Implemented:
- History dialog

Improved:
- Speed up Garbage Collection.
2022-01-13 17:41:45 +09:00
vorotamoroz
ebcb059d99 Modified:
- Plugins and settings is now in beta.

Implemented:
- Show the count of the pending processes into the status.
2022-01-11 13:17:35 +09:00
29 changed files with 2268 additions and 11139 deletions

3
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,3 @@
# These are supported funding model platforms
github: vrtmrz

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "src/lib"]
path = src/lib
url = https://github.com/vrtmrz/livesync-commonlib

View File

@@ -75,6 +75,8 @@ Synchronization status is shown in statusbar.
- ⚠ Error occurred.
- ↑ Uploaded pieces
- ↓ Downloaded pieces
- ⏳ Count of the pending process
If you have deleted or renamed files, please wait until this disappears.
# More supplements

View File

@@ -78,6 +78,8 @@ Self-hosted LiveSync用にWebClipperも作りました。Chrome Web Storeから
- ⚠ エラーが発生しています
- ↑ 送信したデータ数
- ↓ 受信したデータ数
- ⏳ 保留している処理の数です
ファイルを削除したりリネームした場合、この表示が消えるまでお待ちください。
# さらなる補足
- ファイルは同期された後、タイムスタンプを比較して新しければいったん新しい方で上書きされます。その後、衝突が発生したかによって、マージが行われます。

View File

@@ -18,7 +18,14 @@ Note: This password is saved into your Obsidian's vault in plain text.
The Database name to synchronize.
If not exist, created automatically.
### Use the old connecting method
Since v0.8.0, Self-hosted LiveSync uses Obsidian's API to connect to the CouchDB instead of the browser API.
This method will increase the performance and avoid troubles with the CORS.
But it doesn't been well tested yet. If you are troubled, please disable this option once.
### Test Database connection
You can check the connection by clicking this button.
## Local Database Configurations
"Local Database" is created inside your obsidian.
@@ -44,6 +51,8 @@ As a result, Obsidian's behavior is temporarily slowed down.
Default is 300 seconds.
If you are an early adopter, maybe this value is left as 30 seconds. Please change this value to larger values.
Note: If you want to use "Use history", this vault must be set to 0.
### Manual Garbage Collect
Run "Garbage Collection" manually.
@@ -52,6 +61,8 @@ Encrypt your database. It affects only the database, your files are left as plai
The encryption algorithm is AES-GCM.
Note: If you want to use "Plugins and their settings", you have to enable this.
### Passphrase
The passphrase to used as the key of encryption. Please use the long text.
@@ -195,6 +206,29 @@ You can set synchronization method at once as these pattern:
- Sync on File Open : disabled
- Sync on Start : disabled
### Use history
If you enable this option, you can keep document histories in your database.
(Not all intermediate changes are synchronized.)
You can check the changes caused by your edit and/or replication.
### Enable plugin synchronization
If you want to use this feature, you have to activate this feature by this switch.
### Sweep plugins automatically
Plugin sweep will run before replication automatically.
### Sweep plugins periodically
Plugin sweep will run each 1 minute.
### Notify updates
When replication is complete, a message will be notified if a newer version of the plugin applied to this device is configured on another device.
### Device and Vault name
To save the plugins, you have to set a unique name every each device.
### Open
Open the "Plugins and their settings" dialog.
## Hatch
From here, everything is under the hood. Please handle it with care.

37
esbuild.config.mjs Normal file
View File

@@ -0,0 +1,37 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
import sveltePlugin from "esbuild-svelte";
import sveltePreprocess from "svelte-preprocess";
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = process.argv[2] === "production";
esbuild
.build({
banner: {
js: banner,
},
entryPoints: ["src/main.ts"],
bundle: true,
external: ["obsidian", "electron", ...builtins],
format: "cjs",
watch: !prod,
target: "es2015",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
plugins: [
sveltePlugin({
preprocess: sveltePreprocess(),
compilerOptions: { css: true },
}),
],
outfile: "main.js",
})
.catch(() => process.exit(1));

View File

@@ -1,7 +1,7 @@
{
"id": "obsidian-livesync",
"name": "Self-hosted LiveSync",
"version": "0.4.1",
"version": "0.8.8",
"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",

984
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,12 @@
{
"name": "obsidian-livesync",
"version": "0.4.1",
"version": "0.8.8",
"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",
"type": "module",
"scripts": {
"dev": "rollup --config rollup.config.js -w",
"build": "rollup --config rollup.config.js --environment BUILD:production",
"dev": "node esbuild.config.mjs",
"build": "node esbuild.config.mjs production",
"lint": "eslint src"
},
"keywords": [],
@@ -16,19 +17,27 @@
"@rollup/plugin-node-resolve": "^11.2.1",
"@rollup/plugin-typescript": "^8.2.1",
"@types/diff-match-patch": "^1.0.32",
"@types/pouchdb": "^6.4.0",
"@types/pouchdb-browser": "^6.1.3",
"@typescript-eslint/eslint-plugin": "^5.7.0",
"@typescript-eslint/parser": "^5.0.0",
"builtin-modules": "^3.2.0",
"esbuild": "0.13.12",
"esbuild-svelte": "^0.6.0",
"eslint": "^7.32.0",
"eslint-config-airbnb-base": "^14.2.1",
"eslint-plugin-import": "^2.25.2",
"obsidian": "^0.13.11",
"obsidian": "^0.14.6",
"rollup": "^2.32.1",
"svelte-preprocess": "^4.10.2",
"tslib": "^2.2.0",
"typescript": "^4.2.4"
},
"dependencies": {
"diff-match-patch": "^1.0.5",
"esbuild": "0.13.12",
"esbuild-svelte": "^0.6.0",
"svelte-preprocess": "^4.10.2",
"xxhash-wasm": "^0.4.2"
}
}

View File

@@ -1,2 +1,4 @@
# PouchDB-browser
just webpacked.
Just webpacked.
(Rollup couldn't pack pouchdb-browser into browser bundle)

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"pouchdb-browser": "^7.2.2"
"pouchdb-browser": "^7.3.0"
},
"devDependencies": {
"webpack": "^5.58.1",

View File

@@ -1,7 +1,7 @@
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";
import { diff_result } from "./lib/src/types";
import { escapeStringToHTML } from "./lib/src/utils";
export class ConflictResolveModal extends Modal {
// result: Array<[number, string]>;

146
src/DocumentHistoryModal.ts Normal file
View File

@@ -0,0 +1,146 @@
import { TFile, Modal, App } from "obsidian";
import { path2id } from "./utils";
import { escapeStringToHTML } from "./lib/src/utils";
import ObsidianLiveSyncPlugin from "./main";
import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_patch } from "diff-match-patch";
import { LOG_LEVEL } from "./lib/src/types";
import { Logger } from "./lib/src/logger";
export class DocumentHistoryModal extends Modal {
plugin: ObsidianLiveSyncPlugin;
range: HTMLInputElement;
contentView: HTMLDivElement;
info: HTMLDivElement;
fileInfo: HTMLDivElement;
showDiff = false;
file: string;
revs_info: PouchDB.Core.RevisionInfo[] = [];
currentText = "";
constructor(app: App, plugin: ObsidianLiveSyncPlugin, file: TFile) {
super(app);
this.plugin = plugin;
this.file = file.path;
if (localStorage.getItem("ols-history-highlightdiff") == "1") {
this.showDiff = true;
}
}
async loadFile() {
const db = this.plugin.localDatabase;
const w = await db.localDatabase.get(path2id(this.file), { revs_info: true });
this.revs_info = w._revs_info.filter((e) => e.status == "available");
this.range.max = `${this.revs_info.length - 1}`;
this.range.value = this.range.max;
this.fileInfo.setText(`${this.file} / ${this.revs_info.length} revisions`);
await this.loadRevs();
}
async loadRevs() {
const db = this.plugin.localDatabase;
const index = this.revs_info.length - 1 - (this.range.value as any) / 1;
const rev = this.revs_info[index];
const w = await db.getDBEntry(path2id(this.file), { rev: rev.rev }, false, false);
this.currentText = "";
if (w === false) {
this.info.innerHTML = "";
this.contentView.innerHTML = `Could not read this revision<br>(${rev.rev})`;
} else {
this.info.innerHTML = `Modified:${new Date(w.mtime).toLocaleString()}`;
let result = "";
this.currentText = w.data;
if (this.showDiff) {
const prevRevIdx = this.revs_info.length - 1 - ((this.range.value as any) / 1 - 1);
if (prevRevIdx >= 0 && prevRevIdx < this.revs_info.length) {
const oldRev = this.revs_info[prevRevIdx].rev;
const w2 = await db.getDBEntry(path2id(this.file), { rev: oldRev }, false, false);
if (w2 != false) {
const dmp = new diff_match_patch();
const diff = dmp.diff_main(w2.data, w.data);
dmp.diff_cleanupSemantic(diff);
for (const v of diff) {
const x1 = v[0];
const x2 = v[1];
if (x1 == DIFF_DELETE) {
result += "<span class='history-deleted'>" + escapeStringToHTML(x2) + "</span>";
} else if (x1 == DIFF_EQUAL) {
result += "<span class='history-normal'>" + escapeStringToHTML(x2) + "</span>";
} else if (x1 == DIFF_INSERT) {
result += "<span class='history-added'>" + escapeStringToHTML(x2) + "</span>";
}
}
result = result.replace(/\n/g, "<br>");
} else {
result = escapeStringToHTML(w.data);
}
} else {
result = escapeStringToHTML(w.data);
}
} else {
result = escapeStringToHTML(w.data);
}
this.contentView.innerHTML = result;
}
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h2", { text: "Document History" });
this.fileInfo = contentEl.createDiv("");
this.fileInfo.addClass("op-info");
const divView = contentEl.createDiv("");
divView.addClass("op-flex");
divView.createEl("input", { type: "range" }, (e) => {
this.range = e;
e.addEventListener("change", (e) => {
this.loadRevs();
});
e.addEventListener("input", (e) => {
this.loadRevs();
});
});
contentEl
.createDiv("", (e) => {
e.createEl("label", {}, (label) => {
label.appendChild(
createEl("input", { type: "checkbox" }, (checkbox) => {
if (this.showDiff) {
checkbox.checked = true;
}
checkbox.addEventListener("input", (evt: any) => {
this.showDiff = checkbox.checked;
localStorage.setItem("ols-history-highlightdiff", this.showDiff == true ? "1" : "");
this.loadRevs();
});
})
);
label.appendText("Highlight diff");
});
})
.addClass("op-info");
this.info = contentEl.createDiv("");
this.info.addClass("op-info");
this.loadFile();
const div = contentEl.createDiv({ text: "Loading old revisions..." });
this.contentView = div;
div.addClass("op-scrollable");
div.addClass("op-pre");
const buttons = contentEl.createDiv("");
buttons.createEl("button", { text: "Copy to clipboard" }, (e) => {
e.addClass("mod-cta");
e.addEventListener("click", async () => {
await navigator.clipboard.writeText(this.currentText);
Logger(`Old content copied to clipboard`, LOG_LEVEL.NOTICE);
});
});
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}

View File

@@ -1,5 +1,4 @@
import { Notice } from "obsidian";
import { PouchDB } from "../pouchdb-browser-webpack/dist/pouchdb-browser.js";
import { PouchDB } from "./pouchdb-browser";
import xxhash from "xxhash-wasm";
import {
Entry,
@@ -10,7 +9,6 @@ import {
NewEntry,
PlainEntry,
LoadedEntry,
ObsidianLiveSyncSettings,
Credential,
EntryMilestoneInfo,
LOG_LEVEL,
@@ -22,16 +20,20 @@ import {
VER,
MILSTONE_DOCID,
DatabaseConnectingStatus,
} from "./types";
import { resolveWithIgnoreKnownError, delay, path2id, runWithLock } from "./utils";
import { Logger } from "./logger";
} from "./lib/src/types";
import { decrypt, encrypt } from "./lib/src/e2ee";
import { RemoteDBSettings } from "./lib/src/types";
import { resolveWithIgnoreKnownError, delay, runWithLock, isPlainText, splitPieces, NewNotice, WrappedNotice } from "./lib/src/utils";
import { path2id } from "./utils";
import { Logger } from "./lib/src/logger";
import { checkRemoteVersion, connectRemoteCouchDB, getLastPostFailedBySize } from "./utils_couchdb";
import { decrypt, encrypt } from "./e2ee";
type ReplicationCallback = (e: PouchDB.Core.ExistingDocument<EntryDoc>[]) => Promise<void>;
export class LocalPouchDB {
auth: Credential;
dbname: string;
settings: ObsidianLiveSyncSettings;
settings: RemoteDBSettings;
localDatabase: PouchDB.Database<EntryDoc>;
nodeid = "";
isReady = false;
@@ -52,7 +54,7 @@ export class LocalPouchDB {
remoteLockedAndDeviceNotAccepted = false;
changeHandler: PouchDB.Core.Changes<EntryDoc> = null;
syncHandler: PouchDB.Replication.Sync<EntryDoc> = null;
syncHandler: PouchDB.Replication.Sync<EntryDoc> | PouchDB.Replication.Replication<EntryDoc> = null;
leafArrivedCallbacks: { [key: string]: (() => void)[] } = {};
@@ -61,6 +63,8 @@ export class LocalPouchDB {
docSent = 0;
docSeq = "";
isMobile = false;
cancelHandler<T extends PouchDB.Core.Changes<EntryDoc> | PouchDB.Replication.Sync<EntryDoc> | PouchDB.Replication.Replication<EntryDoc>>(handler: T): T {
if (handler != null) {
handler.removeAllListeners();
@@ -77,7 +81,7 @@ export class LocalPouchDB {
this.localDatabase.removeAllListeners();
}
constructor(settings: ObsidianLiveSyncSettings, dbname: string) {
constructor(settings: RemoteDBSettings, dbname: string, isMobile: boolean) {
this.auth = {
username: "",
password: "",
@@ -85,6 +89,7 @@ export class LocalPouchDB {
this.dbname = dbname;
this.settings = settings;
this.cancelHandler = this.cancelHandler.bind(this);
this.isMobile = isMobile;
// this.initializeDatabase();
}
@@ -121,7 +126,7 @@ export class LocalPouchDB {
this.changeHandler = this.cancelHandler(this.changeHandler);
this.localDatabase = null;
this.localDatabase = new PouchDB<EntryDoc>(this.dbname + "-livesync", {
auto_compaction: true,
auto_compaction: this.settings.useHistory ? false : true,
revs_limit: 100,
deterministic_revs: true,
});
@@ -357,7 +362,7 @@ export class LocalPouchDB {
Logger(childrens);
}
} catch (ex) {
Logger(`Something went wrong on reading elements of ${obj._id} from database.`, LOG_LEVEL.NOTICE);
Logger(`Something went wrong on reading elements of ${obj._id} from database:`, LOG_LEVEL.NOTICE);
Logger(ex, LOG_LEVEL.VERBOSE);
this.corruptedEntries[obj._id] = obj;
return false;
@@ -388,7 +393,7 @@ export class LocalPouchDB {
Logger(`Missing document content!, could not read ${obj._id} from database.`, LOG_LEVEL.NOTICE);
return false;
}
Logger(`Something went wrong on reading ${obj._id} from database.`, LOG_LEVEL.NOTICE);
Logger(`Something went wrong on reading ${obj._id} from database:`, LOG_LEVEL.NOTICE);
Logger(ex);
}
}
@@ -501,21 +506,9 @@ export class LocalPouchDB {
Logger(`deleteDBEntryPrefix:deleted ${deleteCount} items, skipped ${notfound}`);
return true;
}
isPlainText(filename: string): boolean {
if (filename.endsWith(".md")) return true;
if (filename.endsWith(".txt")) return true;
if (filename.endsWith(".svg")) return true;
if (filename.endsWith(".html")) return true;
if (filename.endsWith(".csv")) return true;
if (filename.endsWith(".css")) return true;
if (filename.endsWith(".js")) return true;
if (filename.endsWith(".xml")) return true;
return false;
}
async putDBEntry(note: LoadedEntry) {
await this.waitForGCComplete();
let leftData = note.data;
// let leftData = note.data;
const savenNotes = [];
let processed = 0;
let made = 0;
@@ -524,57 +517,26 @@ export class LocalPouchDB {
let plainSplit = false;
let cacheUsed = 0;
const userpasswordHash = this.h32Raw(new TextEncoder().encode(this.settings.passphrase));
if (this.isPlainText(note._id)) {
if (isPlainText(note._id)) {
pieceSize = MAX_DOC_SIZE;
plainSplit = true;
}
const newLeafs: EntryLeaf[] = [];
do {
// To keep low bandwith and database size,
// Dedup pieces on database.
// from 0.1.10, for best performance. we use markdown delimiters
// 1. \n[^\n]{longLineThreshold}[^\n]*\n -> long sentence shuld break.
// 2. \n\n shold break
// 3. \r\n\r\n should break
// 4. \n# should break.
let cPieceSize = pieceSize;
if (plainSplit) {
let minimumChunkSize = this.settings.minimumChunkSize;
if (minimumChunkSize < 10) minimumChunkSize = 10;
let longLineThreshold = this.settings.longLineThreshold;
if (longLineThreshold < 100) longLineThreshold = 100;
cPieceSize = 0;
// lookup for next splittion .
// we're standing on "\n"
do {
const n1 = leftData.indexOf("\n", cPieceSize + 1);
const n2 = leftData.indexOf("\n\n", cPieceSize + 1);
const n3 = leftData.indexOf("\r\n\r\n", cPieceSize + 1);
const n4 = leftData.indexOf("\n#", cPieceSize + 1);
if (n1 == -1 && n2 == -1 && n3 == -1 && n4 == -1) {
cPieceSize = MAX_DOC_SIZE;
break;
}
// To keep low bandwith and database size,
// Dedup pieces on database.
// from 0.1.10, for best performance. we use markdown delimiters
// 1. \n[^\n]{longLineThreshold}[^\n]*\n -> long sentence shuld break.
// 2. \n\n shold break
// 3. \r\n\r\n should break
// 4. \n# should break.
let minimumChunkSize = this.settings.minimumChunkSize;
if (minimumChunkSize < 10) minimumChunkSize = 10;
let longLineThreshold = this.settings.longLineThreshold;
if (longLineThreshold < 100) longLineThreshold = 100;
if (n1 > longLineThreshold) {
// long sentence is an established piece
cPieceSize = n1;
} else {
// cPieceSize = Math.min.apply([n2, n3, n4].filter((e) => e > 1));
// ^ heavy.
if (n1 > 0 && cPieceSize < n1) cPieceSize = n1;
if (n2 > 0 && cPieceSize < n2) cPieceSize = n2 + 1;
if (n3 > 0 && cPieceSize < n3) cPieceSize = n3 + 3;
// Choose shorter, empty line and \n#
if (n4 > 0 && cPieceSize > n4) cPieceSize = n4 + 0;
cPieceSize++;
}
} while (cPieceSize < minimumChunkSize);
}
// piece size determined.
const piece = leftData.substring(0, cPieceSize);
leftData = leftData.substring(cPieceSize);
const pieces = splitPieces(note.data, pieceSize, plainSplit, minimumChunkSize, longLineThreshold);
for (const piece of pieces()) {
processed++;
let leafid = "";
// Get hash of piece.
@@ -606,7 +568,7 @@ export class LocalPouchDB {
try {
pieceData.data = await decrypt(pieceData.data, this.settings.passphrase);
} catch (e) {
Logger("Decode failed !");
Logger("Decode failed!");
throw e;
}
}
@@ -658,7 +620,7 @@ export class LocalPouchDB {
}
}
savenNotes.push(leafid);
} while (leftData != "");
}
let saved = true;
if (newLeafs.length > 0) {
try {
@@ -679,8 +641,8 @@ export class LocalPouchDB {
}
}
} catch (ex) {
Logger("ERROR ON SAVING LEAVES ");
Logger(ex);
Logger("ERROR ON SAVING LEAVES:", LOG_LEVEL.NOTICE);
Logger(ex, LOG_LEVEL.NOTICE);
saved = false;
}
}
@@ -739,80 +701,25 @@ export class LocalPouchDB {
// no op now,
return true;
}
replicateAllToServer(setting: ObsidianLiveSyncSettings, showingNotice?: boolean) {
replicateAllToServer(setting: RemoteDBSettings, showingNotice?: boolean) {
return new Promise(async (res, rej) => {
await this.waitForGCComplete();
this.closeReplication();
Logger("send all data to server", LOG_LEVEL.NOTICE);
let notice: Notice = null;
if (showingNotice) {
notice = new Notice("Initializing", 0);
}
this.syncStatus = "STARTED";
this.updateInfo();
const uri = setting.couchDB_URI + (setting.couchDB_DBNAME == "" ? "" : "/" + setting.couchDB_DBNAME);
const auth: Credential = {
username: setting.couchDB_USER,
password: setting.couchDB_PASSWORD,
};
const dbret = await connectRemoteCouchDB(uri, auth);
if (typeof dbret === "string") {
Logger(`could not connect to ${uri}:${dbret}`, LOG_LEVEL.NOTICE);
if (notice != null) notice.hide();
return rej(`could not connect to ${uri}:${dbret}`);
}
const syncOptionBase: PouchDB.Replication.SyncOptions = {
pull: {
checkpoint: "target",
},
push: {
checkpoint: "source",
},
batches_limit: setting.batches_limit,
batch_size: setting.batch_size,
};
const db = dbret.db;
const totalCount = (await this.localDatabase.info()).doc_count;
//replicate once
const replicate = this.localDatabase.replicate.to(db, { checkpoint: "source", ...syncOptionBase });
replicate
.on("active", () => {
this.syncStatus = "CONNECTED";
this.updateInfo();
if (notice) {
notice.setMessage("CONNECTED");
}
})
.on("change", (e) => {
// no op.
this.docSent += e.docs.length;
this.updateInfo();
notice.setMessage(`SENDING:${e.docs_written}/${totalCount}`);
Logger(`replicateAllToServer: sending..:${e.docs.length}`);
})
.on("complete", (info) => {
this.syncStatus = "COMPLETED";
this.updateInfo();
Logger("replicateAllToServer: Completed", LOG_LEVEL.NOTICE);
this.cancelHandler(replicate);
if (notice != null) notice.hide();
res(true);
})
.on("error", (e) => {
this.syncStatus = "ERRORED";
this.updateInfo();
Logger("replicateAllToServer: Pulling Replication error", LOG_LEVEL.INFO);
Logger(e);
this.cancelHandler(replicate);
if (notice != null) notice.hide();
this.openOneshotReplication(
setting,
showingNotice,
async (e) => { },
false,
(e) => {
if (e === true) res(e);
rej(e);
});
},
true,
false
);
});
}
async checkReplicationConnectivity(setting: ObsidianLiveSyncSettings, keepAlive: boolean, skipCheck: boolean) {
async checkReplicationConnectivity(setting: RemoteDBSettings, keepAlive: boolean, skipCheck: boolean, showResult: boolean) {
if (!this.isReady) {
Logger("Database is not ready.");
return false;
@@ -820,7 +727,7 @@ export class LocalPouchDB {
await this.waitForGCComplete();
if (setting.versionUpFlash != "") {
new Notice("Open settings and check message, please.");
NewNotice("Open settings and check message, please.");
return false;
}
const uri = setting.couchDB_URI + (setting.couchDB_DBNAME == "" ? "" : "/" + setting.couchDB_DBNAME);
@@ -832,9 +739,10 @@ export class LocalPouchDB {
Logger("Another replication running.");
return false;
}
const dbret = await connectRemoteCouchDB(uri, auth);
const dbret = await connectRemoteCouchDB(uri, auth, setting.disableRequestURI || this.isMobile);
if (typeof dbret === "string") {
Logger(`could not connect to ${uri}:${dbret}`, LOG_LEVEL.NOTICE);
Logger(`could not connect to ${uri}: ${dbret}`, showResult ? LOG_LEVEL.NOTICE : LOG_LEVEL.INFO);
return false;
}
@@ -851,15 +759,6 @@ export class LocalPouchDB {
locked: false,
accepted_nodes: [this.nodeid],
};
// const remoteInfo = dbret.info;
// const localInfo = await this.localDatabase.info();
// const remoteDocsCount = remoteInfo.doc_count;
// const localDocsCount = localInfo.doc_count;
// const remoteUpdSeq = typeof remoteInfo.update_seq == "string" ? Number(remoteInfo.update_seq.split("-")[0]) : remoteInfo.update_seq;
// const localUpdSeq = typeof localInfo.update_seq == "string" ? Number(localInfo.update_seq.split("-")[0]) : localInfo.update_seq;
// Logger(`Database diffences: remote:${remoteDocsCount} docs / last update ${remoteUpdSeq}`);
// Logger(`Database diffences: local :${localDocsCount} docs / last update ${localUpdSeq}`);
const remoteMilestone: EntryMilestoneInfo = await resolveWithIgnoreKnownError(dbret.db.get(MILSTONE_DOCID), defMilestonePoint);
this.remoteLocked = remoteMilestone.locked;
@@ -882,190 +781,256 @@ export class LocalPouchDB {
return { db: dbret.db, info: dbret.info, syncOptionBase, syncOption };
}
async openReplication(setting: ObsidianLiveSyncSettings, keepAlive: boolean, showResult: boolean, callback: (e: PouchDB.Core.ExistingDocument<EntryDoc>[]) => Promise<void>): Promise<boolean> {
return await runWithLock("replicate", false, () => {
return this._openReplication(setting, keepAlive, showResult, callback, false);
});
openReplication(setting: RemoteDBSettings, keepAlive: boolean, showResult: boolean, callback: (e: PouchDB.Core.ExistingDocument<EntryDoc>[]) => Promise<void>) {
if (keepAlive) {
this.openContinuousReplication(setting, showResult, callback, false);
} else {
this.openOneshotReplication(setting, showResult, callback, false, null, false, false);
}
}
replicationActivated(notice: WrappedNotice) {
this.syncStatus = "CONNECTED";
this.updateInfo();
Logger("Replication activated");
if (notice != null) notice.setMessage(`Activated..`);
}
async replicationChangeDetected(e: PouchDB.Replication.SyncResult<EntryDoc>, notice: WrappedNotice, docSentOnStart: number, docArrivedOnStart: number, callback: ReplicationCallback) {
try {
if (e.direction == "pull") {
await callback(e.change.docs);
Logger(`replicated ${e.change.docs_read} doc(s)`);
this.docArrived += e.change.docs.length;
} else {
this.docSent += e.change.docs.length;
}
if (notice != null) {
notice.setMessage(`${this.docSent - docSentOnStart}${this.docArrived - docArrivedOnStart}`);
}
this.updateInfo();
} catch (ex) {
Logger("Replication callback error", LOG_LEVEL.NOTICE);
Logger(ex, LOG_LEVEL.NOTICE);
//
}
}
replicationCompleted(notice: WrappedNotice, showResult: boolean) {
this.syncStatus = "COMPLETED";
this.updateInfo();
Logger("Replication completed", showResult ? LOG_LEVEL.NOTICE : LOG_LEVEL.INFO);
if (notice != null) notice.hide();
this.syncHandler = this.cancelHandler(this.syncHandler);
}
replicationDeniend(notice: WrappedNotice, e: any) {
this.syncStatus = "ERRORED";
this.updateInfo();
this.syncHandler = this.cancelHandler(this.syncHandler);
if (notice != null) notice.hide();
Logger("Replication denied", LOG_LEVEL.NOTICE);
Logger(e);
}
replicationErrored(notice: WrappedNotice, e: any) {
this.syncStatus = "ERRORED";
this.syncHandler = this.cancelHandler(this.syncHandler);
this.updateInfo();
}
replicationPaused(notice: WrappedNotice) {
this.syncStatus = "PAUSED";
this.updateInfo();
if (notice != null) notice.hide();
Logger("replication paused", LOG_LEVEL.VERBOSE);
}
originalSetting: ObsidianLiveSyncSettings = null;
// last_seq: number = 200;
async _openReplication(setting: ObsidianLiveSyncSettings, keepAlive: boolean, showResult: boolean, callback: (e: PouchDB.Core.ExistingDocument<EntryDoc>[]) => Promise<void>, retrying: boolean): Promise<boolean> {
const ret = await this.checkReplicationConnectivity(setting, keepAlive, retrying);
if (ret === false) return false;
let notice: Notice = null;
if (showResult) {
notice = new Notice("Looking for the point last synchronized point.", 0);
async openOneshotReplication(
setting: RemoteDBSettings,
showResult: boolean,
callback: (e: PouchDB.Core.ExistingDocument<EntryDoc>[]) => Promise<void>,
retrying: boolean,
callbackDone: (e: boolean | any) => void,
pushOnly: boolean,
pullOnly: boolean
): Promise<boolean> {
if (this.syncHandler != null) {
Logger("Replication is already in progress.", showResult ? LOG_LEVEL.NOTICE : LOG_LEVEL.INFO);
return;
}
const { db, syncOptionBase, syncOption } = ret;
//replicate once
Logger("Oneshot Sync begin...");
let thisCallback = callbackDone;
const ret = await this.checkReplicationConnectivity(setting, true, retrying, showResult);
let notice: WrappedNotice = null;
if (ret === false) {
Logger("Could not connect to server.", showResult ? LOG_LEVEL.NOTICE : LOG_LEVEL.INFO);
return;
}
if (showResult) {
notice = NewNotice("Looking for the point last synchronized point.", 0);
}
const { db, syncOptionBase } = ret;
this.syncStatus = "STARTED";
this.updateInfo();
let resolved = false;
const docArrivedOnStart = this.docArrived;
const docSentOnStart = this.docSent;
const _openReplicationSync = () => {
Logger("Sync Main Started");
if (!retrying) {
this.originalSetting = setting;
}
this.syncHandler = this.cancelHandler(this.syncHandler);
this.syncHandler = this.localDatabase.sync<EntryDoc>(db, {
...syncOption,
pull: {
checkpoint: "target",
},
push: {
checkpoint: "source",
},
});
if (!retrying) {
// If initial replication, save setting to rollback
this.originalSetting = setting;
}
this.syncHandler = this.cancelHandler(this.syncHandler);
if (!pushOnly && !pullOnly) {
this.syncHandler = this.localDatabase.sync(db, { checkpoint: "target", ...syncOptionBase });
this.syncHandler
.on("active", () => {
this.syncStatus = "CONNECTED";
this.updateInfo();
Logger("Replication activated");
if (notice != null) notice.setMessage(`Activated..`);
})
.on("change", async (e) => {
try {
if (e.direction == "pull") {
// console.log(`pulled data:${e.change.docs.map((e) => e._id).join(",")}`);
await callback(e.change.docs);
Logger(`replicated ${e.change.docs_read} doc(s)`);
this.docArrived += e.change.docs.length;
} else {
// console.log(`put data:${e.change.docs.map((e) => e._id).join(",")}`);
this.docSent += e.change.docs.length;
}
if (notice != null) {
notice.setMessage(`${this.docSent - docSentOnStart}${this.docArrived - docArrivedOnStart}`);
}
this.updateInfo();
} catch (ex) {
Logger("Replication callback error");
Logger(ex);
}
// re-connect to retry with original setting
await this.replicationChangeDetected(e, notice, docSentOnStart, docArrivedOnStart, callback);
if (retrying) {
if (this.docSent - docSentOnStart + (this.docArrived - docArrivedOnStart) > this.originalSetting.batch_size * 2) {
// restore sync values
// restore configration.
Logger("Back into original settings once.");
if (notice != null) notice.hide();
this.syncHandler = this.cancelHandler(this.syncHandler);
this._openReplication(this.originalSetting, keepAlive, showResult, callback, false);
this.openOneshotReplication(this.originalSetting, showResult, callback, false, callbackDone, pushOnly, pullOnly);
}
}
})
.on("complete", (e) => {
this.syncStatus = "COMPLETED";
this.updateInfo();
Logger("Replication completed", showResult ? LOG_LEVEL.NOTICE : LOG_LEVEL.INFO);
if (notice != null) notice.hide();
if (!keepAlive) {
this.syncHandler = this.cancelHandler(this.syncHandler);
// if keep alive runnning, resolve here,
this.replicationCompleted(notice, showResult);
if (thisCallback != null) {
thisCallback(true);
}
});
} else if (pullOnly) {
this.syncHandler = this.localDatabase.replicate.to(db, { checkpoint: "target", ...syncOptionBase });
this.syncHandler
.on("change", async (e) => {
await this.replicationChangeDetected({ direction: "pull", change: e }, notice, docSentOnStart, docArrivedOnStart, callback);
if (retrying) {
if (this.docSent - docSentOnStart + (this.docArrived - docArrivedOnStart) > this.originalSetting.batch_size * 2) {
// restore configration.
Logger("Back into original settings once.");
if (notice != null) notice.hide();
this.syncHandler = this.cancelHandler(this.syncHandler);
this.openOneshotReplication(this.originalSetting, showResult, callback, false, callbackDone, pushOnly, pullOnly);
}
}
})
.on("denied", (e) => {
this.syncStatus = "ERRORED";
this.updateInfo();
this.syncHandler = this.cancelHandler(this.syncHandler);
if (notice != null) notice.hide();
Logger("Replication denied", LOG_LEVEL.NOTICE);
.on("complete", (e) => {
this.replicationCompleted(notice, showResult);
if (thisCallback != null) {
thisCallback(true);
}
});
} else if (pushOnly) {
this.syncHandler = this.localDatabase.replicate.to(db, { checkpoint: "target", ...syncOptionBase });
this.syncHandler.on("complete", (e) => {
this.replicationCompleted(notice, showResult);
if (thisCallback != null) {
thisCallback(true);
}
});
}
this.syncHandler
.on("active", () => this.replicationActivated(notice))
.on("denied", (e) => {
this.replicationDeniend(notice, e);
if (thisCallback != null) {
thisCallback(e);
}
})
.on("error", (e) => {
this.replicationErrored(notice, e);
Logger("Replication stopped.", showResult ? LOG_LEVEL.NOTICE : LOG_LEVEL.INFO);
if (notice != null) notice.hide();
if (getLastPostFailedBySize()) {
// Duplicate settings for smaller batch.
const xsetting: RemoteDBSettings = JSON.parse(JSON.stringify(setting));
xsetting.batch_size = Math.ceil(xsetting.batch_size / 2) + 2;
xsetting.batches_limit = Math.ceil(xsetting.batches_limit / 2) + 2;
if (xsetting.batch_size <= 5 && xsetting.batches_limit <= 5) {
Logger("We can't replicate more lower value.", showResult ? LOG_LEVEL.NOTICE : LOG_LEVEL.INFO);
} else {
Logger(`Retry with lower batch size:${xsetting.batch_size}/${xsetting.batches_limit}`, showResult ? LOG_LEVEL.NOTICE : LOG_LEVEL.INFO);
thisCallback = null;
this.openOneshotReplication(xsetting, showResult, callback, true, callbackDone, pushOnly, pullOnly);
}
} else {
Logger("Replication error", LOG_LEVEL.NOTICE);
Logger(e);
})
.on("error", (e) => {
this.syncStatus = "ERRORED";
this.syncHandler = this.cancelHandler(this.syncHandler);
this.updateInfo();
if (notice != null) notice.hide();
if (getLastPostFailedBySize()) {
if (keepAlive) {
Logger("Replication stopped.", LOG_LEVEL.NOTICE);
} else {
// Duplicate settings for smaller batch.
const xsetting: ObsidianLiveSyncSettings = JSON.parse(JSON.stringify(setting));
xsetting.batch_size = Math.ceil(xsetting.batch_size / 2);
xsetting.batches_limit = Math.ceil(xsetting.batches_limit / 2);
if (xsetting.batch_size <= 3 || xsetting.batches_limit <= 3) {
Logger("We can't replicate more lower value.", showResult ? LOG_LEVEL.NOTICE : LOG_LEVEL.INFO);
} else {
Logger(`Retry with lower batch size:${xsetting.batch_size}/${xsetting.batches_limit}`, showResult ? LOG_LEVEL.NOTICE : LOG_LEVEL.INFO);
this._openReplication(xsetting, keepAlive, showResult, callback, true);
}
if (thisCallback != null) {
thisCallback(e);
}
})
.on("paused", (e) => this.replicationPaused(notice));
}
openContinuousReplication(setting: RemoteDBSettings, showResult: boolean, callback: (e: PouchDB.Core.ExistingDocument<EntryDoc>[]) => Promise<void>, retrying: boolean) {
if (this.syncHandler != null) {
Logger("Replication is already in progress.", showResult ? LOG_LEVEL.NOTICE : LOG_LEVEL.INFO);
return;
}
Logger("Before LiveSync, start OneShot once...");
this.openOneshotReplication(
setting,
showResult,
callback,
false,
async () => {
Logger("LiveSync begin...");
const ret = await this.checkReplicationConnectivity(setting, true, true, showResult);
let notice: WrappedNotice = null;
if (ret === false) {
Logger("Could not connect to server.", showResult ? LOG_LEVEL.NOTICE : LOG_LEVEL.INFO);
return;
}
if (showResult) {
notice = NewNotice("Looking for the point last synchronized point.", 0);
}
const { db, syncOption } = ret;
this.syncStatus = "STARTED";
this.updateInfo();
const docArrivedOnStart = this.docArrived;
const docSentOnStart = this.docSent;
if (!retrying) {
//TODO if successfly saven, roll back org setting.
this.originalSetting = setting;
}
this.syncHandler = this.cancelHandler(this.syncHandler);
this.syncHandler = this.localDatabase.sync<EntryDoc>(db, {
...syncOption,
pull: {
checkpoint: "target",
},
push: {
checkpoint: "source",
},
});
this.syncHandler
.on("active", () => this.replicationActivated(notice))
.on("change", async (e) => {
await this.replicationChangeDetected(e, notice, docSentOnStart, docArrivedOnStart, callback);
if (retrying) {
if (this.docSent - docSentOnStart + (this.docArrived - docArrivedOnStart) > this.originalSetting.batch_size * 2) {
// restore sync values
Logger("Back into original settings once.");
if (notice != null) notice.hide();
this.syncHandler = this.cancelHandler(this.syncHandler);
this.openContinuousReplication(this.originalSetting, showResult, callback, false);
}
}
} else {
Logger("Replication error", LOG_LEVEL.NOTICE);
Logger(e);
}
})
.on("paused", (e) => {
this.syncStatus = "PAUSED";
this.updateInfo();
if (notice != null) notice.hide();
Logger("replication paused", LOG_LEVEL.VERBOSE);
if (keepAlive && !resolved) {
// if keep alive runnning, resolve here,
resolved = true;
}
// Logger(e);
});
return this.syncHandler;
};
if (!keepAlive) {
await _openReplicationSync();
return true;
}
this.syncHandler = this.cancelHandler(this.syncHandler);
Logger("Pull before replicate.");
Logger(await this.localDatabase.info(), LOG_LEVEL.VERBOSE);
Logger(await db.info(), LOG_LEVEL.VERBOSE);
let replicate: PouchDB.Replication.Replication<EntryDoc>;
try {
replicate = this.localDatabase.replicate.from(db, { checkpoint: "target", ...syncOptionBase });
replicate
.on("active", () => {
this.syncStatus = "CONNECTED";
this.updateInfo();
Logger("Replication pull activated.");
})
.on("change", async (e) => {
// when in first run, replication will send us tombstone data
// and in normal cases, all leavs should sent before the entry that contains these item.
// so skip to completed all, we should treat all changes.
try {
await callback(e.docs);
this.docArrived += e.docs.length;
this.updateInfo();
Logger(`pulled ${e.docs.length} doc(s)`);
if (notice != null) {
notice.setMessage(`Replication pulled:${e.docs_read}`);
}
} catch (ex) {
Logger("Replication callback error");
Logger(ex);
}
});
this.syncStatus = "COMPLETED";
this.updateInfo();
this.cancelHandler(replicate);
this.syncHandler = this.cancelHandler(this.syncHandler);
Logger("Replication pull completed.");
_openReplicationSync();
return true;
} catch (ex) {
this.syncStatus = "ERRORED";
this.updateInfo();
Logger("Pulling Replication error", LOG_LEVEL.NOTICE);
this.cancelHandler(replicate);
this.syncHandler = this.cancelHandler(this.syncHandler);
if (notice != null) notice.hide();
throw ex;
}
})
.on("complete", (e) => this.replicationCompleted(notice, showResult))
.on("denied", (e) => this.replicationDeniend(notice, e))
.on("error", (e) => {
this.replicationErrored(notice, e);
Logger("Replication stopped.", LOG_LEVEL.NOTICE);
})
.on("paused", (e) => this.replicationPaused(notice));
},
false,
true
);
}
originalSetting: RemoteDBSettings = null;
closeReplication() {
this.syncStatus = "CLOSED";
this.updateInfo();
@@ -1085,41 +1050,42 @@ export class LocalPouchDB {
this.disposeHashCache();
Logger("Local Database Reset", LOG_LEVEL.NOTICE);
}
async tryResetRemoteDatabase(setting: ObsidianLiveSyncSettings) {
async tryResetRemoteDatabase(setting: RemoteDBSettings) {
await this.closeReplication();
const uri = setting.couchDB_URI + (setting.couchDB_DBNAME == "" ? "" : "/" + setting.couchDB_DBNAME);
const auth: Credential = {
username: setting.couchDB_USER,
password: setting.couchDB_PASSWORD,
};
const con = await connectRemoteCouchDB(uri, auth);
const con = await connectRemoteCouchDB(uri, auth, setting.disableRequestURI || this.isMobile);
if (typeof con == "string") return;
try {
await con.db.destroy();
Logger("Remote Database Destroyed", LOG_LEVEL.NOTICE);
await this.tryCreateRemoteDatabase(setting);
} catch (ex) {
Logger("something happend on Remote Database Destory", LOG_LEVEL.NOTICE);
Logger("Something happened on Remote Database Destory:", LOG_LEVEL.NOTICE);
Logger(ex, LOG_LEVEL.NOTICE);
}
}
async tryCreateRemoteDatabase(setting: ObsidianLiveSyncSettings) {
async tryCreateRemoteDatabase(setting: RemoteDBSettings) {
await this.closeReplication();
const uri = setting.couchDB_URI + (setting.couchDB_DBNAME == "" ? "" : "/" + setting.couchDB_DBNAME);
const auth: Credential = {
username: setting.couchDB_USER,
password: setting.couchDB_PASSWORD,
};
const con2 = await connectRemoteCouchDB(uri, auth);
const con2 = await connectRemoteCouchDB(uri, auth, setting.disableRequestURI || this.isMobile);
if (typeof con2 === "string") return;
Logger("Remote Database Created or Connected", LOG_LEVEL.NOTICE);
}
async markRemoteLocked(setting: ObsidianLiveSyncSettings, locked: boolean) {
async markRemoteLocked(setting: RemoteDBSettings, locked: boolean) {
const uri = setting.couchDB_URI + (setting.couchDB_DBNAME == "" ? "" : "/" + setting.couchDB_DBNAME);
const auth: Credential = {
username: setting.couchDB_USER,
password: setting.couchDB_PASSWORD,
};
const dbret = await connectRemoteCouchDB(uri, auth);
const dbret = await connectRemoteCouchDB(uri, auth, setting.disableRequestURI || this.isMobile);
if (typeof dbret === "string") {
Logger(`could not connect to ${uri}:${dbret}`, LOG_LEVEL.NOTICE);
return;
@@ -1147,13 +1113,13 @@ export class LocalPouchDB {
}
await dbret.db.put(remoteMilestone);
}
async markRemoteResolved(setting: ObsidianLiveSyncSettings) {
async markRemoteResolved(setting: RemoteDBSettings) {
const uri = setting.couchDB_URI + (setting.couchDB_DBNAME == "" ? "" : "/" + setting.couchDB_DBNAME);
const auth: Credential = {
username: setting.couchDB_USER,
password: setting.couchDB_PASSWORD,
};
const dbret = await connectRemoteCouchDB(uri, auth);
const dbret = await connectRemoteCouchDB(uri, auth, setting.disableRequestURI || this.isMobile);
if (typeof dbret === "string") {
Logger(`could not connect to ${uri}:${dbret}`, LOG_LEVEL.NOTICE);
return;
@@ -1204,7 +1170,13 @@ export class LocalPouchDB {
}
return false;
}
async garbageCollect() {
// if (this.settings.useHistory) {
// Logger("GC skipped for using history", LOG_LEVEL.VERBOSE);
// return;
// }
// NOTE:Garbage collection could break old revisions.
await runWithLock("replicate", true, async () => {
if (this.gcRunning) return;
this.gcRunning = true;
@@ -1218,29 +1190,36 @@ export class LocalPouchDB {
let usedPieces: string[] = [];
Logger("Collecting Garbage");
do {
const result = await this.localDatabase.allDocs({ include_docs: true, skip: c, limit: 500, conflicts: true });
const result = await this.localDatabase.allDocs({ include_docs: false, skip: c, limit: 2000, conflicts: true });
readCount = result.rows.length;
Logger("checked:" + readCount);
if (readCount > 0) {
//there are some result
for (const v of result.rows) {
const doc = v.doc;
if (doc.type == "newnote" || doc.type == "plain") {
// used pieces memo.
usedPieces = Array.from(new Set([...usedPieces, ...doc.children]));
if (doc._conflicts) {
for (const cid of doc._conflicts) {
const p = await this.localDatabase.get<EntryDoc>(doc._id, { rev: cid });
if (p.type == "newnote" || p.type == "plain") {
usedPieces = Array.from(new Set([...usedPieces, ...p.children]));
if (v.id.startsWith("h:")) {
hashPieces = Array.from(new Set([...hashPieces, v.id]));
} else {
const docT = await this.localDatabase.get(v.id, { revs_info: true });
const revs = docT._revs_info;
// console.log(`revs:${revs.length}`)
for (const rev of revs) {
if (rev.status != "available") continue;
// console.log(`id:${docT._id},rev:${rev.rev}`);
const doc = await this.localDatabase.get(v.id, { rev: rev.rev });
if ("children" in doc) {
// used pieces memo.
usedPieces = Array.from(new Set([...usedPieces, ...doc.children]));
if (doc._conflicts) {
for (const cid of doc._conflicts) {
const p = await this.localDatabase.get<EntryDoc>(doc._id, { rev: cid });
if (p.type == "newnote" || p.type == "plain") {
usedPieces = Array.from(new Set([...usedPieces, ...p.children]));
}
}
}
}
}
}
if (doc.type == "leaf") {
// all pieces.
hashPieces = Array.from(new Set([...hashPieces, doc._id]));
}
}
}
c += readCount;

View File

@@ -1,5 +1,5 @@
import { App, Modal } from "obsidian";
import { escapeStringToHTML } from "./utils";
import { escapeStringToHTML } from "./lib/src/utils";
import ObsidianLiveSyncPlugin from "./main";
export class LogDisplayModal extends Modal {

View File

@@ -1,9 +1,10 @@
import { App, Notice, PluginSettingTab, Setting, sanitizeHTMLToDom } from "obsidian";
import { EntryDoc, LOG_LEVEL } from "./types";
import { escapeStringToHTML, versionNumberString2Number, path2id, id2path, runWithLock } from "./utils";
import { Logger } from "./logger";
import { App, PluginSettingTab, Setting, sanitizeHTMLToDom } from "obsidian";
import { EntryDoc, LOG_LEVEL } from "./lib/src/types";
import { path2id, id2path } from "./utils";
import { NewNotice, runWithLock } from "./lib/src/utils";
import { Logger } from "./lib/src/logger";
import { connectRemoteCouchDB } from "./utils_couchdb";
import { testCrypt } from "./e2ee";
import { testCrypt } from "./lib/src/e2ee";
import ObsidianLiveSyncPlugin from "./main";
export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
@@ -14,10 +15,14 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
this.plugin = plugin;
}
async testConnection(): Promise<void> {
const db = await connectRemoteCouchDB(this.plugin.settings.couchDB_URI + (this.plugin.settings.couchDB_DBNAME == "" ? "" : "/" + this.plugin.settings.couchDB_DBNAME), {
username: this.plugin.settings.couchDB_USER,
password: this.plugin.settings.couchDB_PASSWORD,
});
const db = await connectRemoteCouchDB(
this.plugin.settings.couchDB_URI + (this.plugin.settings.couchDB_DBNAME == "" ? "" : "/" + this.plugin.settings.couchDB_DBNAME),
{
username: this.plugin.settings.couchDB_USER,
password: this.plugin.settings.couchDB_PASSWORD,
},
this.plugin.settings.disableRequestURI
);
if (typeof db === "string") {
this.plugin.addLog(`could not connect to ${this.plugin.settings.couchDB_URI} : ${this.plugin.settings.couchDB_DBNAME} \n(${db})`, LOG_LEVEL.NOTICE);
return;
@@ -165,7 +170,19 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
this.plugin.settings.couchDB_DBNAME = value;
await this.plugin.saveSettings();
})
)
),
new Setting(containerRemoteDatabaseEl)
.setDesc("This feature is locked in mobile")
.setName("Use the old connecting method")
.addToggle((toggle) => {
toggle.setValue(this.plugin.settings.disableRequestURI).onChange(async (value) => {
this.plugin.settings.disableRequestURI = value;
await this.plugin.saveSettings();
});
toggle.setDisabled(this.plugin.isMobile);
return toggle;
})
);
new Setting(containerRemoteDatabaseEl)
@@ -602,6 +619,15 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
})
);
new Setting(containerMiscellaneousEl)
.setName("Use history")
.setDesc("Use history dialog (Restart required, auto compaction would be disabled, and more storage will be consumed)")
.addToggle((toggle) =>
toggle.setValue(this.plugin.settings.useHistory).onChange(async (value) => {
this.plugin.settings.useHistory = value;
await this.plugin.saveSettings();
})
);
addScreenElement("40", containerMiscellaneousEl);
const containerHatchEl = containerEl.createDiv();
@@ -669,7 +695,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
.onClick(async () => {
const files = this.app.vault.getFiles();
Logger("Verify and repair all files started", LOG_LEVEL.NOTICE);
const notice = new Notice("", 0);
const notice = NewNotice("", 0);
let i = 0;
for (const file of files) {
i++;
@@ -695,7 +721,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
.setDisabled(false)
.setWarning()
.onClick(async () => {
const notice = new Notice("", 0);
const notice = NewNotice("", 0);
Logger(`Begin sanity check`, LOG_LEVEL.INFO);
notice.setMessage(`Begin sanity check`);
await runWithLock("sancheck", true, async () => {
@@ -811,30 +837,22 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
// With great respect, thank you TfTHacker!
// refered: https://github.com/TfTHacker/obsidian42-brat/blob/main/src/features/BetaPlugins.ts
const containerPluginSettings = containerEl.createDiv();
containerPluginSettings.createEl("h3", { text: "Plugins and settings (bleeding edge)" });
containerPluginSettings.createEl("h3", { text: "Plugins and settings (beta)" });
const updateDisabledOfDeviceAndVaultName = () => {
vaultName.setDisabled(this.plugin.settings.autoSweepPlugins || this.plugin.settings.autoSweepPluginsPeriodic);
vaultName.setTooltip(this.plugin.settings.autoSweepPlugins || this.plugin.settings.autoSweepPluginsPeriodic ? "You could not change when you enabling auto sweep." : "");
vaultName.setTooltip(this.plugin.settings.autoSweepPlugins || this.plugin.settings.autoSweepPluginsPeriodic ? "You could not change when you enabling auto scan." : "");
};
new Setting(containerPluginSettings).setName("Enable plugin synchronization").addToggle((toggle) =>
toggle.setValue(this.plugin.settings.usePluginSync).onChange(async (value) => {
this.plugin.settings.usePluginSync = value;
await this.plugin.saveSettings();
updatePluginPane();
})
);
new Setting(containerPluginSettings).setName("Show own plugins and settings").addToggle((toggle) =>
toggle.setValue(this.plugin.settings.showOwnPlugins).onChange(async (value) => {
this.plugin.settings.showOwnPlugins = value;
await this.plugin.saveSettings();
updatePluginPane();
})
);
new Setting(containerPluginSettings)
.setName("Sweep plugins automatically")
.setDesc("Sweep plugins before replicating.")
.setName("Scan plugins automatically")
.setDesc("Scan plugins before replicating.")
.addToggle((toggle) =>
toggle.setValue(this.plugin.settings.autoSweepPlugins).onChange(async (value) => {
this.plugin.settings.autoSweepPlugins = value;
@@ -844,8 +862,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
);
new Setting(containerPluginSettings)
.setName("Sweep plugins periodically")
.setDesc("Sweep plugins each 1 minutes.")
.setName("Scan plugins periodically")
.setDesc("Scan plugins each 1 minutes.")
.addToggle((toggle) =>
toggle.setValue(this.plugin.settings.autoSweepPluginsPeriodic).onChange(async (value) => {
this.plugin.settings.autoSweepPluginsPeriodic = value;
@@ -868,240 +886,26 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
.setDesc("")
.addText((text) => {
text.setPlaceholder("desktop-main")
.setValue(this.plugin.settings.deviceAndVaultName)
.setValue(this.plugin.deviceAndVaultName)
.onChange(async (value) => {
this.plugin.settings.deviceAndVaultName = value;
this.plugin.deviceAndVaultName = value;
await this.plugin.saveSettings();
});
// text.inputEl.setAttribute("type", "password");
});
new Setting(containerPluginSettings)
.setName("Open")
.setDesc("Open the plugin dialog")
.addButton((button) => {
button
.setButtonText("Open")
.setDisabled(false)
.onClick(() => {
this.plugin.showPluginSyncModal();
});
});
updateDisabledOfDeviceAndVaultName();
const sweepPlugin = async (showMessage: boolean) => {
if (!this.plugin.settings.usePluginSync) {
return;
}
await this.plugin.sweepPlugin(showMessage);
updatePluginPane();
};
const updatePluginPane = async () => {
pluginConfig.innerHTML = "<div class='sls-plugins-wrap'>Retrieving...</div>";
const { plugins, allPlugins, thisDevicePlugins } = await this.plugin.getPluginList();
let html = `
<div class='sls-plugins-wrap'>
<table class='sls-plugins-tbl'>
`;
for (const vaults in plugins) {
if (!this.plugin.settings.showOwnPlugins && vaults == this.plugin.settings.deviceAndVaultName) continue;
html += `
<tr>
<th colspan=1 class='sls-plugins-tbl-device-head'>${escapeStringToHTML(vaults)}</th>
<td class='sls-plugins-tbl-device-head sls-plugins-tbl-buttons'>
<button class='sls-plugin-apply-all-newer-plugin mod-cta' data-key="${vaults}" aria-label="Apply all newer (without setting)">⚡</button>
<button class='sls-plugin-apply-all-newer-setting mod-cta' data-key="${vaults}" aria-label="Apply all newer settings">📚</button>
<button class='sls-plugin-delete mod-warning' data-key="${vaults}" aria-label="Delete">❌</button>
</td>
</tr>`;
for (const v of plugins[vaults]) {
const mtime = v.mtime == 0 ? "-" : new Date(v.mtime).toLocaleString();
let settingApplyable: boolean | string = "-";
let settingFleshness = "";
let isSameVersion = false;
let isSameContents = false;
if (thisDevicePlugins[v.manifest.id]) {
if (thisDevicePlugins[v.manifest.id].manifest.version == v.manifest.version) {
isSameVersion = true;
}
if (thisDevicePlugins[v.manifest.id].styleCss == v.styleCss && thisDevicePlugins[v.manifest.id].mainJs == v.mainJs && thisDevicePlugins[v.manifest.id].manifestJson == v.manifestJson) {
isSameContents = true;
}
}
if (thisDevicePlugins[v.manifest.id] && v.dataJson) {
// have this plugin.
const localSetting = thisDevicePlugins[v.manifest.id].dataJson || null;
try {
const remoteSetting = v.dataJson;
if (!localSetting) {
settingFleshness = "newer";
settingApplyable = true;
} else if (localSetting == remoteSetting) {
settingApplyable = "even";
} else {
if (v.mtime > thisDevicePlugins[v.manifest.id].mtime) {
settingFleshness = "newer";
} else {
settingFleshness = "older";
}
settingApplyable = true;
}
} catch (ex) {
settingApplyable = "could not decrypt";
}
} else if (!v.dataJson) {
settingApplyable = "N/A";
}
// very ugly way.
const piece = `
<tr class='divider'>
<th colspan=2></th>
</tr>
<tr>
<th class='sls-table-head'>${escapeStringToHTML(v.manifest.name)}</th>
<td class="sls-table-tail tcenter">${isSameContents ? "even" : `<button data-key='${v._id}' class='apply-plugin-version mod-cta'>Use (${isSameVersion ? "=" : ""}${v.manifest.version}) </button>`}</td>
</tr>
<tr>
<td class="sls-table-head tcenter">${escapeStringToHTML(mtime)}</td>
<td class="sls-table-tail tcenter">${settingApplyable === true ? "<button data-key='" + v._id + "' class='apply-plugin-data mod-cta'>Apply (" + settingFleshness + ")</button>" : settingApplyable}</td>
</tr>
`;
html += piece;
}
html += `
<tr class='divider'>
<th colspan=2></th>
</tr>
`;
}
html += "</table></div>";
pluginConfig.innerHTML = html;
pluginConfig.querySelectorAll(".apply-plugin-data").forEach((e) =>
e.addEventListener("click", async (evt) => {
const plugin = allPlugins[e.attributes.getNamedItem("data-key").value];
Logger(`Updating plugin:${plugin.manifest.name}`, LOG_LEVEL.NOTICE);
await this.plugin.applyPluginData(plugin);
Logger(`Setting done:${plugin.manifest.name}`, LOG_LEVEL.NOTICE);
await sweepPlugin(true);
})
);
pluginConfig.querySelectorAll(".apply-plugin-version").forEach((e) =>
e.addEventListener("click", async (evt) => {
const plugin = allPlugins[e.attributes.getNamedItem("data-key").value];
Logger(`Setting plugin:${plugin.manifest.name}`, LOG_LEVEL.NOTICE);
await this.plugin.applyPlugin(plugin);
Logger(`Updated plugin:${plugin.manifest.name}`, LOG_LEVEL.NOTICE);
await sweepPlugin(true);
})
);
pluginConfig.querySelectorAll(".sls-plugin-apply-all-newer-plugin").forEach((e) =>
e.addEventListener("click", async (evt) => {
Logger("Apply all newer plugins.", LOG_LEVEL.NOTICE);
const vaultname = e.attributes.getNamedItem("data-key").value;
const plugins = Object.values(allPlugins).filter((e) => e.deviceVaultName == vaultname && e.manifest.id != "obsidian-livesync");
for (const plugin of plugins) {
const currentPlugin = thisDevicePlugins[plugin.manifest.id];
if (currentPlugin) {
const thisVersion = versionNumberString2Number(plugin.manifest.version);
const currentVersion = versionNumberString2Number(currentPlugin.manifest.version);
if (thisVersion > currentVersion) {
Logger(`Updating plugin:${plugin.manifest.name}`, LOG_LEVEL.NOTICE);
await this.plugin.applyPlugin(plugin);
Logger(`Updated plugin:${plugin.manifest.name}`, LOG_LEVEL.NOTICE);
} else {
Logger(`Plugin ${plugin.manifest.name} is not new`);
}
} else {
Logger(`Updating plugin:${plugin.manifest.name}`, LOG_LEVEL.NOTICE);
await this.plugin.applyPlugin(plugin);
Logger(`Updated plugin:${plugin.manifest.name}`, LOG_LEVEL.NOTICE);
}
}
await sweepPlugin(true);
Logger("Done", LOG_LEVEL.NOTICE);
})
);
pluginConfig.querySelectorAll(".sls-plugin-apply-all-newer-setting").forEach((e) =>
e.addEventListener("click", async (evt) => {
Logger("Apply all newer settings.", LOG_LEVEL.NOTICE);
const vaultname = e.attributes.getNamedItem("data-key").value;
const plugins = Object.values(allPlugins).filter((e) => e.deviceVaultName == vaultname && e.manifest.id != "obsidian-livesync");
for (const plugin of plugins) {
const currentPlugin = thisDevicePlugins[plugin.manifest.id];
if (currentPlugin) {
const thisVersion = plugin.mtime;
const currentVersion = currentPlugin.mtime;
if (thisVersion > currentVersion) {
Logger(`Setting plugin:${plugin.manifest.name}`, LOG_LEVEL.NOTICE);
await this.plugin.applyPluginData(plugin);
Logger(`Setting done:${plugin.manifest.name}`, LOG_LEVEL.NOTICE);
} else {
Logger(`Setting ${plugin.manifest.name} is not new`);
}
} else {
Logger(`Setting plugin:${plugin.manifest.name}`, LOG_LEVEL.NOTICE);
await this.plugin.applyPluginData(plugin);
Logger(`Setting done:${plugin.manifest.name}`, LOG_LEVEL.NOTICE);
}
}
await sweepPlugin(true);
Logger("Done", LOG_LEVEL.NOTICE);
})
);
pluginConfig.querySelectorAll(".sls-plugin-delete").forEach((e) =>
e.addEventListener("click", async (evt) => {
const db = this.plugin.localDatabase.localDatabase;
const vaultname = e.attributes.getNamedItem("data-key").value;
const oldDocs = await db.allDocs({ startkey: `ps:${vaultname}-`, endkey: `ps:${vaultname}.`, include_docs: true });
Logger(`Deleting ${vaultname}`, LOG_LEVEL.NOTICE);
const delDocs = oldDocs.rows.map((e) => {
e.doc._deleted = true;
return e.doc;
});
await db.bulkDocs(delDocs);
Logger(`Deleted ${vaultname}`, LOG_LEVEL.NOTICE);
await this.plugin.replicate(true);
await updatePluginPane();
})
);
};
const pluginConfig = containerPluginSettings.createEl("div");
new Setting(containerPluginSettings)
.setName("Reload")
.setDesc("Replicate once and reload the list")
.addButton((button) =>
button
.setButtonText("Reload")
.setDisabled(false)
.onClick(async () => {
if (!this.plugin.settings.usePluginSync) {
return;
}
await this.plugin.replicate(true);
await updatePluginPane();
})
);
new Setting(containerPluginSettings)
.setName("Save plugins into the database")
.setDesc("")
.addButton((button) =>
button
.setButtonText("Save plugins")
.setDisabled(false)
.onClick(async () => {
if (!this.plugin.settings.usePluginSync) {
return;
}
Logger("Save plugins.", LOG_LEVEL.NOTICE);
await sweepPlugin(true);
Logger("All plugins have been saved.", LOG_LEVEL.NOTICE);
await this.plugin.replicate(true);
})
);
new Setting(containerPluginSettings)
.setName("Check updates")
.setDesc("")
.addButton((button) =>
button
.setButtonText("Check")
.setDisabled(false)
.onClick(async () => {
Logger("Checking plugins.", LOG_LEVEL.NOTICE);
await this.plugin.checkPluginUpdate();
})
);
updatePluginPane();
addScreenElement("60", containerPluginSettings);

290
src/PluginPane.svelte Normal file
View File

@@ -0,0 +1,290 @@
<script lang="ts">
import ObsidianLiveSyncPlugin from "./main";
import { onMount } from "svelte";
import { DevicePluginList, PluginDataEntry } from "./types";
import { versionNumberString2Number } from "./lib/src/utils";
type JudgeResult = "" | "NEWER" | "EVEN" | "EVEN_BUT_DIFFERENT" | "OLDER" | "REMOTE_ONLY";
interface PluginDataEntryDisp extends PluginDataEntry {
versionInfo: string;
mtimeInfo: string;
mtimeFlag: JudgeResult;
versionFlag: JudgeResult;
}
export let plugin: ObsidianLiveSyncPlugin;
let plugins: PluginDataEntry[] = [];
let deviceAndPlugins: { [key: string]: PluginDataEntryDisp[] } = {};
let devicePluginList: [string, PluginDataEntryDisp[]][] = [];
let ownPlugins: DevicePluginList = null;
let showOwnPlugins = false;
let targetList: { [key: string]: boolean } = {};
function saveTargetList() {
window.localStorage.setItem("ols-plugin-targetlist", JSON.stringify(targetList));
}
function loadTargetList() {
let e = window.localStorage.getItem("ols-plugin-targetlist") || "{}";
try {
targetList = JSON.parse(e);
} catch (_) {
// NO OP.
}
}
function clearSelection() {
targetList = {};
}
async function updateList() {
let x = await plugin.getPluginList();
ownPlugins = x.thisDevicePlugins;
plugins = Object.values(x.allPlugins);
let targetListItems = Array.from(new Set(plugins.map((e) => e.deviceVaultName + "---" + e.manifest.id)));
let newTargetList: { [key: string]: boolean } = {};
for (const id of targetListItems) {
for (const tag of ["---plugin", "---setting"]) {
newTargetList[id + tag] = id + tag in targetList && targetList[id + tag];
}
}
targetList = newTargetList;
saveTargetList();
}
$: {
deviceAndPlugins = {};
for (const p of plugins) {
if (p.deviceVaultName == plugin.deviceAndVaultName && !showOwnPlugins) {
continue;
}
if (!(p.deviceVaultName in deviceAndPlugins)) {
deviceAndPlugins[p.deviceVaultName] = [];
}
let dispInfo: PluginDataEntryDisp = { ...p, versionInfo: "", mtimeInfo: "", versionFlag: "", mtimeFlag: "" };
dispInfo.versionInfo = p.manifest.version;
let x = new Date().getTime() / 1000;
let mtime = p.mtime / 1000;
let diff = (x - mtime) / 60;
if (p.mtime == 0) {
dispInfo.mtimeInfo = `-`;
} else if (diff < 60) {
dispInfo.mtimeInfo = `${diff | 0} Mins ago`;
} else if (diff < 60 * 24) {
dispInfo.mtimeInfo = `${(diff / 60) | 0} Hours ago`;
} else if (diff < 60 * 24 * 10) {
dispInfo.mtimeInfo = `${(diff / (60 * 24)) | 0} Days ago`;
} else {
dispInfo.mtimeInfo = new Date(dispInfo.mtime).toLocaleString();
}
// compare with own plugin
let id = p.manifest.id;
if (id in ownPlugins) {
// Which we have.
const ownPlugin = ownPlugins[id];
let localVer = versionNumberString2Number(ownPlugin.manifest.version);
let pluginVer = versionNumberString2Number(p.manifest.version);
if (localVer > pluginVer) {
dispInfo.versionFlag = "OLDER";
} else if (localVer == pluginVer) {
if (ownPlugin.manifestJson + (ownPlugin.styleCss ?? "") + ownPlugin.mainJs != p.manifestJson + (p.styleCss ?? "") + p.mainJs) {
dispInfo.versionFlag = "EVEN_BUT_DIFFERENT";
} else {
dispInfo.versionFlag = "EVEN";
}
} else if (localVer < pluginVer) {
dispInfo.versionFlag = "NEWER";
}
if ((ownPlugin.dataJson ?? "") == (p.dataJson ?? "")) {
if (ownPlugin.mtime == 0 && p.mtime == 0) {
dispInfo.mtimeFlag = "";
} else {
dispInfo.mtimeFlag = "EVEN";
}
} else {
if (((ownPlugin.mtime / 1000) | 0) > ((p.mtime / 1000) | 0)) {
dispInfo.mtimeFlag = "OLDER";
} else if (((ownPlugin.mtime / 1000) | 0) == ((p.mtime / 1000) | 0)) {
dispInfo.mtimeFlag = "EVEN_BUT_DIFFERENT";
} else if (((ownPlugin.mtime / 1000) | 0) < ((p.mtime / 1000) | 0)) {
dispInfo.mtimeFlag = "NEWER";
}
}
} else {
dispInfo.versionFlag = "REMOTE_ONLY";
dispInfo.mtimeFlag = "REMOTE_ONLY";
}
deviceAndPlugins[p.deviceVaultName].push(dispInfo);
}
devicePluginList = Object.entries(deviceAndPlugins);
}
function getDispString(stat: JudgeResult): string {
if (stat == "") return "";
if (stat == "NEWER") return " (Newer)";
if (stat == "OLDER") return " (Older)";
if (stat == "EVEN") return " (Even)";
if (stat == "EVEN_BUT_DIFFERENT") return " (Even but different)";
if (stat == "REMOTE_ONLY") return " (Remote Only)";
return "";
}
onMount(async () => {
loadTargetList();
await updateList();
});
function toggleShowOwnPlugins() {
showOwnPlugins = !showOwnPlugins;
}
function toggleTarget(key: string) {
targetList[key] = !targetList[key];
saveTargetList();
}
function toggleAll(devicename: string) {
for (const c in targetList) {
if (c.startsWith(devicename)) {
targetList[c] = true;
}
}
}
async function sweepPlugins() {
//@ts-ignore
await plugin.app.plugins.loadManifests();
await plugin.sweepPlugin(true);
updateList();
}
async function applyPlugins() {
for (const c in targetList) {
if (targetList[c] == true) {
const [deviceAndVault, id, opt] = c.split("---");
if (deviceAndVault in deviceAndPlugins) {
const entry = deviceAndPlugins[deviceAndVault].find((e) => e.manifest.id == id);
if (entry) {
if (opt == "plugin") {
if (entry.versionFlag != "EVEN") await plugin.applyPlugin(entry);
} else if (opt == "setting") {
if (entry.mtimeFlag != "EVEN") await plugin.applyPluginData(entry);
}
}
}
}
}
//@ts-ignore
await plugin.app.plugins.loadManifests();
await plugin.sweepPlugin(true);
updateList();
}
async function checkUpdates() {
await plugin.checkPluginUpdate();
}
async function replicateAndRefresh() {
await plugin.replicate(true);
updateList();
}
</script>
<div>
<h1>Plugins and their settings</h1>
<div class="ols-plugins-div-buttons">
Show own items
<div class="checkbox-container" class:is-enabled={showOwnPlugins} on:click={toggleShowOwnPlugins} />
</div>
<div class="sls-plugins-wrap">
<table class="sls-plugins-tbl">
<tr style="position:sticky">
<th class="sls-plugins-tbl-device-head">Name</th>
<th class="sls-plugins-tbl-device-head">Info</th>
<th class="sls-plugins-tbl-device-head">Target</th>
</tr>
{#if devicePluginList.length == 0}
<tr>
<td colspan="3" class="sls-table-tail tcenter"> Retrieving... </td>
</tr>
{/if}
{#each devicePluginList as [deviceName, devicePlugins]}
<tr>
<th colspan="2" class="sls-plugins-tbl-device-head">{deviceName}</th>
<th class="sls-plugins-tbl-device-head">
<button class="mod-cta" on:click={() => toggleAll(deviceName)}>✔</button>
</th>
</tr>
{#each devicePlugins as plugin}
<tr>
<td class="sls-table-head">{plugin.manifest.name}</td>
<td class="sls-table-tail tcenter">{plugin.versionInfo}{getDispString(plugin.versionFlag)}</td>
<td class="sls-table-tail tcenter">
{#if plugin.versionFlag === "EVEN" || plugin.versionFlag === ""}
-
{:else}
<div class="wrapToggle">
<div
class="checkbox-container"
class:is-enabled={targetList[plugin.deviceVaultName + "---" + plugin.manifest.id + "---plugin"]}
on:click={() => toggleTarget(plugin.deviceVaultName + "---" + plugin.manifest.id + "---plugin")}
/>
</div>
{/if}
</td>
</tr>
<tr>
<td class="sls-table-head">Settings</td>
<td class="sls-table-tail tcenter">{plugin.mtimeInfo}{getDispString(plugin.mtimeFlag)}</td>
<td class="sls-table-tail tcenter">
{#if plugin.mtimeFlag === "EVEN" || plugin.mtimeFlag === ""}
-
{:else}
<div class="wrapToggle">
<div
class="checkbox-container"
class:is-enabled={targetList[plugin.deviceVaultName + "---" + plugin.manifest.id + "---setting"]}
on:click={() => toggleTarget(plugin.deviceVaultName + "---" + plugin.manifest.id + "---setting")}
/>
</div>
{/if}
</td>
</tr>
<tr class="divider">
<th colspan="3" />
</tr>
{/each}
{/each}
</table>
</div>
<div class="ols-plugins-div-buttons">
<button class="" on:click={replicateAndRefresh}>Replicate and refresh</button>
<button class="" on:click={clearSelection}>Clear Selection</button>
</div>
<div class="ols-plugins-div-buttons">
<button class="mod-cta" on:click={checkUpdates}>Check Updates</button>
<button class="mod-cta" on:click={sweepPlugins}>Scan installed</button>
<button class="mod-cta" on:click={applyPlugins}>Apply all</button>
</div>
<!-- <div class="ols-plugins-div-buttons">-->
<!-- <button class="mod-warning" on:click={applyPlugins}>Delete all selected</button>-->
<!-- </div>-->
</div>
<style>
.ols-plugins-div-buttons {
display: flex;
flex-direction: row;
justify-content: flex-end;
margin-top: 8px;
}
.wrapToggle {
display: flex;
justify-content: center;
align-content: center;
}
</style>

View File

@@ -1,168 +0,0 @@
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;
}
}

1
src/lib Submodule

Submodule src/lib added at d495f4577a

View File

@@ -1,13 +0,0 @@
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;
}

View File

@@ -1,29 +1,60 @@
import { debounce, Notice, Plugin, TFile, addIcon, TFolder, normalizePath, TAbstractFile, Editor, MarkdownView, PluginManifest } from "obsidian";
import { debounce, Notice, Plugin, TFile, addIcon, TFolder, normalizePath, TAbstractFile, Editor, MarkdownView, PluginManifest, Modal, App } from "obsidian";
import { diff_match_patch } from "diff-match-patch";
import { EntryDoc, LoadedEntry, ObsidianLiveSyncSettings, diff_check_result, diff_result_leaf, EntryBody, LOG_LEVEL, VER, DEFAULT_SETTINGS, diff_result, FLAGMD_REDFLAG } from "./lib/src/types";
import { PluginDataEntry, PERIODIC_PLUGIN_SWEEP, PluginList, DevicePluginList } from "./types";
import {
EntryDoc,
LoadedEntry,
ObsidianLiveSyncSettings,
diff_check_result,
diff_result_leaf,
EntryBody,
PluginDataEntry,
LOG_LEVEL,
VER,
PERIODIC_PLUGIN_SWEEP,
DEFAULT_SETTINGS,
PluginList,
DevicePluginList,
diff_result,
FLAGMD_REDFLAG,
} from "./types";
import { base64ToString, arrayBufferToBase64, base64ToArrayBuffer, isValidPath, versionNumberString2Number, id2path, path2id, runWithLock, shouldBeIgnored } from "./utils";
import { Logger, setLogger } from "./logger";
base64ToString,
arrayBufferToBase64,
base64ToArrayBuffer,
isValidPath,
versionNumberString2Number,
runWithLock,
shouldBeIgnored,
getProcessingCounts,
setLockNotifier,
isPlainText,
setNoticeClass,
NewNotice,
allSettledWithConcurrencyLimit,
} from "./lib/src/utils";
import { Logger, setLogger } from "./lib/src/logger";
import { LocalPouchDB } from "./LocalPouchDB";
import { LogDisplayModal } from "./LogDisplayModal";
import { ConflictResolveModal } from "./ConflictResolveModal";
import { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab";
import { DocumentHistoryModal } from "./DocumentHistoryModal";
import PluginPane from "./PluginPane.svelte";
import { id2path, path2id } from "./utils";
setNoticeClass(Notice);
class PluginDialogModal extends Modal {
plugin: ObsidianLiveSyncPlugin;
logEl: HTMLDivElement;
component: PluginPane = null;
constructor(app: App, plugin: ObsidianLiveSyncPlugin) {
super(app);
this.plugin = plugin;
}
onOpen() {
const { contentEl } = this;
if (this.component == null) {
this.component = new PluginPane({
target: contentEl,
props: { plugin: this.plugin },
});
}
}
onClose() {
if (this.component != null) {
this.component.$destroy();
this.component = null;
}
}
}
export default class ObsidianLiveSyncPlugin extends Plugin {
settings: ObsidianLiveSyncSettings;
@@ -32,12 +63,15 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
statusBar: HTMLElement;
statusBar2: HTMLElement;
suspended: boolean;
deviceAndVaultName: string;
isMobile = false;
setInterval(handler: () => any, timeout?: number): number {
const timer = window.setInterval(handler, timeout);
this.registerInterval(timer);
return timer;
}
isRedFlagRaised(): boolean {
const redflag = this.app.vault.getAbstractFileByPath(normalizePath(FLAGMD_REDFLAG));
if (redflag != null) {
@@ -45,12 +79,26 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
}
return false;
}
showHistory(file: TFile) {
if (!this.settings.useHistory) {
Logger("You have to enable Use History in misc.", LOG_LEVEL.NOTICE);
} else {
new DocumentHistoryModal(this.app, this, file).open();
}
}
async onload() {
setLogger(this.addLog.bind(this)); // Logger moved to global.
Logger("loading plugin");
const lsname = "obsidian-live-sync-ver" + this.app.vault.getName();
const last_version = localStorage.getItem(lsname);
await this.loadSettings();
//@ts-ignore
if (this.app.isMobile) {
this.isMobile = true;
this.settings.disableRequestURI = true;
}
if (last_version && Number(last_version) < VER) {
this.settings.liveSync = false;
this.settings.syncOnSave = false;
@@ -108,6 +156,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
this.periodicSync = this.periodicSync.bind(this);
this.setPeriodicSync = this.setPeriodicSync.bind(this);
this.getPluginList = this.getPluginList.bind(this);
// this.registerWatchEvents();
this.addSettingTab(new ObsidianLiveSyncSettingTab(this.app, this));
@@ -137,7 +186,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
await this.realizeSettingSyncMode();
this.registerWatchEvents();
if (this.settings.syncOnStart) {
await this.localDatabase.openReplication(this.settings, false, false, this.parseReplicationResult);
this.localDatabase.openReplication(this.settings, false, false, this.parseReplicationResult);
}
} catch (ex) {
Logger("Error while loading Self-hosted LiveSync", LOG_LEVEL.NOTICE);
@@ -147,8 +196,8 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
this.addCommand({
id: "livesync-replicate",
name: "Replicate now",
callback: () => {
this.replicate();
callback: async () => {
await this.replicate();
},
});
this.addCommand({
@@ -203,10 +252,47 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
this.saveSettings();
},
});
this.addCommand({
id: "livesync-history",
name: "Show history",
editorCallback: (editor: Editor, view: MarkdownView) => {
this.showHistory(view.file);
},
});
this.triggerRealizeSettingSyncMode = debounce(this.triggerRealizeSettingSyncMode.bind(this), 1000);
this.triggerCheckPluginUpdate = debounce(this.triggerCheckPluginUpdate.bind(this), 3000);
setLockNotifier(() => {
this.refreshStatusText();
});
this.addCommand({
id: "livesync-plugin-dialog",
name: "Show Plugins and their settings",
callback: () => {
this.showPluginSyncModal();
},
});
}
pluginDialog: PluginDialogModal = null;
showPluginSyncModal() {
if (this.pluginDialog != null) {
this.pluginDialog.open();
} else {
this.pluginDialog = new PluginDialogModal(this.app, this);
this.pluginDialog.open();
}
}
hidePluginSyncModal() {
if (this.pluginDialog != null) {
this.pluginDialog.close();
this.pluginDialog = null;
}
}
onunload() {
this.hidePluginSyncModal();
this.localDatabase.onunload();
if (this.gcTimerHandler != null) {
clearTimeout(this.gcTimerHandler);
@@ -226,12 +312,15 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
}
const vaultName = this.app.vault.getName();
Logger("Open Database...");
this.localDatabase = new LocalPouchDB(this.settings, vaultName);
//@ts-ignore
const isMobile = this.app.isMobile;
this.localDatabase = new LocalPouchDB(this.settings, vaultName, isMobile);
this.localDatabase.updateInfo = () => {
this.refreshStatusText();
};
await this.localDatabase.initializeDatabase();
}
async garbageCollect() {
await this.localDatabase.garbageCollect();
}
@@ -240,19 +329,35 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
this.settings.workingEncrypt = this.settings.encrypt;
this.settings.workingPassphrase = this.settings.passphrase;
const lsname = "obsidian-live-sync-vaultanddevicename-" + this.app.vault.getName();
if (this.settings.deviceAndVaultName != "") {
if (!localStorage.getItem(lsname)) {
this.deviceAndVaultName = this.settings.deviceAndVaultName;
localStorage.setItem(lsname, this.deviceAndVaultName);
this.settings.deviceAndVaultName = "";
}
}
this.deviceAndVaultName = localStorage.getItem(lsname) || "";
}
triggerRealizeSettingSyncMode() {
(async () => await this.realizeSettingSyncMode())();
}
async saveSettings() {
const lsname = "obsidian-live-sync-vaultanddevicename-" + this.app.vault.getName();
localStorage.setItem(lsname, this.deviceAndVaultName || "");
await this.saveData(this.settings);
this.localDatabase.settings = this.settings;
this.triggerRealizeSettingSyncMode();
}
gcTimerHandler: any = null;
gcHook() {
if (this.settings.gcDelay == 0) return;
if (this.settings.useHistory) return;
const GC_DELAY = this.settings.gcDelay * 1000; // if leaving opening window, try GC,
if (this.gcTimerHandler != null) {
clearTimeout(this.gcTimerHandler);
@@ -263,11 +368,12 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
this.garbageCollect();
}, GC_DELAY);
}
registerWatchEvents() {
this.registerEvent(this.app.vault.on("modify", this.watchVaultChange));
this.registerEvent(this.app.vault.on("delete", this.watchVaultDelete));
this.registerEvent(this.app.vault.on("rename", this.watchVaultRename));
this.registerEvent(this.app.vault.on("create", this.watchVaultChange));
this.registerEvent(this.app.vault.on("create", this.watchVaultCreate));
this.registerEvent(this.app.workspace.on("file-open", this.watchWorkspaceOpen));
window.addEventListener("visibilitychange", this.watchWindowVisiblity);
}
@@ -275,6 +381,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
watchWindowVisiblity() {
this.watchWindowVisiblityAsync();
}
async watchWindowVisiblityAsync() {
if (this.settings.suspendFileWatching) return;
// if (this.suspended) return;
@@ -290,10 +397,10 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
await this.sweepPlugin(false);
}
if (this.settings.liveSync) {
await this.localDatabase.openReplication(this.settings, true, false, this.parseReplicationResult);
this.localDatabase.openReplication(this.settings, true, false, this.parseReplicationResult);
}
if (this.settings.syncOnStart) {
await this.localDatabase.openReplication(this.settings, false, false, this.parseReplicationResult);
this.localDatabase.openReplication(this.settings, false, false, this.parseReplicationResult);
}
if (this.settings.periodicReplication) {
this.setPeriodicSync();
@@ -306,9 +413,12 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
if (this.settings.suspendFileWatching) return;
this.watchWorkspaceOpenAsync(file);
}
async watchWorkspaceOpenAsync(file: TFile) {
await this.applyBatchChange();
if (file == null) return;
if (file == null) {
return;
}
if (this.settings.syncOnFileOpen && !this.suspended) {
await this.replicate();
}
@@ -316,26 +426,40 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
await this.showIfConflicted(file);
this.gcHook();
}
watchVaultCreate(file: TFile, ...args: any[]) {
if (this.settings.suspendFileWatching) return;
this.watchVaultChangeAsync(file, ...args);
}
watchVaultChange(file: TAbstractFile, ...args: any[]) {
if (!(file instanceof TFile)) {
return;
}
if (this.settings.suspendFileWatching) return;
// If batchsave is enabled, queue all changes and do nothing.
if (this.settings.batchSave) {
this.batchFileChange = Array.from(new Set([...this.batchFileChange, file.path]));
this.refreshStatusText();
~(async () => {
const meta = await this.localDatabase.getDBEntryMeta(file.path);
if (meta != false) {
const localMtime = ~~(file.stat.mtime / 1000);
const docMtime = ~~(meta.mtime / 1000);
if (localMtime !== docMtime) {
// Perhaps we have to modify (to using newer doc), but we don't be sure to every device's clock is adjusted.
this.batchFileChange = Array.from(new Set([...this.batchFileChange, file.path]));
this.refreshStatusText();
}
}
})();
return;
}
this.watchVaultChangeAsync(file, ...args);
}
async applyBatchChange() {
if (!this.settings.batchSave || this.batchFileChange.length == 0) {
return [];
return;
}
return await runWithLock("batchSave", false, async () => {
const batchItems = JSON.parse(JSON.stringify(this.batchFileChange)) as string[];
@@ -353,22 +477,27 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
}
});
this.refreshStatusText();
return await Promise.all(promises);
await allSettledWithConcurrencyLimit(promises, 3);
return;
});
}
batchFileChange: string[] = [];
async watchVaultChangeAsync(file: TFile, ...args: any[]) {
if (file instanceof TFile) {
await this.updateIntoDB(file);
this.gcHook();
}
}
watchVaultDelete(file: TAbstractFile) {
// When save is delayed, it should be cancelled.
this.batchFileChange = this.batchFileChange.filter((e) => e == file.path);
if (this.settings.suspendFileWatching) return;
this.watchVaultDeleteAsync(file);
this.watchVaultDeleteAsync(file).then(() => {});
}
async watchVaultDeleteAsync(file: TAbstractFile) {
if (file instanceof TFile) {
await this.deleteFromDB(file);
@@ -377,6 +506,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
}
this.gcHook();
}
GetAllFilesRecursively(file: TAbstractFile): TFile[] {
if (file instanceof TFile) {
return [file];
@@ -391,10 +521,12 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
throw new Error(`Filetype error:${file.path}`);
}
}
watchVaultRename(file: TAbstractFile, oldFile: any) {
if (this.settings.suspendFileWatching) return;
this.watchVaultRenameAsync(file, oldFile);
this.watchVaultRenameAsync(file, oldFile).then(() => {});
}
getFilePath(file: TAbstractFile): string {
if (file instanceof TFolder) {
if (file.isRoot()) return "";
@@ -406,6 +538,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
return this.getFilePath(file.parent) + "/" + file.name;
}
async watchVaultRenameAsync(file: TAbstractFile, oldFile: any) {
Logger(`${oldFile} renamed to ${file.path}`, LOG_LEVEL.VERBOSE);
try {
@@ -442,9 +575,11 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
}
this.gcHook();
}
addLogHook: () => void = null;
//--> Basic document Functions
notifies: { [key: string]: { notice: Notice; timer: NodeJS.Timeout; count: number } } = {};
// eslint-disable-next-line require-await
async addLog(message: any, level: LOG_LEVEL = LOG_LEVEL.INFO) {
if (level < LOG_LEVEL.INFO && this.settings && this.settings.lessInformationInLog) {
@@ -528,7 +663,10 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
}
await this.ensureDirectory(path);
try {
const newfile = await this.app.vault.createBinary(normalizePath(path), bin, { ctime: doc.ctime, mtime: doc.mtime });
const newfile = await this.app.vault.createBinary(normalizePath(path), bin, {
ctime: doc.ctime,
mtime: doc.mtime,
});
Logger("live : write to local (newfile:b) " + path);
this.app.vault.trigger("create", newfile);
} catch (ex) {
@@ -543,7 +681,10 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
}
await this.ensureDirectory(path);
try {
const newfile = await this.app.vault.create(normalizePath(path), doc.data, { ctime: doc.ctime, mtime: doc.mtime });
const newfile = await this.app.vault.create(normalizePath(path), doc.data, {
ctime: doc.ctime,
mtime: doc.mtime,
});
Logger("live : write to local (newfile:p) " + path);
this.app.vault.trigger("create", newfile);
} catch (ex) {
@@ -571,14 +712,15 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
}
}
}
async doc2storate_modify(docEntry: EntryBody, file: TFile, force?: boolean) {
async doc2storage_modify(docEntry: EntryBody, file: TFile, force?: boolean) {
const pathSrc = id2path(docEntry._id);
if (shouldBeIgnored(pathSrc)) {
return;
}
if (docEntry._deleted) {
//basically pass.
//but if there're no docs left, delete file.
//but if there are no docs left, delete file.
const lastDocs = await this.localDatabase.getDBEntry(pathSrc);
if (lastDocs === false) {
await this.deleteVaultItem(file);
@@ -638,6 +780,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
//eq.case
}
}
async handleDBChanged(change: EntryBody) {
const targetFile = this.app.vault.getAbstractFileByPath(id2path(change._id));
if (targetFile == null) {
@@ -649,7 +792,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
} else if (targetFile instanceof TFile) {
const doc = change;
const file = targetFile;
await this.doc2storate_modify(doc, file);
await this.doc2storage_modify(doc, file);
this.queueConflictedCheck(file);
} else {
Logger(`${id2path(change._id)} is already exist as the folder`);
@@ -657,6 +800,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
}
periodicSyncHandler: number = null;
//---> Sync
async parseReplicationResult(docs: Array<PouchDB.Core.ExistingDocument<EntryDoc>>): Promise<void> {
this.refreshStatusText();
@@ -683,61 +827,81 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
this.gcHook();
}
}
triggerCheckPluginUpdate() {
(async () => await this.checkPluginUpdate())();
}
async checkPluginUpdate() {
if (!this.settings.usePluginSync) return;
await this.sweepPlugin(false);
const { allPlugins, thisDevicePlugins } = await this.getPluginList();
const arrPlugins = Object.values(allPlugins);
let updateFound = false;
for (const plugin of arrPlugins) {
const currentPlugin = thisDevicePlugins[plugin.manifest.id];
if (currentPlugin) {
const thisVersion = versionNumberString2Number(plugin.manifest.version);
const currentVersion = versionNumberString2Number(currentPlugin.manifest.version);
if (thisVersion > currentVersion) {
Logger(`the device ${plugin.deviceVaultName} has the newer plugin:${plugin.manifest.name}`, LOG_LEVEL.NOTICE);
const ownPlugin = thisDevicePlugins[plugin.manifest.id];
if (ownPlugin) {
const remoteVersion = versionNumberString2Number(plugin.manifest.version);
const ownVersion = versionNumberString2Number(ownPlugin.manifest.version);
if (remoteVersion > ownVersion) {
updateFound = true;
}
if (plugin.mtime > currentPlugin.mtime) {
Logger(`the device ${plugin.deviceVaultName} has the newer settings of the plugin:${plugin.manifest.name}`, LOG_LEVEL.NOTICE);
if (((plugin.mtime / 1000) | 0) > ((ownPlugin.mtime / 1000) | 0) && (plugin.dataJson ?? "") != (ownPlugin.dataJson ?? "")) {
updateFound = true;
}
} else {
Logger(`the device ${plugin.deviceVaultName} has the new plugin:${plugin.manifest.name}`, LOG_LEVEL.NOTICE);
}
}
if (updateFound) {
const fragment = createFragment((doc) => {
doc.createEl("a", null, (a) => {
a.text = "There're some new plugins or their settings";
a.addEventListener("click", () => this.showPluginSyncModal());
});
});
NewNotice(fragment, 10000);
} else {
Logger("Everything is up to date.", LOG_LEVEL.NOTICE);
}
}
clearPeriodicSync() {
if (this.periodicSyncHandler != null) {
clearInterval(this.periodicSyncHandler);
this.periodicSyncHandler = null;
}
}
setPeriodicSync() {
if (this.settings.periodicReplication && this.settings.periodicReplicationInterval > 0) {
this.clearPeriodicSync();
this.periodicSyncHandler = this.setInterval(async () => await this.periodicSync(), Math.max(this.settings.periodicReplicationInterval, 30) * 1000);
}
}
async periodicSync() {
await this.replicate();
}
periodicPluginSweepHandler: number = null;
clearPluginSweep() {
if (this.periodicPluginSweepHandler != null) {
clearInterval(this.periodicPluginSweepHandler);
this.periodicPluginSweepHandler = null;
}
}
setPluginSweep() {
if (this.settings.autoSweepPluginsPeriodic) {
this.clearPluginSweep();
this.periodicPluginSweepHandler = this.setInterval(async () => await this.periodicPluginSweep(), PERIODIC_PLUGIN_SWEEP * 1000);
}
}
async periodicPluginSweep() {
await this.sweepPlugin(false);
}
async realizeSettingSyncMode() {
this.localDatabase.closeReplication();
this.clearPeriodicSync();
@@ -749,13 +913,15 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
await this.sweepPlugin(false);
}
if (this.settings.liveSync) {
await this.localDatabase.openReplication(this.settings, true, false, this.parseReplicationResult);
this.localDatabase.openReplication(this.settings, true, false, this.parseReplicationResult);
this.refreshStatusText();
}
this.setPeriodicSync();
this.setPluginSweep();
}
lastMessage = "";
refreshStatusText() {
const sent = this.localDatabase.docSent;
const arrived = this.localDatabase.docArrived;
@@ -787,9 +953,12 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
waiting = " " + this.batchFileChange.map((e) => "🛫").join("");
waiting = waiting.replace(/(🛫){10}/g, "🚀");
}
const message = `Sync:${w}${sent}${arrived}${waiting}`;
const procs = getProcessingCounts();
const procsDisp = procs == 0 ? "" : `${procs}`;
const message = `Sync:${w}${sent}${arrived}${waiting}${procsDisp}`;
this.setStatusBarText(message);
}
setStatusBarText(message: string) {
if (this.lastMessage != message) {
this.statusBar.setText(message);
@@ -803,42 +972,48 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
this.lastMessage = message;
}
}
async replicate(showMessage?: boolean) {
if (this.settings.versionUpFlash != "") {
new Notice("Open settings and check message, please.");
NewNotice("Open settings and check message, please.");
return;
}
await this.applyBatchChange();
if (this.settings.autoSweepPlugins) {
await this.sweepPlugin(false);
}
await this.localDatabase.openReplication(this.settings, false, showMessage, this.parseReplicationResult);
this.localDatabase.openReplication(this.settings, false, showMessage, this.parseReplicationResult);
}
async initializeDatabase(showingNotice?: boolean) {
await this.openDatabase();
await this.syncAllFiles(showingNotice);
}
async replicateAllToServer(showingNotice?: boolean) {
if (this.settings.autoSweepPlugins) {
await this.sweepPlugin(showingNotice);
}
return await this.localDatabase.replicateAllToServer(this.settings, showingNotice);
}
async markRemoteLocked() {
return await this.localDatabase.markRemoteLocked(this.settings, true);
}
async markRemoteUnlocked() {
return await this.localDatabase.markRemoteLocked(this.settings, false);
}
async markRemoteResolved() {
return await this.localDatabase.markRemoteResolved(this.settings);
}
async syncAllFiles(showingNotice?: boolean) {
// synchronize all files between database and storage.
let notice: Notice = null;
if (showingNotice) {
notice = new Notice("Initializing", 0);
notice = NewNotice("Initializing", 0);
}
const filesStorage = this.app.vault.getFiles();
const filesStorageName = filesStorage.map((e) => e.path);
@@ -860,12 +1035,14 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
Logger(procedurename);
let i = 0;
// let lastTicks = performance.now() + 2000;
let workProcs = 0;
const procs = objects.map(async (e) => {
try {
workProcs++;
await callback(e);
i++;
if (i % 25 == 0) {
const notify = `${procedurename} : ${i}/${count}`;
const notify = `${procedurename} : ${workProcs}/${count} (Pending:${workProcs})`;
if (notice != null) notice.setMessage(notify);
Logger(notify);
this.setStatusBarText(notify);
@@ -873,25 +1050,12 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
} catch (ex) {
Logger(`Error while ${procedurename}`, LOG_LEVEL.NOTICE);
Logger(ex);
} finally {
workProcs--;
}
});
if (!Promise.allSettled) {
await Promise.all(
procs.map((p) =>
p
.then((value) => ({
status: "fulfilled",
value,
}))
.catch((reason) => ({
status: "rejected",
reason,
}))
)
);
} else {
await Promise.allSettled(procs);
}
await allSettledWithConcurrencyLimit(procs, 10);
};
await runAll("UPDATE DATABASE", onlyInStorage, async (e) => {
Logger(`Update into ${e.path}`);
@@ -911,6 +1075,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
Logger("Initialize done!", LOG_LEVEL.NOTICE);
}
}
async deleteFolderOnDB(folder: TFolder) {
Logger(`delete folder:${folder.path}`);
await this.localDatabase.deleteDBEntryPrefix(folder.path + "/");
@@ -995,6 +1160,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
}
return false;
}
/**
* Getting file conflicted status.
* @param path the file location
@@ -1055,6 +1221,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
diff: diff,
};
}
showMergeDialog(file: TFile, conflictCheckResult: diff_result): Promise<boolean> {
return new Promise((res, rej) => {
Logger("open conflict dialog", LOG_LEVEL.VERBOSE);
@@ -1074,10 +1241,10 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
//concat both,
// write data,and delete both old rev.
const p = conflictCheckResult.diff.map((e) => e[1]).join("");
await this.app.vault.modify(file, p);
await this.updateIntoDB(file);
await this.localDatabase.deleteDBEntry(file.path, { rev: conflictCheckResult.left.rev });
await this.localDatabase.deleteDBEntry(file.path, { rev: conflictCheckResult.right.rev });
await this.app.vault.modify(file, p);
await this.updateIntoDB(file);
await this.pullFile(file.path);
Logger("concat both file");
setTimeout(() => {
@@ -1100,10 +1267,12 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
}).open();
});
}
conflictedCheckFiles: string[] = [];
// queueing the conflicted file check
conflictedCheckTimer: number;
queueConflictedCheck(file: TFile) {
this.conflictedCheckFiles = this.conflictedCheckFiles.filter((e) => e != file.path);
this.conflictedCheckFiles.push(file.path);
@@ -1125,6 +1294,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
}
}, 1000);
}
async showIfConflicted(file: TFile) {
await runWithLock("conflicted", false, async () => {
const conflictCheckResult = await this.getConflictedStatus(file.path);
@@ -1144,6 +1314,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
await this.showMergeDialog(file, conflictCheckResult);
});
}
async pullFile(filename: string, fileList?: TFile[], force?: boolean, rev?: string, waitForReady = true) {
const targetFile = this.app.vault.getAbstractFileByPath(id2path(filename));
if (targetFile == null) {
@@ -1156,13 +1327,14 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
const file = targetFile;
const doc = await this.localDatabase.getDBEntry(filename, rev ? { rev: rev } : null, false, waitForReady);
if (doc === false) return;
await this.doc2storate_modify(doc, file, force);
await this.doc2storage_modify(doc, file, force);
} else {
Logger(`target files:${filename} is exists as the folder`);
//something went wrong..
}
//when to opened file;
}
async syncFileBetweenDBandStorage(file: TFile, fileList?: TFile[]) {
const doc = await this.localDatabase.getDBEntryMeta(file.path);
if (doc === false) return;
@@ -1180,7 +1352,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
Logger(`${storageMtime} < ${docMtime}`);
const docx = await this.localDatabase.getDBEntry(file.path, null, false, false);
if (docx != false) {
await this.doc2storate_modify(docx, file);
await this.doc2storage_modify(docx, file);
}
} else {
// Logger("EVEN :" + file.path, LOG_LEVEL.VERBOSE);
@@ -1196,7 +1368,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
await this.localDatabase.waitForGCComplete();
let content = "";
let datatype: "plain" | "newnote" = "newnote";
if (file.extension != "md") {
if (!isPlainText(file.name)) {
const contentBin = await this.app.vault.readBinary(file);
content = await arrayBufferToBase64(contentBin);
datatype = "newnote";
@@ -1236,6 +1408,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
await this.replicate();
}
}
async deleteFromDB(file: TFile) {
const fullpath = file.path;
Logger(`deleteDB By path:${fullpath}`);
@@ -1244,6 +1417,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
await this.replicate();
}
}
async deleteFromDBbyPath(fullpath: string) {
await this.localDatabase.deleteDBEntry(fullpath);
if (this.settings.syncOnSave && !this.suspended) {
@@ -1254,12 +1428,15 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
async resetLocalDatabase() {
await this.localDatabase.resetDatabase();
}
async tryResetRemoteDatabase() {
await this.localDatabase.tryResetRemoteDatabase(this.settings);
}
async tryCreateRemoteDatabase() {
await this.localDatabase.tryCreateRemoteDatabase(this.settings);
}
async getPluginList(): Promise<{ plugins: PluginList; allPlugins: DevicePluginList; thisDevicePlugins: DevicePluginList }> {
const db = this.localDatabase.localDatabase;
const docList = await db.allDocs<PluginDataEntry>({ startkey: `ps:`, endkey: `ps;`, include_docs: false });
@@ -1273,12 +1450,13 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
}
plugins[v.deviceVaultName].push(v);
allPlugins[v._id] = v;
if (v.deviceVaultName == this.settings.deviceAndVaultName) {
if (v.deviceVaultName == this.deviceAndVaultName) {
thisDevicePlugins[v.manifest.id] = v;
}
}
return { plugins, allPlugins, thisDevicePlugins };
}
async sweepPlugin(showMessage = false) {
if (!this.settings.usePluginSync) return;
await runWithLock("sweepplugin", false, async () => {
@@ -1287,13 +1465,17 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
Logger("You have to encrypt the database to use plugin setting sync.", LOG_LEVEL.NOTICE);
return;
}
if (!this.settings.deviceAndVaultName) {
if (!this.deviceAndVaultName) {
Logger("You have to set your device and vault name.", LOG_LEVEL.NOTICE);
return;
}
Logger("Sweeping plugins", logLevel);
Logger("Scanning plugins", logLevel);
const db = this.localDatabase.localDatabase;
const oldDocs = await db.allDocs({ startkey: `ps:${this.settings.deviceAndVaultName}-`, endkey: `ps:${this.settings.deviceAndVaultName}.`, include_docs: true });
const oldDocs = await db.allDocs({
startkey: `ps:${this.deviceAndVaultName}-`,
endkey: `ps:${this.deviceAndVaultName}.`,
include_docs: true,
});
Logger("OLD DOCS.", LOG_LEVEL.VERBOSE);
// sweep current plugin.
// @ts-ignore
@@ -1316,9 +1498,9 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
mtime = (await adapter.stat(path + "/data.json")).mtime;
}
const p: PluginDataEntry = {
_id: `ps:${this.settings.deviceAndVaultName}-${m.id}`,
_id: `ps:${this.deviceAndVaultName}-${m.id}`,
dataJson: pluginData["data.json"],
deviceVaultName: this.settings.deviceAndVaultName,
deviceVaultName: this.deviceAndVaultName,
mainJs: pluginData["main.js"],
styleCss: pluginData["styles.css"],
manifest: m,
@@ -1359,15 +1541,16 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
return e.doc;
});
await db.bulkDocs(delDocs);
Logger(`Sweep plugin done.`, logLevel);
Logger(`Scan plugin done.`, logLevel);
});
}
async applyPluginData(plugin: PluginDataEntry) {
await runWithLock("plugin-" + plugin.manifest.id, false, async () => {
const pluginTargetFolderPath = normalizePath(plugin.manifest.dir) + "/";
const adapter = this.app.vault.adapter;
// @ts-ignore
const stat = this.app.plugins.enabledPlugins[plugin.manifest.id];
const stat = this.app.plugins.enabledPlugins.has(plugin.manifest.id) == true;
if (stat) {
// @ts-ignore
await this.app.plugins.unloadPlugin(plugin.manifest.id);
@@ -1375,7 +1558,6 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
}
if (plugin.dataJson) await adapter.write(pluginTargetFolderPath + "data.json", plugin.dataJson);
Logger("wrote:" + pluginTargetFolderPath + "data.json", LOG_LEVEL.NOTICE);
// @ts-ignore
if (stat) {
// @ts-ignore
await this.app.plugins.loadPlugin(plugin.manifest.id);
@@ -1383,10 +1565,11 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
}
});
}
async applyPlugin(plugin: PluginDataEntry) {
await runWithLock("plugin-" + plugin.manifest.id, false, async () => {
// @ts-ignore
const stat = this.app.plugins.enabledPlugins[plugin.manifest.id];
const stat = this.app.plugins.enabledPlugins.has(plugin.manifest.id) == true;
if (stat) {
// @ts-ignore
await this.app.plugins.unloadPlugin(plugin.manifest.id);
@@ -1401,7 +1584,6 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
await adapter.write(pluginTargetFolderPath + "main.js", plugin.mainJs);
await adapter.write(pluginTargetFolderPath + "manifest.json", plugin.manifestJson);
if (plugin.styleCss) await adapter.write(pluginTargetFolderPath + "styles.css", plugin.styleCss);
// if (plugin.dataJson) await adapter.write(pluginTargetFolderPath + "data.json", plugin.dataJson);
if (stat) {
// @ts-ignore
await this.app.plugins.loadPlugin(plugin.manifest.id);

4
src/pouchdb-browser.ts Normal file
View File

@@ -0,0 +1,4 @@
import { PouchDB as PouchDB_ } from "../pouchdb-browser-webpack/dist/pouchdb-browser.js";
const Pouch: PouchDB.Static = PouchDB_;
export { Pouch as PouchDB };

View File

@@ -1,149 +1,7 @@
// docs should be encoded as base64, so 1 char -> 1 bytes
// and cloudant limitation is 1MB , we use 900kb;
import { PluginManifest } from "obsidian";
import { DatabaseEntry } from "./lib/src/types";
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;
batch_size: number;
batches_limit: number;
}
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,
batch_size: 250,
batches_limit: 40,
};
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;
export interface PluginDataEntry extends DatabaseEntry {
deviceVaultName: string;
mtime: number;
manifest: PluginManifest;
@@ -152,73 +10,10 @@ export interface PluginDataEntry {
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[];
}
@@ -226,5 +21,4 @@ export interface PluginList {
export interface DevicePluginList {
[key: string]: PluginDataEntry;
}
export const FLAGMD_REDFLAG = "redflag.md";
export const PERIODIC_PLUGIN_SWEEP = 60;

View File

@@ -1,209 +1,14 @@
import { normalizePath } from "obsidian";
import { Logger } from "./logger";
import { FLAGMD_REDFLAG, 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 = {
"<": "&lt;",
">": "&gt;",
"&": "&amp;",
'"': "&quot;",
"'": "&#39;",
"`": "&#x60;",
};
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 shouldBeIgnored(filename: string): boolean {
if (filename == FLAGMD_REDFLAG) {
return true;
}
return false;
}
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);
});
};
import { path2id_base, id2path_base } from "./lib/src/utils";
// 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;
const x = normalizePath(filename);
return path2id_base(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 async/await as like transacion ISOLATION 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();
});
});
} else {
if (pendingProcs && lockKey in pendingProcs && pendingProcs[lockKey].length == 0) {
delete pendingProcs[lockKey];
}
}
}
};
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);
// Logger(`Lock:${lockKey}:queud:left${pendingProcs[lockKey].length}`, LOG_LEVEL.VERBOSE);
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);
});
});
}
return id2path_base(normalizePath(filename));
}

View File

@@ -1,7 +1,8 @@
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";
import { Logger } from "./lib/src/logger";
import { LOG_LEVEL, VER, VERSIONINFO_DOCID, EntryVersionInfo, EntryDoc } from "./lib/src/types";
import { resolveWithIgnoreKnownError } from "./lib/src/utils";
import { PouchDB } from "./pouchdb-browser";
import { requestUrl, RequestUrlParam, RequestUrlResponse } from "obsidian";
export const isValidRemoteCouchDBURI = (uri: string): boolean => {
if (uri.startsWith("https://")) return true;
@@ -12,13 +13,34 @@ let last_post_successed = false;
export const getLastPostFailedBySize = () => {
return !last_post_successed;
};
export const connectRemoteCouchDB = async (uri: string, auth: { username: string; password: string }): Promise<string | { db: PouchDB.Database<EntryDoc>; info: PouchDB.Core.DatabaseInfo }> => {
const fetchByAPI = async (request: RequestUrlParam): Promise<RequestUrlResponse> => {
const ret = await requestUrl(request);
if (ret.status - (ret.status % 100) !== 200) {
const er: Error & { status?: number } = new Error(`Request Error:${ret.status}`);
if (ret.json) {
er.message = ret.json.reason;
er.name = `${ret.json.error ?? ""}:${ret.json.message ?? ""}`;
}
er.status = ret.status;
throw er;
}
return ret;
};
export const connectRemoteCouchDB = async (uri: string, auth: { username: string; password: string }, disableRequestURI: boolean): Promise<string | { db: PouchDB.Database<EntryDoc>; info: PouchDB.Core.DatabaseInfo }> => {
if (!isValidRemoteCouchDBURI(uri)) return "Remote URI is not valid";
let authHeader = "";
if (auth.username && auth.password) {
const utf8str = String.fromCharCode.apply(null, new TextEncoder().encode(`${auth.username}:${auth.password}`));
const encoded = window.btoa(utf8str);
authHeader = "Basic " + encoded;
} else {
authHeader = "";
}
const conf: PouchDB.HttpAdapter.HttpAdapterConfiguration = {
adapter: "http",
auth,
fetch: async function (url: string | Request, opts: RequestInit) {
let size_ok = true;
let size = "";
const localURL = url.toString().substring(uri.length);
const method = opts.method ?? "GET";
@@ -26,7 +48,6 @@ export const connectRemoteCouchDB = async (uri: string, auth: { username: string
const opts_length = opts.body.toString().length;
if (opts_length > 1024 * 1024 * 10) {
// over 10MB
size_ok = false;
if (uri.contains(".cloudantnosqldb.")) {
last_post_successed = false;
Logger("This request should fail on IBM Cloudant.", LOG_LEVEL.VERBOSE);
@@ -35,6 +56,52 @@ export const connectRemoteCouchDB = async (uri: string, auth: { username: string
}
size = ` (${opts_length})`;
}
if (!disableRequestURI && typeof url == "string" && typeof (opts.body ?? "") == "string") {
const body = opts.body as string;
const transformedHeaders = { ...(opts.headers as Record<string, string>) };
if (authHeader != "") transformedHeaders["authorization"] = authHeader;
delete transformedHeaders["host"];
delete transformedHeaders["Host"];
delete transformedHeaders["content-length"];
delete transformedHeaders["Content-Length"];
const requestParam: RequestUrlParam = {
url: url as string,
method: opts.method,
body: body,
headers: transformedHeaders,
contentType: "application/json",
// contentType: opts.headers,
};
try {
const r = await fetchByAPI(requestParam);
if (method == "POST" || method == "PUT") {
last_post_successed = r.status - (r.status % 100) == 200;
} else {
last_post_successed = true;
}
Logger(`HTTP:${method}${size} to:${localURL} -> ${r.status}`, LOG_LEVEL.VERBOSE);
return new Response(r.arrayBuffer, {
headers: r.headers,
status: r.status,
statusText: `${r.status}`,
});
} catch (ex) {
Logger(`HTTP:${method}${size} to:${localURL} -> failed`, LOG_LEVEL.VERBOSE);
// limit only in bulk_docs.
if (url.toString().indexOf("_bulk_docs") !== -1) {
last_post_successed = false;
}
Logger(ex);
throw ex;
}
}
// -old implementation
try {
const responce: Response = await fetch(url, opts);
if (method == "POST" || method == "PUT") {
@@ -46,7 +113,8 @@ export const connectRemoteCouchDB = async (uri: string, auth: { username: string
return responce;
} catch (ex) {
Logger(`HTTP:${method}${size} to:${localURL} -> failed`, LOG_LEVEL.VERBOSE);
if (!size_ok && (method == "POST" || method == "PUT")) {
// limit only in bulk_docs.
if (url.toString().indexOf("_bulk_docs") !== -1) {
last_post_successed = false;
}
Logger(ex);

View File

@@ -34,11 +34,13 @@
.sls-plugins-wrap {
display: flex;
flex-grow: 1;
/* overflow: scroll; */
max-height: 50vh;
overflow-y: scroll;
}
.sls-plugins-tbl {
border: 1px solid var(--background-modifier-border);
width: 100%;
max-height: 80%;
}
.divider th {
border-top: 1px solid var(--background-modifier-border);
@@ -140,3 +142,33 @@ div.sls-setting-menu-btn {
background-color: var(--background-secondary-alt);
color: var(--text-accent);
}
.op-flex {
display: flex;
}
.op-flex input {
display: inline-flex;
flex-grow: 1;
margin-bottom: 8px;
}
.op-info {
display: inline-flex;
flex-grow: 1;
border-bottom: 1px solid var(--background-modifier-border);
width: 100%;
margin-bottom: 4px;
padding-bottom: 4px;
}
.history-added {
color: var(--text-on-accent);
background-color: var(--text-accent);
}
.history-normal {
color: var(--text-normal);
}
.history-deleted {
color: var(--text-on-accent);
background-color: var(--text-muted);
text-decoration: line-through;
}

View File

@@ -1,21 +1,16 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "es6",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
// "importsNotUsedAsValues": "error",
"importHelpers": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"strictFunctionTypes": true,
"alwaysStrict": true,
"lib": ["dom", "es5", "ES6", "ES7", "es2020"]
"lib": ["es2018", "DOM", "ES5", "ES6", "ES7"]
},
"include": ["./src/*.ts"],
// "files": ["./src/main.ts"],
"include": ["**/*.ts"],
"exclude": ["pouchdb-browser-webpack"]
}