mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-05-09 09:11:51 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc210de58b | ||
|
|
1b2f9dd171 | ||
|
|
eef2281ae3 | ||
|
|
40ed2bbdcf | ||
|
|
92fd814c89 | ||
|
|
3118276603 |
3
.eslintignore
Normal file
3
.eslintignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
npm node_modules
|
||||||
|
build
|
||||||
|
.eslintrc.js.bak
|
||||||
19
.eslintrc
Normal file
19
.eslintrc
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"root": true,
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"plugins": ["@typescript-eslint"],
|
||||||
|
"extends": ["eslint:recommended", "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended"],
|
||||||
|
"parserOptions": {
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
|
||||||
|
"@typescript-eslint/ban-ts-comment": "off",
|
||||||
|
"no-prototype-builtins": "off",
|
||||||
|
"@typescript-eslint/no-empty-function": "off",
|
||||||
|
"require-await": "warn",
|
||||||
|
"no-async-promise-executor": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,7 +50,7 @@ Especially, in some companies, people have to store all data to their fully cont
|
|||||||
2. Get your database. IBM Cloudant is preferred for testing. Or you can use your own server with CouchDB.
|
2. Get your database. IBM Cloudant is preferred for testing. Or you can use your own server with CouchDB.
|
||||||
For more information, refer below:
|
For more information, refer below:
|
||||||
1. [Setup IBM Cloudant](docs/setup_cloudant.md)
|
1. [Setup IBM Cloudant](docs/setup_cloudant.md)
|
||||||
2. [Setup your CouchDB](docs/setup_own_server.md) (Now writing)
|
2. [Setup your CouchDB](docs/setup_own_server.md)
|
||||||
3. Enter connection information to Plugin's setting dialog. In details, refer [Settings of Self-hosted LiveSync](docs/settings.md)
|
3. Enter connection information to Plugin's setting dialog. In details, refer [Settings of Self-hosted LiveSync](docs/settings.md)
|
||||||
4. Enable LiveSync or other Synchronize method as you like.
|
4. Enable LiveSync or other Synchronize method as you like.
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ NDAや類似の契約や義務、倫理を守る必要のある、研究者、
|
|||||||
1. Community Pluginsから、Self-holsted LiveSyncと検索しインストールするか、このリポジトリのReleasesから`main.js`, `manifest.json`, `style.css` をダウンロードしvaultの中の`.obsidian/plugins/obsidian-livesync`に入れて、Obsidianを再起動してください。
|
1. Community Pluginsから、Self-holsted LiveSyncと検索しインストールするか、このリポジトリのReleasesから`main.js`, `manifest.json`, `style.css` をダウンロードしvaultの中の`.obsidian/plugins/obsidian-livesync`に入れて、Obsidianを再起動してください。
|
||||||
2. サーバーを確保します。IBM Cloudantがお手軽かつ堅牢で便利です。完全にセルフホストする際にはお持ちのサーバーにCouchDBをインストールする必要があります。詳しくは下記を参照してください
|
2. サーバーを確保します。IBM Cloudantがお手軽かつ堅牢で便利です。完全にセルフホストする際にはお持ちのサーバーにCouchDBをインストールする必要があります。詳しくは下記を参照してください
|
||||||
1. [IBM Cloudantのセットアップ](docs/setup_cloudant_ja.md)
|
1. [IBM Cloudantのセットアップ](docs/setup_cloudant_ja.md)
|
||||||
2. [独自のCouchDBのセットアップ](docs/setup_own_server_ja.md) (執筆中)
|
2. [独自のCouchDBのセットアップ](docs/setup_own_server_ja.md)
|
||||||
3. サーバー情報を入力します。初回のみ、Obsidianを再起動することをオススメします。
|
3. サーバー情報を入力します。初回のみ、Obsidianを再起動することをオススメします。
|
||||||
設定内容の詳細は[このプラグインの設定](docs/settings_ja.md)を参照してください。
|
設定内容の詳細は[このプラグインの設定](docs/settings_ja.md)を参照してください。
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,95 @@
|
|||||||
# Setup CouchDB to your server
|
# Setup CouchDB to your server
|
||||||
|
|
||||||
Coming soon!
|
|
||||||
|
## Install CouchDB and access from PC or Mac
|
||||||
|
|
||||||
|
The easiest way to set up the CouchDB is using the [docker image]((https://hub.docker.com/_/couchdb)).
|
||||||
|
|
||||||
|
But some additional configurations are required in `local.ini` to use from Self-hosted LiveSync, like below:
|
||||||
|
|
||||||
|
```
|
||||||
|
[couchdb]
|
||||||
|
single_node=true
|
||||||
|
|
||||||
|
[chttpd]
|
||||||
|
require_valid_user = true
|
||||||
|
|
||||||
|
[chttpd_auth]
|
||||||
|
require_valid_user = true
|
||||||
|
authentication_redirect = /_utils/session.html
|
||||||
|
|
||||||
|
[httpd]
|
||||||
|
WWW-Authenticate = Basic realm="couchdb"
|
||||||
|
enable_cors = true
|
||||||
|
|
||||||
|
[cors]
|
||||||
|
origins = app://obsidian.md,capacitor://localhost,http://localhost
|
||||||
|
credentials = true
|
||||||
|
headers = accept, authorization, content-type, origin, referer
|
||||||
|
methods = GET, PUT, POST, HEAD, DELETE
|
||||||
|
max_age = 3600
|
||||||
|
```
|
||||||
|
|
||||||
|
Make `local.ini` and run with docker run like this, you can launch the CouchDB.
|
||||||
|
```
|
||||||
|
$ docker run --rm -it -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password -v .local.ini:/opt/couchdb/etc/local.ini -p 5984:5984 couchdb
|
||||||
|
```
|
||||||
|
Note: At this time, the file owner of local.ini became 5984:5984. It's the limitation docker image. please change the owner before editing local.ini again.
|
||||||
|
|
||||||
|
If you could confirm that Self-hosted LiveSync can sync with the server, launch docker image as background as you like.
|
||||||
|
|
||||||
|
example)
|
||||||
|
```
|
||||||
|
$ docker run -d --restart always -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password -v .local.ini:/opt/couchdb/etc/local.ini -p 5984:5984 couchdb
|
||||||
|
```
|
||||||
|
|
||||||
|
## Access from mobile device
|
||||||
|
If you want to access Self-hosted LiveSync from mobile devices, you need a valid SSL certificate.
|
||||||
|
|
||||||
|
### Testing from mobile
|
||||||
|
In the testing phase, [localhost.run](http://localhost.run/) or something like services is very useful.
|
||||||
|
|
||||||
|
example on using localhost.run)
|
||||||
|
```
|
||||||
|
$ ssh -R 80:localhost:5984 nokey@localhost.run
|
||||||
|
Warning: Permanently added the RSA host key for IP address '35.171.254.69' to the list of known hosts.
|
||||||
|
|
||||||
|
===============================================================================
|
||||||
|
Welcome to localhost.run!
|
||||||
|
|
||||||
|
Follow your favourite reverse tunnel at [https://twitter.com/localhost_run].
|
||||||
|
|
||||||
|
**You need a SSH key to access this service.**
|
||||||
|
If you get a permission denied follow Gitlab's most excellent howto:
|
||||||
|
https://docs.gitlab.com/ee/ssh/
|
||||||
|
*Only rsa and ed25519 keys are supported*
|
||||||
|
|
||||||
|
To set up and manage custom domains go to https://admin.localhost.run/
|
||||||
|
|
||||||
|
More details on custom domains (and how to enable subdomains of your custom
|
||||||
|
domain) at https://localhost.run/docs/custom-domains
|
||||||
|
|
||||||
|
To explore using localhost.run visit the documentation site:
|
||||||
|
https://localhost.run/docs/
|
||||||
|
|
||||||
|
===============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
** your connection id is xxxxxxxxxxxxxxxxxxxxxxxxxxxx, please mention it if you send me a message about an issue. **
|
||||||
|
|
||||||
|
xxxxxxxx.localhost.run tunneled with tls termination, https://xxxxxxxx.localhost.run
|
||||||
|
Connection to localhost.run closed by remote host.
|
||||||
|
Connection to localhost.run closed.
|
||||||
|
```
|
||||||
|
|
||||||
|
https://xxxxxxxx.localhost.run is the temporary server address.
|
||||||
|
|
||||||
|
### Setting up your domain
|
||||||
|
|
||||||
|
Set the A record of your domain to point to your server, and host reverse proxy as you like.
|
||||||
|
Note: Mounting CouchDB on the top directory is not recommended.
|
||||||
|
Using Caddy is a handy way to serve the server with SSL automatically.
|
||||||
|
|
||||||
|
I have published [docker-compose.yml and ini files](https://github.com/vrtmrz/self-hosted-livesync-server) that launches Caddy and CouchDB at once. Please try it out.
|
||||||
|
|
||||||
|
And, be sure to check the server log and be careful of malicious access.
|
||||||
@@ -1,3 +1,91 @@
|
|||||||
# CouchDB のセットアップ方法
|
# CouchDBのセットアップ方法
|
||||||
|
|
||||||
早めに作成します!
|
## CouchDBのインストールとPCやMacでの使用
|
||||||
|
CouchDBを構築するには、[Dockerのイメージ](https://hub.docker.com/_/couchdb)を使用するのが一番簡単です。
|
||||||
|
ただし、インストールしたCouchDBをSelf-hosted LiveSyncから使用するためには、少々設定が必要となります。
|
||||||
|
具体的には、下記の設定が`local.ini`として必要になります。
|
||||||
|
|
||||||
|
```
|
||||||
|
[couchdb]
|
||||||
|
single_node=true
|
||||||
|
|
||||||
|
[chttpd]
|
||||||
|
require_valid_user = true
|
||||||
|
|
||||||
|
[chttpd_auth]
|
||||||
|
require_valid_user = true
|
||||||
|
authentication_redirect = /_utils/session.html
|
||||||
|
|
||||||
|
[httpd]
|
||||||
|
WWW-Authenticate = Basic realm="couchdb"
|
||||||
|
enable_cors = true
|
||||||
|
|
||||||
|
[cors]
|
||||||
|
origins = app://obsidian.md,capacitor://localhost,http://localhost
|
||||||
|
credentials = true
|
||||||
|
headers = accept, authorization, content-type, origin, referer
|
||||||
|
methods = GET, PUT, POST, HEAD, DELETE
|
||||||
|
max_age = 3600
|
||||||
|
```
|
||||||
|
|
||||||
|
このファイルを作成し、
|
||||||
|
```
|
||||||
|
$ docker run --rm -it -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password -v .local.ini:/opt/couchdb/etc/local.ini -p 5984:5984 couchdb
|
||||||
|
```
|
||||||
|
とすると簡単にCouchDBを起動することができます。
|
||||||
|
備考:このとき、local.iniのオーナーが5984:5984になります。これは、Dockerイメージの制限事項です。編集する場合はいったんオーナーを変更してください。
|
||||||
|
正常にSelf-hosted LiveSyncからアクセスすることができたら、お好みでバックグラウンドで起動するように編集して起動してください。
|
||||||
|
例)
|
||||||
|
```
|
||||||
|
$ docker run -d --restart always -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password -v .local.ini:/opt/couchdb/etc/local.ini -p 5984:5984 couchdb
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## モバイルからのアクセス
|
||||||
|
MacやPCからアクセスする場合は上記の方法で作ったサーバーで問題ありませんが、モバイル端末からアクセスする場合は有効なSSLの証明書が必要となります。
|
||||||
|
|
||||||
|
### モバイルからのアクセスのテスト
|
||||||
|
テストを行う場合は、[localhost.run](http://localhost.run/)などのサービスが便利です。
|
||||||
|
```
|
||||||
|
$ ssh -R 80:localhost:5984 nokey@localhost.run
|
||||||
|
Warning: Permanently added the RSA host key for IP address '35.171.254.69' to the list of known hosts.
|
||||||
|
|
||||||
|
===============================================================================
|
||||||
|
Welcome to localhost.run!
|
||||||
|
|
||||||
|
Follow your favourite reverse tunnel at [https://twitter.com/localhost_run].
|
||||||
|
|
||||||
|
**You need a SSH key to access this service.**
|
||||||
|
If you get a permission denied follow Gitlab's most excellent howto:
|
||||||
|
https://docs.gitlab.com/ee/ssh/
|
||||||
|
*Only rsa and ed25519 keys are supported*
|
||||||
|
|
||||||
|
To set up and manage custom domains go to https://admin.localhost.run/
|
||||||
|
|
||||||
|
More details on custom domains (and how to enable subdomains of your custom
|
||||||
|
domain) at https://localhost.run/docs/custom-domains
|
||||||
|
|
||||||
|
To explore using localhost.run visit the documentation site:
|
||||||
|
https://localhost.run/docs/
|
||||||
|
|
||||||
|
===============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
** your connection id is xxxxxxxxxxxxxxxxxxxxxxxxxxxx, please mention it if you send me a message about an issue. **
|
||||||
|
|
||||||
|
xxxxxxxx.localhost.run tunneled with tls termination, https://xxxxxxxx.localhost.run
|
||||||
|
Connection to localhost.run closed by remote host.
|
||||||
|
Connection to localhost.run closed.
|
||||||
|
```
|
||||||
|
このように表示された場合、`https://xxxxxxxx.localhost.run`が一時的なサーバアドレスとして使用できます。
|
||||||
|
|
||||||
|
### ドメインを設定してアクセスする。
|
||||||
|
|
||||||
|
DNSのAレコードを設定し、お好みの方法でリバースプロキシをホスティングしてください。
|
||||||
|
備考:トップディレクトリにCouchDBを露出させるのはおすすめしません。
|
||||||
|
Caddy等でLet's Encryptの証明書を自動取得すると運用が楽になります。
|
||||||
|
|
||||||
|
CaddyとCouchDBを同時に立てられる[docker-composeの設定とiniファイル](https://github.com/vrtmrz/self-hosted-livesync-server)を公開しています。
|
||||||
|
ぜひご利用下さい。
|
||||||
|
|
||||||
|
なお、サーバのログは必ず確認し、不正なアクセスに注意してください。
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "obsidian-livesync",
|
"id": "obsidian-livesync",
|
||||||
"name": "Self-hosted LiveSync",
|
"name": "Self-hosted LiveSync",
|
||||||
"version": "0.1.24",
|
"version": "0.2.0",
|
||||||
"minAppVersion": "0.9.12",
|
"minAppVersion": "0.9.12",
|
||||||
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||||
"author": "vorotamoroz",
|
"author": "vorotamoroz",
|
||||||
|
|||||||
4036
package-lock.json
generated
4036
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "obsidian-livesync",
|
"name": "obsidian-livesync",
|
||||||
"version": "0.1.24",
|
"version": "0.2.0",
|
||||||
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "rollup --config rollup.config.js -w",
|
"dev": "rollup --config rollup.config.js -w",
|
||||||
"build": "rollup --config rollup.config.js --environment BUILD:production"
|
"build": "rollup --config rollup.config.js --environment BUILD:production",
|
||||||
|
"lint": "eslint src"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "vorotamoroz",
|
"author": "vorotamoroz",
|
||||||
@@ -16,6 +17,11 @@
|
|||||||
"@rollup/plugin-typescript": "^8.2.1",
|
"@rollup/plugin-typescript": "^8.2.1",
|
||||||
"@types/diff-match-patch": "^1.0.32",
|
"@types/diff-match-patch": "^1.0.32",
|
||||||
"@types/pouchdb-browser": "^6.1.3",
|
"@types/pouchdb-browser": "^6.1.3",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.7.0",
|
||||||
|
"@typescript-eslint/parser": "^5.0.0",
|
||||||
|
"eslint": "^7.32.0",
|
||||||
|
"eslint-config-airbnb-base": "^14.2.1",
|
||||||
|
"eslint-plugin-import": "^2.25.2",
|
||||||
"obsidian": "^0.12.0",
|
"obsidian": "^0.12.0",
|
||||||
"rollup": "^2.32.1",
|
"rollup": "^2.32.1",
|
||||||
"tslib": "^2.2.0",
|
"tslib": "^2.2.0",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ if you want to view the source visit the plugins github repository
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
input: "main.ts",
|
input: "./src/main.ts",
|
||||||
output: {
|
output: {
|
||||||
dir: ".",
|
dir: ".",
|
||||||
sourcemap: "inline",
|
sourcemap: "inline",
|
||||||
|
|||||||
74
src/ConflictResolveModal.ts
Normal file
74
src/ConflictResolveModal.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { App, Modal } from "obsidian";
|
||||||
|
import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT } from "diff-match-patch";
|
||||||
|
import { diff_result } from "./types";
|
||||||
|
import { escapeStringToHTML } from "./utils";
|
||||||
|
|
||||||
|
export class ConflictResolveModal extends Modal {
|
||||||
|
// result: Array<[number, string]>;
|
||||||
|
result: diff_result;
|
||||||
|
callback: (remove_rev: string) => Promise<void>;
|
||||||
|
constructor(app: App, diff: diff_result, callback: (remove_rev: string) => Promise<void>) {
|
||||||
|
super(app);
|
||||||
|
this.result = diff;
|
||||||
|
this.callback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
|
||||||
|
contentEl.empty();
|
||||||
|
|
||||||
|
contentEl.createEl("h2", { text: "This document has conflicted changes." });
|
||||||
|
const div = contentEl.createDiv("");
|
||||||
|
div.addClass("op-scrollable");
|
||||||
|
let diff = "";
|
||||||
|
for (const v of this.result.diff) {
|
||||||
|
const x1 = v[0];
|
||||||
|
const x2 = v[1];
|
||||||
|
if (x1 == DIFF_DELETE) {
|
||||||
|
diff += "<span class='deleted'>" + escapeStringToHTML(x2) + "</span>";
|
||||||
|
} else if (x1 == DIFF_EQUAL) {
|
||||||
|
diff += "<span class='normal'>" + escapeStringToHTML(x2) + "</span>";
|
||||||
|
} else if (x1 == DIFF_INSERT) {
|
||||||
|
diff += "<span class='added'>" + escapeStringToHTML(x2) + "</span>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diff = diff.replace(/\n/g, "<br>");
|
||||||
|
div.innerHTML = diff;
|
||||||
|
const div2 = contentEl.createDiv("");
|
||||||
|
const date1 = new Date(this.result.left.mtime).toLocaleString();
|
||||||
|
const date2 = new Date(this.result.right.mtime).toLocaleString();
|
||||||
|
div2.innerHTML = `
|
||||||
|
<span class='deleted'>A:${date1}</span><br /><span class='added'>B:${date2}</span><br>
|
||||||
|
`;
|
||||||
|
contentEl.createEl("button", { text: "Keep A" }, (e) => {
|
||||||
|
e.addEventListener("click", async () => {
|
||||||
|
await this.callback(this.result.right.rev);
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
contentEl.createEl("button", { text: "Keep B" }, (e) => {
|
||||||
|
e.addEventListener("click", async () => {
|
||||||
|
await this.callback(this.result.left.rev);
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
contentEl.createEl("button", { text: "Concat both" }, (e) => {
|
||||||
|
e.addEventListener("click", async () => {
|
||||||
|
await this.callback(null);
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
contentEl.createEl("button", { text: "Not now" }, (e) => {
|
||||||
|
e.addEventListener("click", () => {
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
1229
src/LocalPouchDB.ts
Normal file
1229
src/LocalPouchDB.ts
Normal file
File diff suppressed because it is too large
Load Diff
37
src/LogDisplayModal.ts
Normal file
37
src/LogDisplayModal.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { App, Modal } from "obsidian";
|
||||||
|
import { escapeStringToHTML } from "./utils";
|
||||||
|
import ObsidianLiveSyncPlugin from "./main";
|
||||||
|
|
||||||
|
export class LogDisplayModal extends Modal {
|
||||||
|
plugin: ObsidianLiveSyncPlugin;
|
||||||
|
logEl: HTMLDivElement;
|
||||||
|
constructor(app: App, plugin: ObsidianLiveSyncPlugin) {
|
||||||
|
super(app);
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
updateLog() {
|
||||||
|
let msg = "";
|
||||||
|
for (const v of this.plugin.logMessage) {
|
||||||
|
msg += escapeStringToHTML(v) + "<br>";
|
||||||
|
}
|
||||||
|
this.logEl.innerHTML = msg;
|
||||||
|
}
|
||||||
|
onOpen() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
|
||||||
|
contentEl.empty();
|
||||||
|
contentEl.createEl("h2", { text: "Sync Status" });
|
||||||
|
const div = contentEl.createDiv("");
|
||||||
|
div.addClass("op-scrollable");
|
||||||
|
div.addClass("op-pre");
|
||||||
|
this.logEl = div;
|
||||||
|
this.updateLog = this.updateLog.bind(this);
|
||||||
|
this.plugin.addLogHook = this.updateLog;
|
||||||
|
this.updateLog();
|
||||||
|
}
|
||||||
|
onClose() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
this.plugin.addLogHook = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
1093
src/ObsidianLiveSyncSettingTab.ts
Normal file
1093
src/ObsidianLiveSyncSettingTab.ts
Normal file
File diff suppressed because it is too large
Load Diff
168
src/e2ee.ts
Normal file
168
src/e2ee.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { Logger } from "./logger";
|
||||||
|
import { LOG_LEVEL } from "./types";
|
||||||
|
|
||||||
|
export type encodedData = [encryptedData: string, iv: string, salt: string];
|
||||||
|
export type KeyBuffer = {
|
||||||
|
index: string;
|
||||||
|
key: CryptoKey;
|
||||||
|
salt: Uint8Array;
|
||||||
|
};
|
||||||
|
|
||||||
|
const KeyBuffs: KeyBuffer[] = [];
|
||||||
|
const decKeyBuffs: KeyBuffer[] = [];
|
||||||
|
|
||||||
|
const KEY_RECYCLE_COUNT = 100;
|
||||||
|
let recycleCount = KEY_RECYCLE_COUNT;
|
||||||
|
|
||||||
|
let semiStaticFieldBuffer: Uint8Array = null;
|
||||||
|
const nonceBuffer: Uint32Array = new Uint32Array(1);
|
||||||
|
|
||||||
|
export async function getKeyForEncrypt(passphrase: string): Promise<[CryptoKey, Uint8Array]> {
|
||||||
|
// For performance, the plugin reuses the key KEY_RECYCLE_COUNT times.
|
||||||
|
const f = KeyBuffs.find((e) => e.index == passphrase);
|
||||||
|
if (f) {
|
||||||
|
recycleCount--;
|
||||||
|
if (recycleCount > 0) {
|
||||||
|
return [f.key, f.salt];
|
||||||
|
}
|
||||||
|
KeyBuffs.remove(f);
|
||||||
|
recycleCount = KEY_RECYCLE_COUNT;
|
||||||
|
}
|
||||||
|
const xpassphrase = new TextEncoder().encode(passphrase);
|
||||||
|
const digest = await crypto.subtle.digest({ name: "SHA-256" }, xpassphrase);
|
||||||
|
const keyMaterial = await crypto.subtle.importKey("raw", digest, { name: "PBKDF2" }, false, ["deriveKey"]);
|
||||||
|
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const key = await crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: "PBKDF2",
|
||||||
|
salt,
|
||||||
|
iterations: 100000,
|
||||||
|
hash: "SHA-256",
|
||||||
|
},
|
||||||
|
keyMaterial,
|
||||||
|
{ name: "AES-GCM", length: 256 },
|
||||||
|
false,
|
||||||
|
["encrypt"]
|
||||||
|
);
|
||||||
|
KeyBuffs.push({
|
||||||
|
index: passphrase,
|
||||||
|
key,
|
||||||
|
salt,
|
||||||
|
});
|
||||||
|
while (KeyBuffs.length > 50) {
|
||||||
|
KeyBuffs.shift();
|
||||||
|
}
|
||||||
|
return [key, salt];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getKeyForDecryption(passphrase: string, salt: Uint8Array): Promise<[CryptoKey, Uint8Array]> {
|
||||||
|
const bufKey = passphrase + uint8ArrayToHexString(salt);
|
||||||
|
const f = decKeyBuffs.find((e) => e.index == bufKey);
|
||||||
|
if (f) {
|
||||||
|
return [f.key, f.salt];
|
||||||
|
}
|
||||||
|
const xpassphrase = new TextEncoder().encode(passphrase);
|
||||||
|
const digest = await crypto.subtle.digest({ name: "SHA-256" }, xpassphrase);
|
||||||
|
const keyMaterial = await crypto.subtle.importKey("raw", digest, { name: "PBKDF2" }, false, ["deriveKey"]);
|
||||||
|
const key = await crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: "PBKDF2",
|
||||||
|
salt,
|
||||||
|
iterations: 100000,
|
||||||
|
hash: "SHA-256",
|
||||||
|
},
|
||||||
|
keyMaterial,
|
||||||
|
{ name: "AES-GCM", length: 256 },
|
||||||
|
false,
|
||||||
|
["decrypt"]
|
||||||
|
);
|
||||||
|
decKeyBuffs.push({
|
||||||
|
index: bufKey,
|
||||||
|
key,
|
||||||
|
salt,
|
||||||
|
});
|
||||||
|
while (decKeyBuffs.length > 50) {
|
||||||
|
decKeyBuffs.shift();
|
||||||
|
}
|
||||||
|
return [key, salt];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSemiStaticField(reset?: boolean) {
|
||||||
|
// return fixed field of iv.
|
||||||
|
if (semiStaticFieldBuffer != null && !reset) {
|
||||||
|
return semiStaticFieldBuffer;
|
||||||
|
}
|
||||||
|
semiStaticFieldBuffer = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
return semiStaticFieldBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNonce() {
|
||||||
|
// This is nonce, so do not send same thing.
|
||||||
|
nonceBuffer[0]++;
|
||||||
|
if (nonceBuffer[0] > 10000) {
|
||||||
|
// reset semi-static field.
|
||||||
|
getSemiStaticField(true);
|
||||||
|
}
|
||||||
|
return nonceBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uint8ArrayToHexString(src: Uint8Array): string {
|
||||||
|
return Array.from(src)
|
||||||
|
.map((e: number): string => `00${e.toString(16)}`.slice(-2))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
function hexStringToUint8Array(src: string): Uint8Array {
|
||||||
|
const srcArr = [...src];
|
||||||
|
const arr = srcArr.reduce((acc, _, i) => (i % 2 ? acc : [...acc, srcArr.slice(i, i + 2).join("")]), []).map((e) => parseInt(e, 16));
|
||||||
|
return Uint8Array.from(arr);
|
||||||
|
}
|
||||||
|
export async function encrypt(input: string, passphrase: string) {
|
||||||
|
const [key, salt] = await getKeyForEncrypt(passphrase);
|
||||||
|
// Create initial vector with semifixed part and incremental part
|
||||||
|
// I think it's not good against related-key attacks.
|
||||||
|
const fixedPart = getSemiStaticField();
|
||||||
|
const invocationPart = getNonce();
|
||||||
|
const iv = Uint8Array.from([...fixedPart, ...new Uint8Array(invocationPart.buffer)]);
|
||||||
|
const plainStringified: string = JSON.stringify(input);
|
||||||
|
const plainStringBuffer: Uint8Array = new TextEncoder().encode(plainStringified);
|
||||||
|
const encryptedDataArrayBuffer = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plainStringBuffer);
|
||||||
|
|
||||||
|
const encryptedData = window.btoa(Array.from(new Uint8Array(encryptedDataArrayBuffer), (char) => String.fromCharCode(char)).join(""));
|
||||||
|
|
||||||
|
//return data with iv and salt.
|
||||||
|
const response: encodedData = [encryptedData, uint8ArrayToHexString(iv), uint8ArrayToHexString(salt)];
|
||||||
|
const ret = JSON.stringify(response);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decrypt(encryptedResult: string, passphrase: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const [encryptedData, ivString, salt]: encodedData = JSON.parse(encryptedResult);
|
||||||
|
const [key] = await getKeyForDecryption(passphrase, hexStringToUint8Array(salt));
|
||||||
|
const iv = hexStringToUint8Array(ivString);
|
||||||
|
// decode base 64, it should increase speed and i should with in MAX_DOC_SIZE_BIN, so it won't OOM.
|
||||||
|
const encryptedDataBin = window.atob(encryptedData);
|
||||||
|
const encryptedDataArrayBuffer = Uint8Array.from(encryptedDataBin.split(""), (char) => char.charCodeAt(0));
|
||||||
|
const plainStringBuffer: ArrayBuffer = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, encryptedDataArrayBuffer);
|
||||||
|
const plainStringified = new TextDecoder().decode(plainStringBuffer);
|
||||||
|
const plain = JSON.parse(plainStringified);
|
||||||
|
return plain;
|
||||||
|
} catch (ex) {
|
||||||
|
Logger("Couldn't decode! You should wrong the passphrases", LOG_LEVEL.VERBOSE);
|
||||||
|
Logger(ex, LOG_LEVEL.VERBOSE);
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testCrypt() {
|
||||||
|
const src = "supercalifragilisticexpialidocious";
|
||||||
|
const encoded = await encrypt(src, "passwordTest");
|
||||||
|
const decrypted = await decrypt(encoded, "passwordTest");
|
||||||
|
if (src != decrypted) {
|
||||||
|
Logger("WARNING! Your device would not support encryption.", LOG_LEVEL.VERBOSE);
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
Logger("CRYPT LOGIC OK", LOG_LEVEL.VERBOSE);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/logger.ts
Normal file
13
src/logger.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { LOG_LEVEL } from "./types";
|
||||||
|
|
||||||
|
// eslint-disable-next-line require-await
|
||||||
|
export let Logger: (message: any, levlel?: LOG_LEVEL) => Promise<void> = async (message, _) => {
|
||||||
|
const timestamp = new Date().toLocaleString();
|
||||||
|
const messagecontent = typeof message == "string" ? message : message instanceof Error ? `${message.name}:${message.message}` : JSON.stringify(message, null, 2);
|
||||||
|
const newmessage = timestamp + "->" + messagecontent;
|
||||||
|
console.log(newmessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function setLogger(loggerFun: (message: any, levlel?: LOG_LEVEL) => Promise<void>) {
|
||||||
|
Logger = loggerFun;
|
||||||
|
}
|
||||||
1300
src/main.ts
Normal file
1300
src/main.ts
Normal file
File diff suppressed because it is too large
Load Diff
224
src/types.ts
Normal file
224
src/types.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
// docs should be encoded as base64, so 1 char -> 1 bytes
|
||||||
|
// and cloudant limitation is 1MB , we use 900kb;
|
||||||
|
|
||||||
|
import { PluginManifest } from "obsidian";
|
||||||
|
|
||||||
|
export const MAX_DOC_SIZE = 1000; // for .md file, but if delimiters exists. use that before.
|
||||||
|
export const MAX_DOC_SIZE_BIN = 102400; // 100kb
|
||||||
|
export const VER = 10;
|
||||||
|
|
||||||
|
export const RECENT_MOFIDIED_DOCS_QTY = 30;
|
||||||
|
export const LEAF_WAIT_TIMEOUT = 90000; // in synchronization, waiting missing leaf time out.
|
||||||
|
export const LOG_LEVEL = {
|
||||||
|
VERBOSE: 1,
|
||||||
|
INFO: 10,
|
||||||
|
NOTICE: 100,
|
||||||
|
URGENT: 1000,
|
||||||
|
} as const;
|
||||||
|
export type LOG_LEVEL = typeof LOG_LEVEL[keyof typeof LOG_LEVEL];
|
||||||
|
export const VERSIONINFO_DOCID = "obsydian_livesync_version";
|
||||||
|
export const MILSTONE_DOCID = "_local/obsydian_livesync_milestone";
|
||||||
|
export const NODEINFO_DOCID = "_local/obsydian_livesync_nodeinfo";
|
||||||
|
|
||||||
|
export interface ObsidianLiveSyncSettings {
|
||||||
|
couchDB_URI: string;
|
||||||
|
couchDB_USER: string;
|
||||||
|
couchDB_PASSWORD: string;
|
||||||
|
couchDB_DBNAME: string;
|
||||||
|
liveSync: boolean;
|
||||||
|
syncOnSave: boolean;
|
||||||
|
syncOnStart: boolean;
|
||||||
|
syncOnFileOpen: boolean;
|
||||||
|
savingDelay: number;
|
||||||
|
lessInformationInLog: boolean;
|
||||||
|
gcDelay: number;
|
||||||
|
versionUpFlash: string;
|
||||||
|
minimumChunkSize: number;
|
||||||
|
longLineThreshold: number;
|
||||||
|
showVerboseLog: boolean;
|
||||||
|
suspendFileWatching: boolean;
|
||||||
|
trashInsteadDelete: boolean;
|
||||||
|
periodicReplication: boolean;
|
||||||
|
periodicReplicationInterval: number;
|
||||||
|
encrypt: boolean;
|
||||||
|
passphrase: string;
|
||||||
|
workingEncrypt: boolean;
|
||||||
|
workingPassphrase: string;
|
||||||
|
doNotDeleteFolder: boolean;
|
||||||
|
resolveConflictsByNewerFile: boolean;
|
||||||
|
batchSave: boolean;
|
||||||
|
deviceAndVaultName: string;
|
||||||
|
usePluginSettings: boolean;
|
||||||
|
showOwnPlugins: boolean;
|
||||||
|
showStatusOnEditor: boolean;
|
||||||
|
usePluginSync: boolean;
|
||||||
|
autoSweepPlugins: boolean;
|
||||||
|
autoSweepPluginsPeriodic: boolean;
|
||||||
|
notifyPluginOrSettingUpdated: boolean;
|
||||||
|
checkIntegrityOnSave: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: ObsidianLiveSyncSettings = {
|
||||||
|
couchDB_URI: "",
|
||||||
|
couchDB_USER: "",
|
||||||
|
couchDB_PASSWORD: "",
|
||||||
|
couchDB_DBNAME: "",
|
||||||
|
liveSync: false,
|
||||||
|
syncOnSave: false,
|
||||||
|
syncOnStart: false,
|
||||||
|
savingDelay: 200,
|
||||||
|
lessInformationInLog: false,
|
||||||
|
gcDelay: 300,
|
||||||
|
versionUpFlash: "",
|
||||||
|
minimumChunkSize: 20,
|
||||||
|
longLineThreshold: 250,
|
||||||
|
showVerboseLog: false,
|
||||||
|
suspendFileWatching: false,
|
||||||
|
trashInsteadDelete: true,
|
||||||
|
periodicReplication: false,
|
||||||
|
periodicReplicationInterval: 60,
|
||||||
|
syncOnFileOpen: false,
|
||||||
|
encrypt: false,
|
||||||
|
passphrase: "",
|
||||||
|
workingEncrypt: false,
|
||||||
|
workingPassphrase: "",
|
||||||
|
doNotDeleteFolder: false,
|
||||||
|
resolveConflictsByNewerFile: false,
|
||||||
|
batchSave: false,
|
||||||
|
deviceAndVaultName: "",
|
||||||
|
usePluginSettings: false,
|
||||||
|
showOwnPlugins: false,
|
||||||
|
showStatusOnEditor: false,
|
||||||
|
usePluginSync: false,
|
||||||
|
autoSweepPlugins: false,
|
||||||
|
autoSweepPluginsPeriodic: false,
|
||||||
|
notifyPluginOrSettingUpdated: false,
|
||||||
|
checkIntegrityOnSave: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PERIODIC_PLUGIN_SWEEP = 60;
|
||||||
|
|
||||||
|
export interface Entry {
|
||||||
|
_id: string;
|
||||||
|
data: string;
|
||||||
|
_rev?: string;
|
||||||
|
ctime: number;
|
||||||
|
mtime: number;
|
||||||
|
size: number;
|
||||||
|
_deleted?: boolean;
|
||||||
|
_conflicts?: string[];
|
||||||
|
type?: "notes";
|
||||||
|
}
|
||||||
|
export interface NewEntry {
|
||||||
|
_id: string;
|
||||||
|
children: string[];
|
||||||
|
_rev?: string;
|
||||||
|
ctime: number;
|
||||||
|
mtime: number;
|
||||||
|
size: number;
|
||||||
|
_deleted?: boolean;
|
||||||
|
_conflicts?: string[];
|
||||||
|
NewNote: true;
|
||||||
|
type: "newnote";
|
||||||
|
}
|
||||||
|
export interface PlainEntry {
|
||||||
|
_id: string;
|
||||||
|
children: string[];
|
||||||
|
_rev?: string;
|
||||||
|
ctime: number;
|
||||||
|
mtime: number;
|
||||||
|
size: number;
|
||||||
|
_deleted?: boolean;
|
||||||
|
NewNote: true;
|
||||||
|
_conflicts?: string[];
|
||||||
|
type: "plain";
|
||||||
|
}
|
||||||
|
export type LoadedEntry = Entry & {
|
||||||
|
children: string[];
|
||||||
|
datatype: "plain" | "newnote";
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PluginDataEntry {
|
||||||
|
_id: string;
|
||||||
|
deviceVaultName: string;
|
||||||
|
mtime: number;
|
||||||
|
manifest: PluginManifest;
|
||||||
|
mainJs: string;
|
||||||
|
manifestJson: string;
|
||||||
|
styleCss?: string;
|
||||||
|
// it must be encrypted.
|
||||||
|
dataJson?: string;
|
||||||
|
_rev?: string;
|
||||||
|
_deleted?: boolean;
|
||||||
|
_conflicts?: string[];
|
||||||
|
type: "plugin";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryLeaf {
|
||||||
|
_id: string;
|
||||||
|
data: string;
|
||||||
|
_deleted?: boolean;
|
||||||
|
type: "leaf";
|
||||||
|
_rev?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryVersionInfo {
|
||||||
|
_id: typeof VERSIONINFO_DOCID;
|
||||||
|
_rev?: string;
|
||||||
|
type: "versioninfo";
|
||||||
|
version: number;
|
||||||
|
_deleted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryMilestoneInfo {
|
||||||
|
_id: typeof MILSTONE_DOCID;
|
||||||
|
_rev?: string;
|
||||||
|
type: "milestoneinfo";
|
||||||
|
_deleted?: boolean;
|
||||||
|
created: number;
|
||||||
|
accepted_nodes: string[];
|
||||||
|
locked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryNodeInfo {
|
||||||
|
_id: typeof NODEINFO_DOCID;
|
||||||
|
_rev?: string;
|
||||||
|
_deleted?: boolean;
|
||||||
|
type: "nodeinfo";
|
||||||
|
nodeid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EntryBody = Entry | NewEntry | PlainEntry;
|
||||||
|
export type EntryDoc = EntryBody | LoadedEntry | EntryLeaf | EntryVersionInfo | EntryMilestoneInfo | EntryNodeInfo;
|
||||||
|
|
||||||
|
export type diff_result_leaf = {
|
||||||
|
rev: string;
|
||||||
|
data: string;
|
||||||
|
ctime: number;
|
||||||
|
mtime: number;
|
||||||
|
};
|
||||||
|
export type dmp_result = Array<[number, string]>;
|
||||||
|
|
||||||
|
export type diff_result = {
|
||||||
|
left: diff_result_leaf;
|
||||||
|
right: diff_result_leaf;
|
||||||
|
diff: dmp_result;
|
||||||
|
};
|
||||||
|
export type diff_check_result = boolean | diff_result;
|
||||||
|
|
||||||
|
export type Credential = {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EntryDocResponse = EntryDoc & PouchDB.Core.IdMeta & PouchDB.Core.GetMeta;
|
||||||
|
|
||||||
|
export type DatabaseConnectingStatus = "STARTED" | "NOT_CONNECTED" | "PAUSED" | "CONNECTED" | "COMPLETED" | "CLOSED" | "ERRORED";
|
||||||
|
|
||||||
|
export interface PluginList {
|
||||||
|
[key: string]: PluginDataEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DevicePluginList {
|
||||||
|
[key: string]: PluginDataEntry;
|
||||||
|
}
|
||||||
197
src/utils.ts
Normal file
197
src/utils.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { normalizePath } from "obsidian";
|
||||||
|
import { Logger } from "./logger";
|
||||||
|
import { LOG_LEVEL } from "./types";
|
||||||
|
|
||||||
|
export function arrayBufferToBase64(buffer: ArrayBuffer): Promise<string> {
|
||||||
|
return new Promise((res) => {
|
||||||
|
const blob = new Blob([buffer], { type: "application/octet-binary" });
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = function (evt) {
|
||||||
|
const dataurl = evt.target.result.toString();
|
||||||
|
res(dataurl.substr(dataurl.indexOf(",") + 1));
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function base64ToString(base64: string): string {
|
||||||
|
try {
|
||||||
|
const binary_string = window.atob(base64);
|
||||||
|
const len = binary_string.length;
|
||||||
|
const bytes = new Uint8Array(len);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
bytes[i] = binary_string.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
} catch (ex) {
|
||||||
|
return base64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||||
|
try {
|
||||||
|
const binary_string = window.atob(base64);
|
||||||
|
const len = binary_string.length;
|
||||||
|
const bytes = new Uint8Array(len);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
bytes[i] = binary_string.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
} catch (ex) {
|
||||||
|
try {
|
||||||
|
return new Uint16Array(
|
||||||
|
[].map.call(base64, function (c: string) {
|
||||||
|
return c.charCodeAt(0);
|
||||||
|
})
|
||||||
|
).buffer;
|
||||||
|
} catch (ex2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const escapeStringToHTML = (str: string) => {
|
||||||
|
if (!str) return "";
|
||||||
|
return str.replace(/[<>&"'`]/g, (match) => {
|
||||||
|
const escape: any = {
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
"&": "&",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
"`": "`",
|
||||||
|
};
|
||||||
|
return escape[match];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveWithIgnoreKnownError<T>(p: Promise<T>, def: T): Promise<T> {
|
||||||
|
return new Promise((res, rej) => {
|
||||||
|
p.then(res).catch((ex) => (ex.status && ex.status == 404 ? res(def) : rej(ex)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidPath(filename: string): boolean {
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const regex = /[\u0000-\u001f]|[\\"':?<>|*]/g;
|
||||||
|
let x = filename.replace(regex, "_");
|
||||||
|
const win = /(\\|\/)(COM\d|LPT\d|CON|PRN|AUX|NUL|CLOCK$)($|\.)/gi;
|
||||||
|
const sx = (x = x.replace(win, "/_"));
|
||||||
|
return sx == filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function versionNumberString2Number(version: string): number {
|
||||||
|
return version // "1.23.45"
|
||||||
|
.split(".") // 1 23 45
|
||||||
|
.reverse() // 45 23 1
|
||||||
|
.map((e, i) => ((e as any) / 1) * 1000 ** i) // 45 23000 1000000
|
||||||
|
.reduce((prev, current) => prev + current, 0); // 1023045
|
||||||
|
}
|
||||||
|
|
||||||
|
export const delay = (ms: number): Promise<void> => {
|
||||||
|
return new Promise((res) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
res();
|
||||||
|
}, ms);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// For backward compatibility, using the path for determining id.
|
||||||
|
// Only CouchDB nonacceptable ID (that starts with an underscore) has been prefixed with "/".
|
||||||
|
// The first slash will be deleted when the path is normalized.
|
||||||
|
export function path2id(filename: string): string {
|
||||||
|
let x = normalizePath(filename);
|
||||||
|
if (x.startsWith("_")) x = "/" + x;
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
export function id2path(filename: string): string {
|
||||||
|
return normalizePath(filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
const runningProcs: string[] = [];
|
||||||
|
const pendingProcs: { [key: string]: (() => Promise<void>)[] } = {};
|
||||||
|
function objectToKey(key: any): string {
|
||||||
|
if (typeof key === "string") return key;
|
||||||
|
const keys = Object.keys(key).sort((a, b) => a.localeCompare(b));
|
||||||
|
return keys.map((e) => e + objectToKey(key[e])).join(":");
|
||||||
|
}
|
||||||
|
// Just run some async/await as like transacion SERIALIZABLE
|
||||||
|
|
||||||
|
export function runWithLock<T>(key: unknown, ignoreWhenRunning: boolean, proc: () => Promise<T>): Promise<T> {
|
||||||
|
Logger(`Lock:${key}:enter`, LOG_LEVEL.VERBOSE);
|
||||||
|
const lockKey = typeof key === "string" ? key : objectToKey(key);
|
||||||
|
const handleNextProcs = () => {
|
||||||
|
if (typeof pendingProcs[lockKey] === "undefined") {
|
||||||
|
//simply unlock
|
||||||
|
runningProcs.remove(lockKey);
|
||||||
|
Logger(`Lock:${lockKey}:released`, LOG_LEVEL.VERBOSE);
|
||||||
|
} else {
|
||||||
|
Logger(`Lock:${lockKey}:left ${pendingProcs[lockKey].length}`, LOG_LEVEL.VERBOSE);
|
||||||
|
let nextProc = null;
|
||||||
|
nextProc = pendingProcs[lockKey].shift();
|
||||||
|
if (nextProc) {
|
||||||
|
// left some
|
||||||
|
nextProc()
|
||||||
|
.then()
|
||||||
|
.catch((err) => {
|
||||||
|
Logger(err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (pendingProcs && lockKey in pendingProcs && pendingProcs[lockKey].length == 0) {
|
||||||
|
delete pendingProcs[lockKey];
|
||||||
|
}
|
||||||
|
queueMicrotask(() => {
|
||||||
|
handleNextProcs();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (runningProcs.contains(lockKey)) {
|
||||||
|
if (ignoreWhenRunning) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof pendingProcs[lockKey] === "undefined") {
|
||||||
|
pendingProcs[lockKey] = [];
|
||||||
|
}
|
||||||
|
let responderRes: (value: T | PromiseLike<T>) => void;
|
||||||
|
let responderRej: (reason?: unknown) => void;
|
||||||
|
const responder = new Promise<T>((res, rej) => {
|
||||||
|
responderRes = res;
|
||||||
|
responderRej = rej;
|
||||||
|
//wait for subproc resolved
|
||||||
|
});
|
||||||
|
const subproc = () =>
|
||||||
|
new Promise<void>((res, rej) => {
|
||||||
|
proc()
|
||||||
|
.then((v) => {
|
||||||
|
Logger(`Lock:${key}:processed`, LOG_LEVEL.VERBOSE);
|
||||||
|
handleNextProcs();
|
||||||
|
responderRes(v);
|
||||||
|
res();
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
Logger(`Lock:${key}:rejected`, LOG_LEVEL.VERBOSE);
|
||||||
|
handleNextProcs();
|
||||||
|
rej(reason);
|
||||||
|
responderRej(reason);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
pendingProcs[lockKey].push(subproc);
|
||||||
|
return responder;
|
||||||
|
} else {
|
||||||
|
runningProcs.push(lockKey);
|
||||||
|
Logger(`Lock:${lockKey}:aqquired`, LOG_LEVEL.VERBOSE);
|
||||||
|
return new Promise((res, rej) => {
|
||||||
|
proc()
|
||||||
|
.then((v) => {
|
||||||
|
handleNextProcs();
|
||||||
|
res(v);
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
handleNextProcs();
|
||||||
|
rej(reason);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/utils_couchdb.ts
Normal file
70
src/utils_couchdb.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { Logger } from "./logger";
|
||||||
|
import { LOG_LEVEL, VER, VERSIONINFO_DOCID, EntryVersionInfo, EntryDoc } from "./types";
|
||||||
|
import { resolveWithIgnoreKnownError } from "./utils";
|
||||||
|
import { PouchDB } from "../pouchdb-browser-webpack/dist/pouchdb-browser.js";
|
||||||
|
|
||||||
|
export const isValidRemoteCouchDBURI = (uri: string): boolean => {
|
||||||
|
if (uri.startsWith("https://")) return true;
|
||||||
|
if (uri.startsWith("http://")) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
export const connectRemoteCouchDB = async (uri: string, auth: { username: string; password: string }): Promise<string | { db: PouchDB.Database<EntryDoc>; info: PouchDB.Core.DatabaseInfo }> => {
|
||||||
|
if (!isValidRemoteCouchDBURI(uri)) return "Remote URI is not valid";
|
||||||
|
const db: PouchDB.Database<EntryDoc> = new PouchDB<EntryDoc>(uri, {
|
||||||
|
auth,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const info = await db.info();
|
||||||
|
return { db: db, info: info };
|
||||||
|
} catch (ex) {
|
||||||
|
let msg = `${ex.name}:${ex.message}`;
|
||||||
|
if (ex.name == "TypeError" && ex.message == "Failed to fetch") {
|
||||||
|
msg += "\n**Note** This error caused by many reasons. The only sure thing is you didn't touch the server.\nTo check details, open inspector.";
|
||||||
|
}
|
||||||
|
Logger(ex, LOG_LEVEL.VERBOSE);
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// check the version of remote.
|
||||||
|
// if remote is higher than current(or specified) version, return false.
|
||||||
|
export const checkRemoteVersion = async (db: PouchDB.Database, migrate: (from: number, to: number) => Promise<boolean>, barrier: number = VER): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const versionInfo = (await db.get(VERSIONINFO_DOCID)) as EntryVersionInfo;
|
||||||
|
if (versionInfo.type != "versioninfo") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = versionInfo.version;
|
||||||
|
if (version < barrier) {
|
||||||
|
const versionUpResult = await migrate(version, barrier);
|
||||||
|
if (versionUpResult) {
|
||||||
|
await bumpRemoteVersion(db);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (version == barrier) return true;
|
||||||
|
return false;
|
||||||
|
} catch (ex) {
|
||||||
|
if (ex.status && ex.status == 404) {
|
||||||
|
if (await bumpRemoteVersion(db)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export const bumpRemoteVersion = async (db: PouchDB.Database, barrier: number = VER): Promise<boolean> => {
|
||||||
|
const vi: EntryVersionInfo = {
|
||||||
|
_id: VERSIONINFO_DOCID,
|
||||||
|
version: barrier,
|
||||||
|
type: "versioninfo",
|
||||||
|
};
|
||||||
|
const versionInfo = (await resolveWithIgnoreKnownError(db.get(VERSIONINFO_DOCID), vi)) as EntryVersionInfo;
|
||||||
|
if (versionInfo.type != "versioninfo") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
vi._rev = versionInfo._rev;
|
||||||
|
await db.put(vi);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
63
styles.css
63
styles.css
@@ -64,7 +64,8 @@
|
|||||||
:root {
|
:root {
|
||||||
--slsmessage: "";
|
--slsmessage: "";
|
||||||
}
|
}
|
||||||
.CodeMirror-wrap::before , .cm-s-obsidian > .cm-editor::before {
|
.CodeMirror-wrap::before,
|
||||||
|
.cm-s-obsidian > .cm-editor::before {
|
||||||
content: var(--slsmessage);
|
content: var(--slsmessage);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -73,13 +74,69 @@
|
|||||||
top: 8px;
|
top: 8px;
|
||||||
color: --text-normal;
|
color: --text-normal;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
font-size:80%;
|
font-size: 80%;
|
||||||
-webkit-filter: grayscale(100%);
|
-webkit-filter: grayscale(100%);
|
||||||
filter: grayscale(100%);
|
filter: grayscale(100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.CodeMirror-wrap::before {
|
.CodeMirror-wrap::before {
|
||||||
right: 0px;
|
right: 0px;
|
||||||
} .cm-s-obsidian > .cm-editor::before {
|
}
|
||||||
|
.cm-s-obsidian > .cm-editor::before {
|
||||||
right: 16px;
|
right: 16px;
|
||||||
}
|
}
|
||||||
|
.sls-setting-tab {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
div.sls-setting-menu-btn {
|
||||||
|
color: var(--text-normal);
|
||||||
|
background-color: var(--background-secondary-alt);
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: 12px;
|
||||||
|
font-family: "Inter", sans-serif;
|
||||||
|
outline: none;
|
||||||
|
user-select: none;
|
||||||
|
flex-grow: 1;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sls-setting-label.selected {
|
||||||
|
/* order: 1; */
|
||||||
|
flex-grow: 1;
|
||||||
|
/* width: 100%; */
|
||||||
|
}
|
||||||
|
.sls-setting-tab:hover ~ div.sls-setting-menu-btn,
|
||||||
|
.sls-setting-tab:checked ~ div.sls-setting-menu-btn {
|
||||||
|
background-color: var(--interactive-accent);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sls-setting-menu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
/* flex-wrap: wrap; */
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.sls-setting-label {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.setting-collapsed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.sls-plugins-tbl-buttons {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sls-plugins-tbl-buttons button {
|
||||||
|
flex-grow: 0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
.sls-plugins-tbl-device-head {
|
||||||
|
background-color: var(--background-secondary-alt);
|
||||||
|
color: var(--text-accent);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,9 +9,13 @@
|
|||||||
"noImplicitAny": true,
|
"noImplicitAny": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"importHelpers": true,
|
"importHelpers": true,
|
||||||
"lib": ["dom", "es5", "scripthost", "es2015"]
|
"noImplicitReturns": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"strictFunctionTypes": true,
|
||||||
|
"alwaysStrict": true,
|
||||||
|
"lib": ["dom", "es5", "ES6", "ES7", "es2020"]
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts"],
|
"include": ["./src/*.ts"],
|
||||||
"files": ["./main.ts"],
|
// "files": ["./src/main.ts"],
|
||||||
"exclude": ["pouchdb-browser-webpack"]
|
"exclude": ["pouchdb-browser-webpack"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user