Compare commits
465 Commits
0.12.4
...
snyk-upgra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57cf57ac11 | ||
|
|
ed5cb3e043 | ||
|
|
574fdf9202 | ||
|
|
9ec7b809a9 | ||
|
|
4d302aff9d | ||
|
|
b70009f4a9 | ||
|
|
c24ee32f37 | ||
|
|
012d0aa4df | ||
|
|
5c97e5b672 | ||
|
|
6e1eb36f3b | ||
|
|
8809aee327 | ||
|
|
fc04c557fc | ||
|
|
115a0d2d8a | ||
|
|
2c97289ec8 | ||
|
|
6d472d17fd | ||
|
|
3a3aabfd11 | ||
|
|
8b45dd1d24 | ||
|
|
a2b36ccf31 | ||
|
|
25e30fa09d | ||
|
|
8f5bc387b4 | ||
|
|
5afe24c460 | ||
|
|
658a09f1cc | ||
|
|
a9020a3aea | ||
|
|
293c731437 | ||
|
|
5b4ae37030 | ||
|
|
9e8d126259 | ||
|
|
6d244a6e34 | ||
|
|
1f0ad4eb1e | ||
|
|
e0e0ab0426 | ||
|
|
5023d6da0b | ||
|
|
49160c7d57 | ||
|
|
4434224c29 | ||
|
|
cf3b9e5522 | ||
|
|
7ca5ac5ac7 | ||
|
|
095a3d20fb | ||
|
|
89e23b1bf4 | ||
|
|
48315d657d | ||
|
|
b73ca73776 | ||
|
|
48e4d57278 | ||
|
|
7eae25edd0 | ||
|
|
3285c1694b | ||
|
|
ede126d7d4 | ||
|
|
f778107727 | ||
|
|
630889680e | ||
|
|
e46714e0f9 | ||
|
|
86d5582f37 | ||
|
|
697ee1855b | ||
|
|
12d825ea49 | ||
|
|
b8edc85528 | ||
|
|
e2740cbefe | ||
|
|
a96e4e4472 | ||
|
|
dd26bbfe64 | ||
|
|
6b9bd473cf | ||
|
|
4be4fa6cc7 | ||
|
|
a9745e850e | ||
|
|
7b9515a47e | ||
|
|
220dce51f2 | ||
|
|
a23fc866c0 | ||
|
|
5c86966d89 | ||
|
|
29ed4d2b95 | ||
|
|
16c6c52128 | ||
|
|
8b94a0b72e | ||
|
|
c5ac76d916 | ||
|
|
b67a6db8a1 | ||
|
|
d4202161e8 | ||
|
|
2a2b39009c | ||
|
|
bf3a6e7570 | ||
|
|
069b8513d1 | ||
|
|
128b1843df | ||
|
|
fd722b1fe5 | ||
|
|
0bf087dba0 | ||
|
|
3a4b59b998 | ||
|
|
8fc9d51c45 | ||
|
|
35feb5bf93 | ||
|
|
b3a85c5462 | ||
|
|
7b0ac22c3b | ||
|
|
dca8e4b2a4 | ||
|
|
89de2dcc37 | ||
|
|
172b08dbb3 | ||
|
|
d518a3fc1b | ||
|
|
c6ed867498 | ||
|
|
4f4923e977 | ||
|
|
a5ebf29b3d | ||
|
|
ee465184c8 | ||
|
|
d7d4f1e6f2 | ||
|
|
cbf5023593 | ||
|
|
3925052f92 | ||
|
|
1934418258 | ||
|
|
2ae018b2bd | ||
|
|
8474497985 | ||
|
|
b5714cc83b | ||
|
|
133f5a7109 | ||
|
|
daa3feebf1 | ||
|
|
7b5f7d0fbf | ||
|
|
29532193cb | ||
|
|
5b4309c09d | ||
|
|
16ef582453 | ||
|
|
3e22f70c7a | ||
|
|
0a8dbe097e | ||
|
|
2c0fcf74d0 | ||
|
|
a1ab1efd5d | ||
|
|
c8fcf2d0d5 | ||
|
|
c384e2f7fb | ||
|
|
99c1c7dc1a | ||
|
|
84adec4b1a | ||
|
|
f0b202bd91 | ||
|
|
d54b7e2d93 | ||
|
|
6952ef37f5 | ||
|
|
9630bcbae8 | ||
|
|
c3f925ab9a | ||
|
|
034dc0538f | ||
|
|
b6136df836 | ||
|
|
24aacdc2a1 | ||
|
|
f91109b1ad | ||
|
|
e76e7ae8ea | ||
|
|
f7fbe85d65 | ||
|
|
0313443b29 | ||
|
|
755c30f468 | ||
|
|
b00b0cc5e5 | ||
|
|
d7985a6b41 | ||
|
|
486e816902 | ||
|
|
ef9b19c24b | ||
|
|
4ed9494176 | ||
|
|
fcd56d59d5 | ||
|
|
1cabfcfd19 | ||
|
|
37a18dbfef | ||
|
|
e7edf88713 | ||
|
|
90ff75ab35 | ||
|
|
bff1d661f5 | ||
|
|
6b59c14774 | ||
|
|
8249274eac | ||
|
|
3c6dae7814 | ||
|
|
60cf8fe640 | ||
|
|
3d89b3863f | ||
|
|
ee9364310d | ||
|
|
86b9695bc2 | ||
|
|
e05f8771b9 | ||
|
|
65619c2478 | ||
|
|
1552fa9d9e | ||
|
|
767f12b52f | ||
|
|
4071ba120e | ||
|
|
2c0e3ba01c | ||
|
|
90adf06830 | ||
|
|
cf8e7ff6ca | ||
|
|
95c3ff5043 | ||
|
|
7ea3515801 | ||
|
|
f866981a8a | ||
|
|
8f36d6f893 | ||
|
|
6dd86e9392 | ||
|
|
d22716bef0 | ||
|
|
5d9baec5e4 | ||
|
|
27d71ca2fb | ||
|
|
c024ed13d3 | ||
|
|
b9527ccab0 | ||
|
|
fa3aa2702c | ||
|
|
93e7cbb133 | ||
|
|
716ae32e02 | ||
|
|
d6d8cbcf5a | ||
|
|
efd348b266 | ||
|
|
8969b1800a | ||
|
|
2c8e026e29 | ||
|
|
a6c27eab3d | ||
|
|
9b5c57d540 | ||
|
|
c251c596e8 | ||
|
|
61188cfaef | ||
|
|
97d944fd75 | ||
|
|
d3dc1e7328 | ||
|
|
45304af369 | ||
|
|
7f422d58f2 | ||
|
|
c2491fdfad | ||
|
|
06a6e391e8 | ||
|
|
f99475f6b7 | ||
|
|
109fc00b9d | ||
|
|
c071d822e1 | ||
|
|
d2de5b4710 | ||
|
|
cf5ecd8922 | ||
|
|
b337a05b5a | ||
|
|
9ea6bee9d1 | ||
|
|
9747c26d50 | ||
|
|
bb4b764586 | ||
|
|
279b4b41e5 | ||
|
|
b644fb791d | ||
|
|
5802ed31be | ||
|
|
ac9428e96b | ||
|
|
280d9e1dd9 | ||
|
|
f7209e566c | ||
|
|
4a9ab2d1de | ||
|
|
cb74b5ee93 | ||
|
|
60eecd7001 | ||
|
|
4bd7b54bcd | ||
|
|
8923c73d1b | ||
|
|
11e64b13e2 | ||
|
|
983d9248ed | ||
|
|
7240e84328 | ||
|
|
0d55ae2532 | ||
|
|
dbd284f5dd | ||
|
|
c000a02f4a | ||
|
|
79754f48d6 | ||
|
|
dd7a40630b | ||
|
|
14406f8213 | ||
|
|
3bbd9c048d | ||
|
|
d91c4f50b4 | ||
|
|
395b7fbc42 | ||
|
|
3773e57429 | ||
|
|
4835fce62a | ||
|
|
ff814be4a0 | ||
|
|
b271b63efa | ||
|
|
23419e476a | ||
|
|
b9bd1f17b8 | ||
|
|
bcce277c36 | ||
|
|
5acbbe479e | ||
|
|
c9f9d511e0 | ||
|
|
b8cb94c498 | ||
|
|
52c736f6b9 | ||
|
|
ebd1cb7777 | ||
|
|
10decb7909 | ||
|
|
e0aab8d69d | ||
|
|
618600c753 | ||
|
|
d1aba87e37 | ||
|
|
db889f635e | ||
|
|
dd80e634f5 | ||
|
|
bec6fc1a74 | ||
|
|
5c96c7f99b | ||
|
|
7b9724f713 | ||
|
|
4cd12c85ed | ||
|
|
90651540f9 | ||
|
|
9e504d5002 | ||
|
|
faaa94423c | ||
|
|
a7c179fc86 | ||
|
|
ed1a670b9b | ||
|
|
6c3c265bd6 | ||
|
|
9d68025f2a | ||
|
|
e70972c8f9 | ||
|
|
7607be7729 | ||
|
|
db9d428ab4 | ||
|
|
0a2caea3c7 | ||
|
|
b1d1ba0e6b | ||
|
|
5e844372cb | ||
|
|
99c6911e96 | ||
|
|
dc880d7d4e | ||
|
|
c157fef76c | ||
|
|
2b2011dc49 | ||
|
|
ae451e005e | ||
|
|
8a75d41cbb | ||
|
|
5252cc0372 | ||
|
|
5022155317 | ||
|
|
d36f925c65 | ||
|
|
3ae33e0500 | ||
|
|
13e442a0c7 | ||
|
|
6288716966 | ||
|
|
47d2cf9733 | ||
|
|
ae6a9ecee4 | ||
|
|
2289bea8d9 | ||
|
|
cda90259c5 | ||
|
|
432a211f80 | ||
|
|
eaf8c4998e | ||
|
|
55601f7910 | ||
|
|
13e70475d9 | ||
|
|
2572177879 | ||
|
|
e82a2560e4 | ||
|
|
09146591eb | ||
|
|
69c6e57df3 | ||
|
|
5e181a8ec4 | ||
|
|
4354cc3054 | ||
|
|
0664427c63 | ||
|
|
49c4736d69 | ||
|
|
f0ce8f0e05 | ||
|
|
0a70afc5a3 | ||
|
|
431239a736 | ||
|
|
1ceb671683 | ||
|
|
ea40e5918c | ||
|
|
64681729ff | ||
|
|
830f2f25d1 | ||
|
|
05f0abebf0 | ||
|
|
842da980d7 | ||
|
|
d8ecbb593b | ||
|
|
8d66c372e1 | ||
|
|
7c06750d93 | ||
|
|
808fdc0944 | ||
|
|
ce25eee74b | ||
|
|
146c170dec | ||
|
|
cf06f878db | ||
|
|
e77031f1cd | ||
|
|
3f2224c3a6 | ||
|
|
2322b5bc34 | ||
|
|
83ac5e7086 | ||
|
|
09f35a2af4 | ||
|
|
fae0a9d76a | ||
|
|
9a27c9bfe5 | ||
|
|
5e75917b8d | ||
|
|
3322d13b55 | ||
|
|
851c9f8a71 | ||
|
|
b02596dfa1 | ||
|
|
02c69b202e | ||
|
|
6b2c7b56a5 | ||
|
|
820168a5ab | ||
|
|
40015642e4 | ||
|
|
7a5cffb6a8 | ||
|
|
e395e53248 | ||
|
|
97f91b1eb0 | ||
|
|
2f4159182e | ||
|
|
302a4024a8 | ||
|
|
bc17f4f70d | ||
|
|
6f33d23088 | ||
|
|
4998e2ef0b | ||
|
|
f5e0b826a6 | ||
|
|
3a3f79bb99 | ||
|
|
9efb6ed0c1 | ||
|
|
6b7956ab67 | ||
|
|
58196c2423 | ||
|
|
3940260d42 | ||
|
|
b16333c604 | ||
|
|
7bf6d1f663 | ||
|
|
7046928068 | ||
|
|
333fcbaaeb | ||
|
|
009f92c307 | ||
|
|
3e541bd061 | ||
|
|
52d08301cc | ||
|
|
49d4c239f2 | ||
|
|
748d031b36 | ||
|
|
dbe77718c8 | ||
|
|
f334974cc3 | ||
|
|
8f2ae437c6 | ||
|
|
a0efda9e71 | ||
|
|
be3d61c1c7 | ||
|
|
b24c4ef55b | ||
|
|
ff850b48ca | ||
|
|
ad3860ac40 | ||
|
|
437b7ebae1 | ||
|
|
e3305c24e1 | ||
|
|
d008e2a1d0 | ||
|
|
bb8c7eb043 | ||
|
|
e61bebd3ee | ||
|
|
99594fe517 | ||
|
|
972d208af4 | ||
|
|
2c36ec497c | ||
|
|
677895547c | ||
|
|
aec0b2986b | ||
|
|
e6025b92d8 | ||
|
|
fad9fed5ca | ||
|
|
e46246cd63 | ||
|
|
0f3be19dd7 | ||
|
|
bc568ff479 | ||
|
|
2fdc7669f3 | ||
|
|
ec8d9785ed | ||
|
|
71a80cacc3 | ||
|
|
38daeca89f | ||
|
|
7ec64a6a93 | ||
|
|
c5c6deb742 | ||
|
|
ef57fbfdda | ||
|
|
bc158e9f2b | ||
|
|
6513c53c7e | ||
|
|
5d1074065c | ||
|
|
b444082b0c | ||
|
|
d5e6419504 | ||
|
|
1bf1e1540d | ||
|
|
be1e6b11ac | ||
|
|
a486788572 | ||
|
|
e5784a1da6 | ||
|
|
2100e22276 | ||
|
|
ec08dc5fe8 | ||
|
|
c92e94e552 | ||
|
|
c7db8592c6 | ||
|
|
fc3617d9f9 | ||
|
|
34c1b040db | ||
|
|
6b85aecafe | ||
|
|
4dabadd5ea | ||
|
|
0619c96c48 | ||
|
|
b0f612b61c | ||
|
|
81caad8602 | ||
|
|
f5e28b5e1c | ||
|
|
0c206226b1 | ||
|
|
1ad5dcc1cc | ||
|
|
a512566e5b | ||
|
|
02de82af46 | ||
|
|
840e03a2d3 | ||
|
|
96b676caf3 | ||
|
|
a8219de375 | ||
|
|
db3eb7e1a0 | ||
|
|
50f51393fc | ||
|
|
8a04e332d6 | ||
|
|
12ae17aa2f | ||
|
|
657f12f966 | ||
|
|
15a7bed448 | ||
|
|
420c3b94df | ||
|
|
239c087132 | ||
|
|
d1a633c799 | ||
|
|
1c07cd92fc | ||
|
|
adc84d53b1 | ||
|
|
c3a762ceed | ||
|
|
5945638633 | ||
|
|
331acd463d | ||
|
|
9d4f41bbf9 | ||
|
|
8831165965 | ||
|
|
ed62e9331b | ||
|
|
799e604eb2 | ||
|
|
d9b69d9a1b | ||
|
|
c18b5c24b4 | ||
|
|
07f16e3d7d | ||
|
|
486f1aa4a0 | ||
|
|
075c6beb68 | ||
|
|
d6121b0c1e | ||
|
|
3292a48054 | ||
|
|
ee37764040 | ||
|
|
b6f7fced22 | ||
|
|
13456c0854 | ||
|
|
2663a52fd7 | ||
|
|
d4bbf79514 | ||
|
|
5f96cc6b82 | ||
|
|
8c8f5d045f | ||
|
|
40cf8be890 | ||
|
|
6b03dbbe75 | ||
|
|
74425f75d2 | ||
|
|
ac7c622466 | ||
|
|
4b32365694 | ||
|
|
728edac283 | ||
|
|
ab9c0190bb | ||
|
|
5a7610d411 | ||
|
|
4691ae1463 | ||
|
|
0923ac3d85 | ||
|
|
ca100d6d9d | ||
|
|
bc373d4359 | ||
|
|
4038b683fe | ||
|
|
5e7b44d35a | ||
|
|
d04be6813b | ||
|
|
8e578e2100 | ||
|
|
55fcdfe18f | ||
|
|
66f2fea2f4 | ||
|
|
beb7bf6fb9 | ||
|
|
34791114e5 | ||
|
|
de5cdf507d | ||
|
|
83209f3923 | ||
|
|
b14ecdb205 | ||
|
|
21362adb5b | ||
|
|
f8c1474700 | ||
|
|
b35052a485 | ||
|
|
c367d35e09 | ||
|
|
2a5078cdbb | ||
|
|
8112a07210 | ||
|
|
c9daa1b47d | ||
|
|
73ac93e8c5 | ||
|
|
8d2b9eff37 | ||
|
|
0ee32a2147 | ||
|
|
ac3c78e198 | ||
|
|
0da1e3d9c8 | ||
|
|
8f021a3c93 | ||
|
|
6db0743096 | ||
|
|
0e300a0a6b | ||
|
|
9d0ffd1848 | ||
|
|
e7f4d8c9c2 | ||
|
|
ca36e1b663 | ||
|
|
8f583e3680 | ||
|
|
98407cf72f | ||
|
|
1f377cdf67 | ||
|
|
3a965e74da | ||
|
|
640c2a91f7 | ||
|
|
df9f9d9bc0 | ||
|
|
787bf37906 | ||
|
|
63abeb7f6b | ||
|
|
f0ffb0620e | ||
|
|
c88c939cd9 | ||
|
|
05b53eb2cf | ||
|
|
61b65b0461 | ||
|
|
ac9be937b4 | ||
|
|
c610284cab |
@@ -1,3 +1,9 @@
|
||||
npm node_modules
|
||||
node_modules
|
||||
build
|
||||
.eslintrc.js.bak
|
||||
.eslintrc.js.bak
|
||||
src/lib/src/patches/pouchdb-utils
|
||||
esbuild.config.mjs
|
||||
rollup.config.js
|
||||
src/lib/test
|
||||
src/lib/src/cli
|
||||
main.js
|
||||
|
||||
26
.eslintrc
@@ -4,16 +4,34 @@
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended"],
|
||||
"parserOptions": {
|
||||
"sourceType": "module"
|
||||
"sourceType": "module",
|
||||
"project": ["tsconfig.json"]
|
||||
},
|
||||
"ignorePatterns": [],
|
||||
"rules": {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"args": "none"
|
||||
}
|
||||
],
|
||||
"no-unused-labels": "off",
|
||||
"@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"
|
||||
"@typescript-eslint/require-await": "warn",
|
||||
"@typescript-eslint/no-misused-promises": "warn",
|
||||
"@typescript-eslint/no-floating-promises": "warn",
|
||||
"no-async-promise-executor": "warn",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unnecessary-type-assertion": "error",
|
||||
"no-constant-condition": [
|
||||
"error",
|
||||
{
|
||||
"checkLoops": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
78
.github/ISSUE_TEMPLATE/issue-report.md
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: Issue report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
Thank you for taking the time to report this issue!
|
||||
To improve the process, I would like to ask you to let me know the information in advance.
|
||||
|
||||
All instructions and examples, and empty entries can be deleted.
|
||||
Just for your information, a [filled example](https://docs.vrtmrz.net/LiveSync/hintandtrivia/Issue+example) is also written.
|
||||
|
||||
## Abstract
|
||||
The synchronisation hung up immediately after connecting.
|
||||
|
||||
## Expected behaviour
|
||||
- Synchronisation ends with the message `Replication completed`
|
||||
- Everything synchronised
|
||||
|
||||
## Actually happened
|
||||
- Synchronisation has been cancelled with the message `TypeError ... ` (captured in the attached log, around LL.10-LL.12)
|
||||
- No files synchronised
|
||||
|
||||
## Reproducing procedure
|
||||
|
||||
1. Configure LiveSync as in the attached material.
|
||||
2. Click the replication button on the ribbon.
|
||||
3. Synchronising has begun.
|
||||
4. About two or three seconds later, we got the error `TypeError ... `.
|
||||
5. Replication has been stopped. No files synchronised.
|
||||
|
||||
Note: If you do not catch the reproducing procedure, please let me know the frequency and signs.
|
||||
|
||||
## Report materials
|
||||
If the information is not available, do not hesitate to report it as it is. You can also of course omit it if you think this is indeed unnecessary. If it is necessary, I will ask you.
|
||||
|
||||
### Report from the LiveSync
|
||||
For more information, please refer to [Making the report](https://docs.vrtmrz.net/LiveSync/hintandtrivia/Making+the+report).
|
||||
<details>
|
||||
<summary>Report from hatch</summary>
|
||||
|
||||
```
|
||||
<!-- paste here -->
|
||||
```
|
||||
</details>
|
||||
|
||||
### Obsidian debug info
|
||||
<details>
|
||||
<summary>Debug info</summary>
|
||||
|
||||
```
|
||||
<!-- paste here -->
|
||||
```
|
||||
</details>
|
||||
|
||||
### Plug-in log
|
||||
We can see the log by tapping the Document box icon. If you noticed something suspicious, please let me know.
|
||||
Note: **Please enable `Verbose Log`**. For detail, refer to [Logging](https://docs.vrtmrz.net/LiveSync/hintandtrivia/Logging), please.
|
||||
|
||||
<details>
|
||||
<summary>Plug-in log</summary>
|
||||
|
||||
```
|
||||
<!-- paste here -->
|
||||
```
|
||||
</details>
|
||||
|
||||
### Network log
|
||||
Network logs displayed in DevTools will possibly help with connection-related issues. To capture that, please refer to [DevTools](https://docs.vrtmrz.net/LiveSync/hintandtrivia/DevTools).
|
||||
|
||||
### Screenshots
|
||||
If applicable, please add screenshots to help explain your problem.
|
||||
|
||||
### Other information, insights and intuition.
|
||||
Please provide any additional context or information about the problem.
|
||||
4
.github/workflows/release.yml
vendored
@@ -17,12 +17,12 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: '14.x' # You might need to adjust this value to your own version
|
||||
node-version: '18.x' # You might need to adjust this value to your own version
|
||||
# Get the version number and put it in a variable
|
||||
- name: Get Version
|
||||
id: version
|
||||
run: |
|
||||
echo "::set-output name=tag::$(git describe --abbrev=0)"
|
||||
echo "::set-output name=tag::$(git describe --abbrev=0 --tags)"
|
||||
# Build the plugin
|
||||
- name: Build
|
||||
id: build
|
||||
|
||||
2
.gitignore
vendored
@@ -8,7 +8,9 @@ package-lock.json
|
||||
|
||||
# build
|
||||
main.js
|
||||
main_org.js
|
||||
*.js.map
|
||||
|
||||
# obsidian
|
||||
data.json
|
||||
.vscode
|
||||
|
||||
2
.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
||||
pouchdb-browser.js
|
||||
main_org.js
|
||||
7
.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 4,
|
||||
"printWidth": 120,
|
||||
"semi": true,
|
||||
"endOfLine": "crlf"
|
||||
}
|
||||
153
README.md
@@ -1,130 +1,85 @@
|
||||
<!-- For translation: 20240227r0 -->
|
||||
# Self-hosted LiveSync
|
||||
[Japanese docs](./README_ja.md) - [Chinese docs](./README_cn.md).
|
||||
|
||||
[Japanese docs](./README_ja.md).
|
||||
|
||||
Self-hosted LiveSync is a community implemented synchronization plugin.
|
||||
It uses Self-hosted or you procured CouchDB as the server. Available on every obsidian installed devices.
|
||||
|
||||
Note: It has no compatibilities with official "Sync".
|
||||
Self-hosted LiveSync is a community-implemented synchronization plugin, available on every obsidian-compatible platform and using CouchDB or Object Storage (e.g., MinIO, S3, R2, etc.) as the server.
|
||||
|
||||

|
||||
|
||||
If you install or upgrade LiveSync, please back your vault up.
|
||||
Note: This plugin cannot synchronise with the official "Obsidian Sync".
|
||||
|
||||
## Features
|
||||
|
||||
- Visual conflict resolver included.
|
||||
- Synchronize with other devices bidirectionally near-real-time
|
||||
- You can use CouchDB or its compatibles like IBM Cloudant.
|
||||
- End-to-End encryption.
|
||||
- Plugin synchronization(Beta)
|
||||
- Receive WebClip from [obsidian-livesync-webclip](https://chrome.google.com/webstore/detail/obsidian-livesync-webclip/jfpaflmpckblieefkegjncjoceapakdf) (End-to-End encryption will not be applicable.)
|
||||
- Synchronize vaults very efficiently with less traffic.
|
||||
- Good at conflicted modification.
|
||||
- Automatic merging for simple conflicts.
|
||||
- Using OSS solution for the server.
|
||||
- Compatible solutions can be used.
|
||||
- Supporting End-to-end encryption.
|
||||
- Synchronisation of settings, snippets, themes, and plug-ins, via [Customization sync(Beta)](#customization-sync) or [Hidden File Sync](#hiddenfilesync)
|
||||
- WebClip from [obsidian-livesync-webclip](https://chrome.google.com/webstore/detail/obsidian-livesync-webclip/jfpaflmpckblieefkegjncjoceapakdf)
|
||||
|
||||
It must be useful for the Researcher, Engineer, Developer who has to keep NDA or something like agreement. Especially, in some companies, people have to store all data to their fully controlled host, even End-to-End encryption applied.
|
||||
This plug-in might be useful for researchers, engineers, and developers with a need to keep their notes fully self-hosted for security reasons. Or just anyone who would like the peace of mind of knowing that their notes are fully private.
|
||||
|
||||
## IMPORTANT NOTICE
|
||||
|
||||
- Do not use with other synchronize solutions. Before enabling this plugin, make sure to disable other synchronize solutions, to avoid content corruption or duplication. If you want to synchronize to both backend, sync one by one, please.
|
||||
This includes making your vault on the cloud-controlled folder(e.g., Inside the iCloud folder).
|
||||
- This is the synchronization plugin. Not backup solutions. Do not rely on this for backup.
|
||||
- When the device's storage has been run out, Database corruption may happen.
|
||||
- When editing hidden files or any other invisible files from obsidian, the file wouldn't be kept in the database. (**Or be deleted.**)
|
||||
>[!IMPORTANT]
|
||||
> - Before installing or upgrading this plug-in, please back your vault up.
|
||||
> - Do not enable this plugin with another synchronization solution at the same time (including iCloud and Obsidian Sync).
|
||||
> - This is a synchronization plugin. Not a backup solution. Do not rely on this for backup.
|
||||
|
||||
## How to use
|
||||
|
||||
### Get your database ready.
|
||||
### 3-minute setup - CouchDB on fly.io
|
||||
|
||||
First, get your database ready. IBM Cloudant is preferred for testing. Or you can use your own server with CouchDB. For more information, refer below:
|
||||
1. [Setup IBM Cloudant](docs/setup_cloudant.md)
|
||||
2. [Setup your CouchDB](docs/setup_own_server.md)
|
||||
**Recommended for beginners**
|
||||
|
||||
Note: Information about hosting service wanted! Currently, [Using fly.io](https://github.com/vrtmrz/obsidian-livesync/discussions/85) is on the table.
|
||||
[](https://www.youtube.com/watch?v=7sa_I1832Xc)
|
||||
|
||||
### First device
|
||||
1. [Setup CouchDB on fly.io](docs/setup_flyio.md)
|
||||
2. Configure plug-in in [Quick Setup](docs/quick_setup.md)
|
||||
|
||||
1. Install the plugin on your device.
|
||||
2. Configure with the remote database.
|
||||
1. Fill your server's information into the `Remote Database configuration` pane.
|
||||
2. Enabling `End to End Encryption` is recommended. After inputting the passphrase, you have to press `Just apply`.
|
||||
3. Hit `Test Database Connection` and make sure that the plugin says `Connected`.
|
||||
4. Hit `Check database configuration` and make sure all tests have been passed.
|
||||
3. Configure how to synchronize on `Sync setting`. (You can leave these configures later)
|
||||
1. If you want to synchronize in real-time, enable `LiveSync`.
|
||||
2. Or, set up the synchronization as you like.
|
||||
3. Additional configuration is also here. I recommend enabling `Use Trash for deleted files, but you can leave all configurations disabled.
|
||||
4. Configure miscellaneous features.
|
||||
1. Enabling `Show staus inside editor` bring you information. While edit mode, you can see the status on the top-right of the editor. (Recommended)
|
||||
5. Back to the editor. I hope that initial scan is in the progress or done.
|
||||
6. When status became stabilized (All ⏳ and 🧩 have been disappeared), you are ready to synchronize with the server.
|
||||
7. Press the replicate icon on the Ribbon or run `Replicate now` from the Command pallet. You'll send all your data to the server.
|
||||
8. Open the command palette, `Copy setup URI`, and set the passphrase to encrypt the information. Then your configuration will be copied to the clipboard. Please share copied URI with your other devices.
|
||||
**IMPORTANT NOTICE: BE CAREFUL TO TREAT THIS URI. THE URI CONTAINS YOUR CREDENTIALS EVEN THOUGH NOBODY COULD READ WITHOUT THE PASSPHRASE.**
|
||||
### Manually Setup
|
||||
|
||||
### Subsequent Devices
|
||||
1. Setup the server
|
||||
1. [Setup CouchDB on fly.io](docs/setup_flyio.md)
|
||||
2. [Setup your CouchDB](docs/setup_own_server.md)
|
||||
2. Configure plug-in in [Quick Setup](docs/quick_setup.md)
|
||||
|
||||
Strongly recommend using the vault in which all files are completely synchronized including timestamps. Otherwise, some files will be corrupted if failed to resolve conflicts. To simplify, I recommend using a new empty vault.
|
||||
> [!TIP]
|
||||
> Now, fly.io has become not free. Fortunately, even though there are some issues, we are still able to use IBM Cloudant. Here is [Setup IBM Cloudant](docs/setup_cloudant.md). It will be updated soon!
|
||||
|
||||
1. Install the plug-in.
|
||||
2. Open the link that you had been copied to the other device.
|
||||
3. The plug-in asks you that are you sure to apply the configurations. Please answer `Yes` and the following instruction below:
|
||||
1. Answer `Yes` to `Keep local DB?`.
|
||||
*Note: If you started with existed vault, you have to answer `No`. And `No` to `Rebuild the database?`.*
|
||||
2. Answer `Yes` to `Keep remote DB?`.
|
||||
3. Answer `Yes` to `Replicate once?`.
|
||||
Yes, you have to answer `Yes` to everything.
|
||||
Then, all your settings are copied from the first device.
|
||||
4. Your notes will arrive soon.
|
||||
|
||||
## Something looks corrupted...
|
||||
|
||||
Please open the link again and Answer as below:
|
||||
- If your local database looks corrupted
|
||||
(in other words, when your Obsidian getting weird even standalone.)
|
||||
- Answer `No` to `Keep local DB?`
|
||||
- If your remote database looks corrupted
|
||||
(in other words, when something happens while replicating)
|
||||
- Answer `No` to `Keep remote DB?`
|
||||
|
||||
If you answered `No` to both, your databases will be rebuilt by the content on your device. And the remote database will lock out other devices. You have to synchronize all your devices again. (When this time, almost all your files should be synchronized including a timestamp. So you can use the existed vault).
|
||||
|
||||
## Test Server
|
||||
|
||||
Setting up an instance of Cloudant or local CouchDB is a little complicated, so I made the [Tasting server of self-hosted-livesync](https://olstaste.vrtmrz.net/) up. Try free!
|
||||
Note: Please read "Limitations" carefully. Do not send your private vault.
|
||||
|
||||
## Information in StatusBar
|
||||
|
||||
Synchronization status is shown in statusbar.
|
||||
Synchronization status is shown in the status bar with the following icons.
|
||||
|
||||
- Activity Indicator
|
||||
- 📲 Network request
|
||||
- Status
|
||||
- ⏹️ Stopped
|
||||
- 💤 LiveSync is enabled. Waiting for changes.
|
||||
- ⚡️ Synchronize is now in progress.
|
||||
- ⚠ Error occurred.
|
||||
- ↑ Uploaded pieces
|
||||
- ↓ Downloaded pieces
|
||||
- ⏳ Number of the pending processes
|
||||
- 🧩 Number of the files that waiting for their chunks.
|
||||
If you have deleted or renamed files, please wait until ⏳ disappeared.
|
||||
- 💤 LiveSync enabled. Waiting for changes
|
||||
- ⚡️ Synchronization in progress
|
||||
- ⚠ An error occurred
|
||||
- Statistical indicator
|
||||
- ↑ Uploaded chunks and metadata
|
||||
- ↓ Downloaded chunks and metadata
|
||||
- Progress indicator
|
||||
- 📥 Unprocessed transferred items
|
||||
- 📄 Working database operation
|
||||
- 💾 Working write storage processes
|
||||
- ⏳ Working read storage processes
|
||||
- 🛫 Pending read storage processes
|
||||
- 📬 Batched read storage processes
|
||||
- ⚙️ Working or pending storage processes of hidden files
|
||||
- 🧩 Waiting chunks
|
||||
- 🔌 Working Customisation items (Configuration, snippets, and plug-ins)
|
||||
|
||||
To prevent file and database corruption, please wait to stop Obsidian until all progress indicators have disappeared as possible (The plugin will also try to resume, though). Especially in case of if you have deleted or renamed files.
|
||||
|
||||
|
||||
## Hints
|
||||
- When the folder became empty by replication, The folder will be deleted in the default setting. But you can change this behaivour. Check the [Settings](docs/settings.md).
|
||||
- LiveSync mode drains many batteries in mobile devices. Periodic sync and some automatic sync is recommended.
|
||||
- Mobile Obsidian can not connect to the non-secure(HTTP) or local CA-signed servers, even though the certificate is stored in the device store.
|
||||
- There are no 'exclude_folders' like configurations.
|
||||
- When synchronized, files are compared by their modified times and overwritten by the newer ones once. Then plugin checks the conflicts and if a merge is needed, the dialog will open.
|
||||
- Rarely, the file in the database would be broken. The plugin will not write storage when it looks broken, so some old files must be on your device. If you edit the file, it will be cured. But if the file does not exist on any device, can not rescue it. So you can delete these items from the setting dialog.
|
||||
- If your database looks corrupted, try "Drop History". Usually, It is the easiest way.
|
||||
- To stop the bootup sequence for fixing problems on databases, you can put `redflag.md` on top of your vault.
|
||||
- Q: Database is growing, how can I shrink it up?
|
||||
A: each of the docs is saved with their old 100 revisions to detect and resolve confliction. Picture yourself that one device has been off the line for a while, and joined again. The device has to check his note and remote saved note. If exists in revision histories of remote notes even though the device's note is a little different from the latest one, it could be merged safely. Even if that is not in revision histories, we only have to check differences after the revision that both devices commonly have. This is like The git's conflict resolving method. So, We have to make the database again like an enlarged git repo if you want to solve the root of the problem.
|
||||
- And more technical Information are in the [Technical Information](docs/tech_info.md)
|
||||
- If you want to synchronize files without obsidian, you can use [filesystem-livesync](https://github.com/vrtmrz/filesystem-livesync).
|
||||
- WebClipper is also available.
|
||||
Available from on Chrome Web Store:[obsidian-livesync-webclip](https://chrome.google.com/webstore/detail/obsidian-livesync-webclip/jfpaflmpckblieefkegjncjoceapakdf)
|
||||
Repo is here: [obsidian-livesync-webclip](https://github.com/vrtmrz/obsidian-livesync-webclip). (Docs are work in progress.)
|
||||
|
||||
## Tips and Troubleshooting
|
||||
If you are having problems getting the plugin working see: [Tips and Troubleshooting](docs/troubleshooting.md)
|
||||
|
||||
## License
|
||||
|
||||
The source code is licensed MIT.
|
||||
Licensed under the MIT License.
|
||||
|
||||
129
README_cn.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Self-hosted LiveSync
|
||||
|
||||
Self-hosted LiveSync (自搭建在线同步) 是一个社区实现的在线同步插件。
|
||||
使用一个自搭建的或者购买的 CouchDB 作为中转服务器。兼容所有支持 Obsidian 的平台。
|
||||
|
||||
注意: 本插件与官方的 "Obsidian Sync" 服务不兼容。
|
||||
|
||||

|
||||
|
||||
安装或升级 LiveSync 之前,请备份你的 vault。
|
||||
|
||||
## 功能
|
||||
|
||||
- 可视化的冲突解决器
|
||||
- 接近实时的多设备双向同步
|
||||
- 可使用 CouchDB 以及兼容的服务,如 IBM Cloudant
|
||||
- 支持端到端加密
|
||||
- 插件同步 (Beta)
|
||||
- 从 [obsidian-livesync-webclip](https://chrome.google.com/webstore/detail/obsidian-livesync-webclip/jfpaflmpckblieefkegjncjoceapakdf) 接收 WebClip (本功能不适用端到端加密)
|
||||
|
||||
适用于出于安全原因需要将笔记完全自托管的研究人员、工程师或开发人员,以及任何喜欢笔记完全私密所带来的安全感的人。
|
||||
|
||||
## 重要提醒
|
||||
|
||||
- 请勿与其他同步解决方案(包括 iCloud、Obsidian Sync)一起使用。在启用此插件之前,请确保禁用所有其他同步方法以避免内容损坏或重复。如果要同步到多个服务,请一一进行,切勿同时启用两种同步方法。
|
||||
这包括不能将您的保管库放在云同步文件夹中(例如 iCloud 文件夹或 Dropbox 文件夹)
|
||||
- 这是一个同步插件,不是备份解决方案。不要依赖它进行备份。
|
||||
- 如果设备的存储空间耗尽,可能会发生数据库损坏。
|
||||
- 隐藏文件或任何其他不可见文件不会保存在数据库中,因此不会被同步。(**并且可能会被删除**)
|
||||
|
||||
## 如何使用
|
||||
|
||||
### 准备好你的数据库
|
||||
|
||||
首先,准备好你的数据库。IBM Cloudant 是用于测试的首选。或者,您也可以在自己的服务器上安装 CouchDB。有关更多信息,请参阅以下内容:
|
||||
1. [Setup IBM Cloudant](docs/setup_cloudant.md)
|
||||
2. [Setup your CouchDB](docs/setup_own_server_cn.md)
|
||||
|
||||
Note: 正在征集更多搭建方法!目前在讨论的有 [使用 fly.io](https://github.com/vrtmrz/obsidian-livesync/discussions/85)。
|
||||
|
||||
### 第一个设备
|
||||
|
||||
1. 在您的设备上安装插件。
|
||||
2. 配置远程数据库信息。
|
||||
1. 将您的服务器信息填写到 `Remote Database configuration`(远程数据库配置)设置页中。
|
||||
2. 建议启用 `End to End Encryption`(端到端加密)。输入密码后,单击“应用”。
|
||||
3. 点击 `Test Database Connection` 并确保插件显示 `Connected to (你的数据库名称)`。
|
||||
4. 单击 `Check database configuration`(检查数据库配置)并确保所有测试均已通过。
|
||||
3. 在 `Sync Settings`(同步设置)选项卡中配置何时进行同步。(您也可以稍后再设置)
|
||||
1. 如果要实时同步,请启用 `LiveSync`。
|
||||
2. 或者,根据您的需要设置同步方式。默认情况下,不会启用任何自动同步,这意味着您需要手动触发同步过程。
|
||||
3. 其他配置也在这里。建议启用 `Use Trash for deleted files`(删除文件到回收站),但您也可以保持所有配置不变。
|
||||
4. 配置杂项功能。
|
||||
1. 启用 `Show staus inside editor` 会在编辑器右上角显示状态。(推荐开启)
|
||||
5. 回到编辑器。等待初始扫描完成。
|
||||
6. 当状态不再变化并显示 ⏹️ 图标表示 COMPLETED(没有 ⏳ 和 🧩 图标)时,您就可以与服务器同步了。
|
||||
7. 按功能区上的复制图标或从命令面板运行 `Replicate now`(立刻复制)。这会将您的所有数据发送到服务器。
|
||||
8. 打开命令面板,运行 `Copy setup URI`(复制设置链接),并设置密码。这会将您的配置导出到剪贴板,作为您导入其他设备的链接。
|
||||
|
||||
**重要: 不要公开本链接,这个链接包含了你的所有认证信息!** (即使没有密码别人读不了)
|
||||
|
||||
### 后续设备
|
||||
|
||||
注意:如果要与非空的 vault 进行同步,文件的修改日期和时间必须互相匹配。否则,可能会发生额外的传输或文件可能会损坏。
|
||||
为简单起见,我们强烈建议同步到一个全空的 vault。
|
||||
|
||||
1. 安装插件。
|
||||
2. 打开您从第一台设备导出的链接。
|
||||
3. 插件会询问您是否确定应用配置。 回答 `Yes`,然后按照以下说明进行操作:
|
||||
1. 对 `Keep local DB?` 回答 `Yes`。
|
||||
*注意:如果您希望保留本地现有 vault,则必须对此问题回答 `No`,并对 `Rebuild the database?` 回答 `No`。*
|
||||
2. 对 `Keep remote DB?` 回答 `Yes`。
|
||||
3. 对 `Replicate once?` 回答 `Yes`。
|
||||
完成后,您的所有设置将会从第一台设备成功导入。
|
||||
4. 你的笔记应该很快就会同步。
|
||||
|
||||
## 文件看起来有损坏...
|
||||
|
||||
请再次打开配置链接并回答如下:
|
||||
- 如果您的本地数据库看起来已损坏(当你的本地 Obsidian 文件看起来很奇怪)
|
||||
- 对 `Keep local DB?` 回答 `No`
|
||||
- 如果您的远程数据库看起来已损坏(当复制时发生中断)
|
||||
- 对 `Keep remote DB?` 回答 `No`
|
||||
|
||||
如果您对两者都回答“否”,您的数据库将根据您设备上的内容重建。并且远程数据库将锁定其他设备,您必须再次同步所有设备。(此时,几乎所有文件都会与时间戳同步。因此您可以安全地使用现有的 vault)。
|
||||
|
||||
## 测试服务器
|
||||
|
||||
设置 Cloudant 或本地 CouchDB 实例有点复杂,所以我搭建了一个 [self-hosted-livesync 尝鲜服务器](https://olstaste.vrtmrz.net/)。欢迎免费尝试!
|
||||
注意:请仔细阅读“限制”条目。不要发送您的私人 vault。
|
||||
|
||||
## 状态栏信息
|
||||
|
||||
同步状态将显示在状态栏。
|
||||
|
||||
- 状态
|
||||
- ⏹️ 就绪
|
||||
- 💤 LiveSync 已启用,正在等待更改。
|
||||
- ⚡️ 同步中。
|
||||
- ⚠ 一个错误出现了。
|
||||
- ↑ 上传的 chunk 和元数据数量
|
||||
- ↓ 下载的 chunk 和元数据数量
|
||||
- ⏳ 等待的过程的数量
|
||||
- 🧩 正在等待 chunk 的文件数量
|
||||
如果你删除或更名了文件,请等待 ⏳ 图标消失。
|
||||
|
||||
|
||||
## 提示
|
||||
|
||||
- 如果文件夹在复制后变为空,则默认情况下该文件夹会被删除。您可以关闭此行为。检查 [设置](docs/settings.md)。
|
||||
- LiveSync 模式在移动设备上可能导致耗电量增加。建议使用定期同步 + 条件自动同步。
|
||||
- 移动平台上的 Obsidian 无法连接到非安全 (HTTP) 或本地签名的服务器,即使设备上安装了根证书。
|
||||
- 没有类似“exclude_folders”的配置。
|
||||
- 同步时,文件按修改时间进行比较,较旧的将被较新的文件覆盖。然后插件检查冲突,如果需要合并,将打开一个对话框。
|
||||
- 数据库中的文件在罕见情况下可能会损坏。当接收到的文件看起来已损坏时,插件不会将其写入本地存储。如果您的设备上有文件的本地版本,则可以通过编辑本地文件并进行同步来覆盖损坏的版本。但是,如果您的任何设备上都不存在该文件,则无法挽救该文件。在这种情况下,您可以从设置对话框中删除这些损坏的文件。
|
||||
- 要阻止插件的启动流程(例如,为了修复数据库问题),您可以在 vault 的根目录创建一个 "redflag.md" 文件。
|
||||
- 问:数据库在增长,我该如何缩小它?
|
||||
答:每个文档都保存了过去 100 次修订,用于检测和解决冲突。想象一台设备已经离线一段时间,然后再次上线。设备必须将其笔记与远程保存的笔记进行比较。如果存在曾经相同的历史修订,则可以安全地直接更新这个文件(和 git 的快进原理一样)。即使文件不在修订历史中,我们也只需检查两个设备上该文件的公有修订版本之后的差异。这就像 git 的冲突解决方法。所以,如果想从根本上解决数据库太大的问题,我们像构建一个扩大版的 git repo 一样去重新设计数据库。
|
||||
- 更多技术信息在 [技术信息](docs/tech_info.md)
|
||||
- 如果你想在没有黑曜石的情况下同步文件,你可以使用[filesystem-livesync](https://github.com/vrtmrz/filesystem-livesync)。
|
||||
- WebClipper 也可在 Chrome Web Store 上使用:[obsidian-livesync-webclip](https://chrome.google.com/webstore/detail/obsidian-livesync-webclip/jfpaflmpckblieefkegjncjoceapakdf)
|
||||
|
||||
|
||||
仓库地址:[obsidian-livesync-webclip](https://github.com/vrtmrz/obsidian-livesync-webclip) (文档施工中)
|
||||
|
||||
## License
|
||||
|
||||
The source code is licensed under the MIT License.
|
||||
本源代码使用 MIT 协议授权。
|
||||
155
README_ja.md
@@ -1,96 +1,85 @@
|
||||
<!-- For translation: 20240227r0 -->
|
||||
# Self-hosted LiveSync
|
||||
[英語版ドキュメント](./README.md) - [中国語版ドキュメント](./README_cn.md).
|
||||
|
||||
**旧): obsidian-livesync**
|
||||
Obsidianで利用可能なすべてのプラットフォームで使える、CouchDBをサーバに使用する、コミュニティ版の同期プラグイン
|
||||
|
||||
セルフホストしたデータベースを使って、双方向のライブシンクするObsidianのプラグイン。
|
||||
**公式のSyncとは互換性はありません**
|
||||

|
||||
|
||||
**ほぼ動くようになってきましたが、Vaultのバックアップは確実に取得してください**
|
||||
|
||||
[英語版](./README.md)
|
||||
|
||||
## こんなことができるプラグインです。
|
||||
- Windows, Mac, iPad, iPhone, Android, Chromebookで動く
|
||||
- セルフホストしたデータベースに同期して
|
||||
- 複数端末で同時にその変更をほぼリアルタイムで配信し
|
||||
- さらに、他の端末での変更も別の端末に配信する、双方向リアルタイムなLiveSyncを実現でき、
|
||||
- 発生した変更の衝突はその場で解決できます。
|
||||
- 同期先のホストにはCouchDBまたはその互換DBaaSのIBM Cloudantをサーバーに使用できます。あなたのデータは、あなたのものです。
|
||||
- もちろんLiveではない同期もできます。
|
||||
- 万が一のために、サーバーに送る内容を暗号化できます(betaです)。
|
||||
- [Webクリッパー](https://chrome.google.com/webstore/detail/obsidian-livesync-webclip/jfpaflmpckblieefkegjncjoceapakdf) もあります(End-to-End暗号化対象外です)
|
||||
|
||||
NDAや類似の契約や義務、倫理を守る必要のある、研究者、設計者、開発者のような方に特にオススメです。
|
||||
特にエンタープライズでは、たとえEnd to Endの暗号化が行われていても、管理下にあるサーバーにのみデータを格納することが求められる場合があります。
|
||||
|
||||
# 重要なお知らせ
|
||||
|
||||
- ❌ファイルの重複や破損を避けるため、複数の同期手段を同時に使用しないでください。
|
||||
これは、Vaultをクラウド管理下のフォルダに置くことも含みます。(例えば、iCloudの管理フォルダ内に入れたり)。
|
||||
- ⚠️このプラグインは、端末間でのノートの反映を目的として作成されました。バックアップ等が目的ではありません。そのため、バックアップは必ず別のソリューションで行うようにしてください。
|
||||
- ストレージの空き容量が枯渇した場合、データベースが破損することがあります。
|
||||
- 隠しファイルやObsidisanが認識できないファイルを編集した場合、そのファイルは削除されることがあります。
|
||||
※公式のSyncと同期することはできません。
|
||||
|
||||
|
||||
# 補足
|
||||
## 機能
|
||||
- 高効率・低トラフィックでVault同士を同期
|
||||
- 競合解決がいい感じ
|
||||
- 単純な競合なら自動マージします
|
||||
- OSSソリューションを同期サーバに使用
|
||||
- 互換ソリューションも使用可能です
|
||||
- End-to-End暗号化実装済み
|
||||
- 設定・スニペット・テーマ、プラグインの同期が可能
|
||||
- [Webクリッパー](https://chrome.google.com/webstore/detail/obsidian-livesync-webclip/jfpaflmpckblieefkegjncjoceapakdf) もあります
|
||||
|
||||
- レプリケーションなどでファイルがリモートデバイスから削除された場合、受信したデバイスでも、ファイルの削除が反映されます。
|
||||
- その際、Self-hosted LiveSyncは、フォルダが空になった際に、フォルダをデフォルトでは残しません。残す場合はオプションから設定してください。
|
||||
- LiveSyncはモバイルではバッテリーをかなり消費します。
|
||||
- モバイル端末からは、非httpsのエンドポイント、または独自CAが発行した証明書でホストされているhttpsのサーバーには接続できません。
|
||||
- 除外フォルダのような設定はありません。
|
||||
NDAや類似の契約や義務、倫理を守る必要のある、研究者、設計者、開発者のような方に特にオススメです。
|
||||
|
||||
|
||||
# このプラグインの使い方
|
||||
|
||||
1. Community Pluginsから、Self-holsted LiveSyncと検索しインストールするか、このリポジトリのReleasesから`main.js`, `manifest.json`, `style.css` をダウンロードしvaultの中の`.obsidian/plugins/obsidian-livesync`に入れて、Obsidianを再起動してください。
|
||||
2. サーバーを確保します。IBM Cloudantがお手軽かつ堅牢で便利です。完全にセルフホストする際にはお持ちのサーバーにCouchDBをインストールする必要があります。詳しくは下記を参照してください
|
||||
1. [IBM Cloudantのセットアップ](docs/setup_cloudant_ja.md)
|
||||
2. [独自のCouchDBのセットアップ](docs/setup_own_server_ja.md)
|
||||
3. サーバー情報を入力します。初回のみ、Obsidianを再起動することをオススメします。
|
||||
設定内容の詳細は[このプラグインの設定](docs/settings_ja.md)を参照してください。
|
||||
|
||||
4. お好きな同期方法を選んで、利用を開始してください。
|
||||
|
||||
# テストサーバー
|
||||
|
||||
もし、CouchDBをインストールしたり、Cloudantのインスタンスをセットアップしたりするのに気が引ける場合、[Self-hosted LiveSyncのテストサーバー](https://olstaste.vrtmrz.net/)を作りましたので、使ってみてください。
|
||||
|
||||
備考: 制限事項をよく確認して使用してください。くれぐれも、本当に使用している自分のVaultを同期しないようにしてください。
|
||||
|
||||
# WebClipperあります
|
||||
Self-hosted LiveSync用にWebClipperも作りました。Chrome Web Storeからダウンロードできます。
|
||||
|
||||
[obsidian-livesync-webclip](https://chrome.google.com/webstore/detail/obsidian-livesync-webclip/jfpaflmpckblieefkegjncjoceapakdf)
|
||||
|
||||
リポジトリはこちらです: [obsidian-livesync-webclip](https://github.com/vrtmrz/obsidian-livesync-webclip)。
|
||||
|
||||
相変わらずドキュメントは間に合っていません。
|
||||
|
||||
# ステータスバーの情報
|
||||
右下のステータスバーに、同期の状態が表示されます
|
||||
|
||||
- 同期状態
|
||||
- ⏹️ 同期は停止しています
|
||||
- 💤 同期はLiveSync中で、なにか起こるのを待っています
|
||||
- ⚡️ 同期中です
|
||||
- ⚠ エラーが発生しています
|
||||
- ↑ 送信したデータ数
|
||||
- ↓ 受信したデータ数
|
||||
- ⏳ 保留している処理の数です
|
||||
ファイルを削除したりリネームした場合、この表示が消えるまでお待ちください。
|
||||
|
||||
# さらなる補足
|
||||
- ファイルは同期された後、タイムスタンプを比較して新しければいったん新しい方で上書きされます。その後、衝突が発生したかによって、マージが行われます。
|
||||
- まれにファイルが破損することがあります。破損したファイルに関してはディスクへの反映を試みないため、実際には使用しているデバイスには少し古いファイルが残っていることが多いです。そのファイルを再度更新してもらうと、データベースが更新されて問題なくなるケースがあります。ファイルがどの端末にも存在しない場合は、設定画面から、削除できます。
|
||||
- データベースが変。そういうときは、いったんデータベースをDrop Historyのapply and sendで再初期化してみてください。だいたい直ります。
|
||||
- データベースの復旧中に再起動した場合など、うまくローカルデータベースを修正できない際には、Vaultのトップに`redflag.md`というファイルを置いてください。起動時のシーケンスがスキップされます。
|
||||
- データベースが大きくなってきてるんだけど、小さくできる?→各ノートは、それぞれの古い100リビジョンとともに保存されています。例えば、しばらくオフラインだったあるデバイスが、久しぶりに同期したと想定してみてください。そのとき、そのデバイスは最新とは少し異なるリビジョンを持ってるはずです。その場合でも、リモートのリビジョン履歴にリモートのものが存在した場合、安全にマージできます。もしリビジョン履歴に存在しなかった場合、確認しなければいけない差分も、対象を存在して持っている共通のリビジョン以降のみに絞れます。ちょうどGitのような方法で、衝突を解決している形になるのです。そのため、肥大化したリポジトリの解消と同様に、本質的にデータベースを小さくしたい場合は、データベースの作り直しが必要です。
|
||||
- その他の技術的なお話は、[技術的な内容](docs/tech_info_ja.md)に書いてあります。
|
||||
>[!IMPORTANT]
|
||||
> - インストール・アップデート前には必ずVaultをバックアップしてください
|
||||
> - 複数の同期ソリューションを同時に有効にしないでください(これはiCloudや公式のSyncも含みます)
|
||||
> - このプラグインは同期プラグインです。バックアップとして使用しないでください
|
||||
|
||||
|
||||
# ライセンス
|
||||
## このプラグインの使い方
|
||||
|
||||
The source code is licensed MIT.
|
||||
|
||||
### 3分セットアップ - CouchDB on fly.io
|
||||
|
||||
**はじめての方におすすめ**
|
||||
|
||||
[](https://www.youtube.com/watch?v=7sa_I1832Xc)
|
||||
|
||||
1. [Fly.ioにCouchDBをセットアップする](docs/setup_flyio.md)
|
||||
2. [Quick Setup](docs/quick_setup_ja.md)でプラグインを設定する
|
||||
|
||||
|
||||
### Manually Setup
|
||||
|
||||
1. サーバのセットアップ
|
||||
1. [Fly.ioにCouchDBをセットアップする](docs/setup_flyio.md)
|
||||
2. [CouchDBをセットアップする](docs/setup_own_server_ja.md)
|
||||
2. [Quick Setup](docs/quick_setup_ja.md)でプラグインを設定する
|
||||
|
||||
> [!TIP]
|
||||
> IBM Cloudantもまだ使用できますが、いくつかの理由で現在はおすすめしていません。[IBM Cloudantのセットアップ](docs/setup_cloudant_ja.md)はまだあります。
|
||||
|
||||
## ステータスバーの説明
|
||||
|
||||
同期ステータスはステータスバーに、下記のアイコンとともに表示されます
|
||||
|
||||
- アクティビティー
|
||||
- 📲 ネットワーク接続中
|
||||
- 同期ステータス
|
||||
- ⏹️ 停止中
|
||||
- 💤 変更待ち(LiveSync中)
|
||||
- ⚡️ 同期の進行中
|
||||
- ⚠ エラー
|
||||
- 統計情報
|
||||
- ↑ アップロードしたチャンクとメタデータ数
|
||||
- ↓ ダウンロードしたチャンクとメタデータ数
|
||||
- 進捗情報
|
||||
- 📥 転送後、未処理の項目数
|
||||
- 📄 稼働中データベース操作数
|
||||
- 💾 稼働中のストレージ書き込み数操作数
|
||||
- ⏳ 稼働中のストレージ読み込み数操作数
|
||||
- 🛫 待機中のストレージ読み込み数操作数
|
||||
- ⚙️ 隠しファイルの操作数(待機・稼働中合計)
|
||||
- 🧩 取得待ちを行っているチャンク数
|
||||
- 🔌 設定同期関連の操作数
|
||||
|
||||
データベースやファイルの破損を避けるため、Obsidianの終了は進捗情報が表示されなくなるまで待ってください(プラグインも復帰を試みますが)。特にファイルを削除やリネームした場合は気をつけてください。
|
||||
|
||||
|
||||
## Tips and Troubleshooting
|
||||
何かこまったら、[Tips and Troubleshooting](docs/troubleshooting.md)をご参照ください。
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the MIT License.
|
||||
46
docker-compose.traefik.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
# For details and other explanations about this file refer to:
|
||||
# https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/setup_own_server.md#traefik
|
||||
|
||||
version: "2.1"
|
||||
services:
|
||||
couchdb:
|
||||
image: couchdb:latest
|
||||
container_name: obsidian-livesync
|
||||
user: 1000:1000
|
||||
environment:
|
||||
- COUCHDB_USER=username
|
||||
- COUCHDB_PASSWORD=password
|
||||
volumes:
|
||||
- ./data:/opt/couchdb/data
|
||||
- ./local.ini:/opt/couchdb/etc/local.ini
|
||||
# Ports not needed when already passed to Traefik
|
||||
#ports:
|
||||
# - 5984:5984
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- proxy
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# The Traefik Network
|
||||
- "traefik.docker.network=proxy"
|
||||
# Don't forget to replace 'obsidian-livesync.example.org' with your own domain
|
||||
- "traefik.http.routers.obsidian-livesync.rule=Host(`obsidian-livesync.example.org`)"
|
||||
# The 'websecure' entryPoint is basically your HTTPS entrypoint. Check the next code snippet if you are encountering problems only; you probably have a working traefik configuration if this is not your first container you are reverse proxying.
|
||||
- "traefik.http.routers.obsidian-livesync.entrypoints=websecure"
|
||||
- "traefik.http.routers.obsidian-livesync.service=obsidian-livesync"
|
||||
- "traefik.http.services.obsidian-livesync.loadbalancer.server.port=5984"
|
||||
- "traefik.http.routers.obsidian-livesync.tls=true"
|
||||
# Replace the string 'letsencrypt' with your own certificate resolver
|
||||
- "traefik.http.routers.obsidian-livesync.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.obsidian-livesync.middlewares=obsidiancors"
|
||||
# The part needed for CORS to work on Traefik 2.x starts here
|
||||
- "traefik.http.middlewares.obsidiancors.headers.accesscontrolallowmethods=GET,PUT,POST,HEAD,DELETE"
|
||||
- "traefik.http.middlewares.obsidiancors.headers.accesscontrolallowheaders=accept,authorization,content-type,origin,referer"
|
||||
- "traefik.http.middlewares.obsidiancors.headers.accesscontrolalloworiginlist=app://obsidian.md,capacitor://localhost,http://localhost"
|
||||
- "traefik.http.middlewares.obsidiancors.headers.accesscontrolmaxage=3600"
|
||||
- "traefik.http.middlewares.obsidiancors.headers.addvaryheader=true"
|
||||
- "traefik.http.middlewares.obsidiancors.headers.accessControlAllowCredentials=true"
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
34
docs/adding_translations.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# How to add translations
|
||||
|
||||
## Getting ready
|
||||
|
||||
1. Clone this repository recursively.
|
||||
```sh
|
||||
git clone --recursive https://github.com/vrtmrz/obsidian-livesync
|
||||
```
|
||||
2. Make `ls-debug` folder under your vault's `.obsidian` folder (as like `.../dev/.obsidian/ls-debug`).
|
||||
|
||||
## Add translations for already defined terms
|
||||
|
||||
1. Install dependencies, and build the plug-in as dev. build.
|
||||
```sh
|
||||
cd obsidian-livesync
|
||||
npm i -D
|
||||
npm run buildDev
|
||||
```
|
||||
|
||||
2. Copy the `main.js` to `.obsidian/plugins/obsidian-livesync` folder of your vault, and run Obsidian-Self-hosted LiveSync.
|
||||
3. You will get the `missing-translation-yyyy-mm-dd.jsonl`, please fill in new translations.
|
||||
4. Build the plug-in again, and confirm that displayed things were expected.
|
||||
5. Merge them into `rosetta.ts`, and make the PR to `https://github.com/vrtmrz/livesync-commonlib`.
|
||||
|
||||
## Make messages to be translated
|
||||
|
||||
1. Find the message that you want to be translated.
|
||||
2. Change the literal to a tagged template literal using `$f`, like below.
|
||||
```diff
|
||||
- Logger("Could not determine passphrase to save data.json! You probably make the configuration sure again!", LOG_LEVEL_URGENT);
|
||||
+ Logger($f`Could not determine passphrase to save data.json! You probably make the configuration sure again!`, LOG_LEVEL_URGENT);
|
||||
```
|
||||
3. Make the PR to `https://github.com/vrtmrz/obsidian-livesync`.
|
||||
4. Follow the steps of "Add translations for already defined terms" to add the translations.
|
||||
50
docs/design_docs_of_journalsync.md
Normal file
@@ -0,0 +1,50 @@
|
||||
## The design document of the journal sync
|
||||
|
||||
Original title: Synchronise without CouchDB
|
||||
|
||||
### Goal
|
||||
- Synchronise vaults without CouchDB
|
||||
|
||||
### Motivation
|
||||
- Serving CouchDB is not pretty easy.
|
||||
- Full spec DBaaS (Paid IBM Cloudant) is a bit expensive and lacking of alternatives.
|
||||
- Securing alternatives, from just one protocol.
|
||||
|
||||
### Prerequisite
|
||||
- We should have multiple implementations of the server software.
|
||||
- We should also be able to use SaaS, with a choice of options.
|
||||
- We should require them a reasonable sense of cost, ideally free of charge for trials.
|
||||
- We should be able to serve some instance of the server software, as OSS — with transparency, availability of auditing, and the fact that they actually took place.
|
||||
|
||||
### Methods and implementations
|
||||
|
||||
Ordinarily, local pouchDB and the remote CouchDB are synchronised by sending each missing document through several conversations in their replication protocol. However, to achieve this plan, we cannot rely on CouchDB and its protocols. This limitation is so harsh. However, Overcoming this means gaining new possibilities. After some trials, It was concluded that synchronisation could be completed even if the actions that could be performed were limited to uploading, downloading and retrieving the list. This means we can use any old-fashioned WebDAV server, and Sophisticated “Object storages” such as Self-hosted MinIO, S3, and R2 or any we like. This is realised by sharing and complementing the differences of the journal by each client. Therefore, The focus is therefore on how to identify which are the differences and send them without dynamic communication.
|
||||
|
||||
All clients manage their data in PouchDB. I know this is probably known information, but it has its own journal.
|
||||
|
||||
First, all clients should record to what point in the journal they sent themselves last time. The client then packs from the previous point to the latest when sending and also updates their record. This pack is uploaded to the server with the name starting with the timestamp of its creation. This is the send operation.
|
||||
|
||||
Conversely, when receiving, the packs uploaded to the server that have not yet been received are received in order. This is easy as their names are in date order. When the process is successfully completed, the names of the files received are recorded. The journals from this pack are then reflected in their own database. Conflict resolution is left to PouchDB, so the client only needs to do the work of applying any differences. And here is the key: the client records the ID and revision of the document that was in the journal and applied.
|
||||
|
||||
This key works when creating a pack. When creating a pack, the client omits this 'document recorded as received and used'. This is because received and applied means that it has already been sent by another client and exists on the server. This ensures that unnecessary transmissions do not take place.
|
||||
|
||||
Synchronisation is then always started by receiving. This is a little trick to avoid including unnecessary documents in the pack.
|
||||
|
||||
These behaviours allow clients to voluntarily send and receive only the missing parts of the journal that are not stored on the server, without having to communicate with each other, and still keep a single, consistent journal on the server.
|
||||
|
||||
Source codes actually implemented this is already committed into the repository.
|
||||
|
||||
### Test strategy
|
||||
|
||||
This implementation replaces the synchronisation performed by CouchDB. Therefore, testing was simply done by comparing the same changes to the same vault, replicated in CouchDB, with those done by this implementation.
|
||||
|
||||
### Documentation strategy
|
||||
|
||||
- Documentation should be done in a quick setup, at least.
|
||||
- As several server implementations can be selected, the description is omitted with regard to specific configuration values.
|
||||
- A MinIO set-up might be nice to have. However, it is not considered essential.
|
||||
- It would be a good opportunity to also publish these design documents.
|
||||
|
||||
### Consideration and Conclusion
|
||||
|
||||
This design offers a novel approach to journal synchronisation without relying on CouchDB. It leverages PouchDB's journaling capabilities and leverages simple server-side storage for efficient data exchange. Hence, the new design could be said to have gotten a broader outlook.
|
||||
81
docs/design_docs_of_keep_newborn_chunks.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Keep newborn chunks in Eden.
|
||||
|
||||
NOTE: This is the planned feature design document. This is planned, but not be implemented now (v0.23.3). This has not reached the design freeze and will be added to from time to time.
|
||||
|
||||
## Goal
|
||||
|
||||
Reduce the number of chunks which in volatile, and reduce the usage of storage of the remote database in middle or long term.
|
||||
|
||||
## Motivation
|
||||
|
||||
- In the current implementation, Self-hosted LiveSync splits documents into metadata and multiple chunks. In particular, chunks are split so that they do not exceed a certain length.
|
||||
- This is to optimise the transfer and take advantage of the properties of CouchDB. This also complies with the restriction of IBM Cloudant on the size of a single document.
|
||||
- However, creating chunks halfway through each editing operation increases the number of unnecessary chunks.
|
||||
- Chunks are shared by several documents. For this reason, it is not clear whether these chunks are needed or not unless all revisions of all documents are checked. This makes it difficult to remove unnecessary data.
|
||||
- On the other hand, chunks are done in units that can be neatly divided as markdown to ensure relatively accurate de-duplication, even if they are created simultaneously on multiple terminals. Therefore, it is unlikely that the data in the editing process will be reused.
|
||||
- For this reason, we have made features such as Batch save available, but they are not a fundamental solution.
|
||||
- As a result, there is a large amount of data that cannot be erased and is probably unused. Therefore, `Fetch chunks on demand` is currently performed for optimal communication.
|
||||
- If the generation of unnecessary chunks is sufficiently reduced, this function will become unnecessary.
|
||||
- The problem is that this unnecessary chunking slows down both local and remote operations.
|
||||
|
||||
## Prerequisite
|
||||
- The implementation must be able to control the size of the document appropriately so that it does not become non-transferable (1).
|
||||
- The implementation must be such that data corruption can be avoided even if forward compatibility is not maintained; due to the nature of Self-hosted LiveSync, backward version connexions are expected.
|
||||
- Viewed as a feature:
|
||||
- This feature should be disabled for migration users.
|
||||
- This feature should be enabled for new users and after rebuilds of migrated users.
|
||||
- Therefore, back into the implementation view, Ideally, the implementation should be such that data recovery can be achieved by immediately upgrading after replication.
|
||||
|
||||
## Outlined methods and implementation plans
|
||||
### Abstract
|
||||
To store and transfer only stable chunks independently and share them from multiple documents after stabilisation, new chunks, i.e. chunks that are considered non-stable, are modified to be stored in the document and transferred with the document. In this case, care should be taken not to exceed prerequisite (1).
|
||||
|
||||
If this is achieved, the non-leaf document will not be transferred, and even if it is, the chunk will be stored in the document, so that the size can be reduced by the compaction.
|
||||
|
||||
Details are given below.
|
||||
|
||||
1. The document will henceforth have the property eden.
|
||||
```typescript
|
||||
// Paritally Type
|
||||
type EntryWithEden = {
|
||||
eden: {
|
||||
[key: DocumentID]: {
|
||||
data: string,
|
||||
epoch: number, // The document revision which this chunk has been born.
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
2. The following configuration items are added:
|
||||
Note: These configurations should be shared as `Tweaks value` between each client.
|
||||
- useEden : boolean
|
||||
- Max chunks in eden : number
|
||||
- Max Total chunk lengths in eden: number
|
||||
- Max age while in eden: number
|
||||
3. In the document saving operation, chunks are added to Eden within each document, having the revision number of the existing document. And if some chunks in eden are not used in the operating revision, they would be removed.
|
||||
Then after being so chosen, a few chunks are also chosen to be graduated as an independent `chunk` in following rules, and they would be left the eden:
|
||||
- Those that have already been confirmed to exist as independent chunks.
|
||||
- This confirmation of existence may ideally be determined by a fast first-order determination, e.g. by a Bloom filter.
|
||||
- Those whose length exceeds the configured maximum length.
|
||||
- Those have aged over the configured value, since epoch at the operating revision.
|
||||
- Those whose total length, when added up when they are arranged in reverse order of the revision in which they were generated, is after the point at which they exceed the max length in the configuration. Or, those after the configured maximum items.
|
||||
4. In the document loading operation, chunks are firstly read from these eden.
|
||||
5. In End-to-End Encryption, property `eden` of documents will also be encrypted.
|
||||
|
||||
### Note
|
||||
- When this feature has been enabled, forward compatibility is temporarily lost. However, it is detected as missing chunks, and this data is not reflected in the storage in the old version. Therefore, no data loss will occur.
|
||||
|
||||
## Test strategy
|
||||
|
||||
1. Confirm that synchronisation with the previous version is possible with this feature disabled.
|
||||
2. With this feature enabled, connect from the previous version and confirm that errors are detected in the previous version but the files are not corrupted.
|
||||
3. Ensure that the two versions with this feature enabled can withstand normal use.
|
||||
|
||||
## Documentation strategy
|
||||
|
||||
- This document is published, and will be referred from the release note.
|
||||
- Indeed, we lack a fulfilled configuration table. Efforts will be made and, if they can be produced, this document will then be referenced. But not required while in the experimental or beta feature.
|
||||
- However, this might be an essential feature. Further efforts are desired.
|
||||
|
||||
### Consideration and Conclusion
|
||||
To be described after implemented, tested, and, released.
|
||||
55
docs/design_docs_of_sharing_tweak_value.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Sharing `Tweak values`
|
||||
|
||||
NOTE: This is the planned feature design document. This is planned, but not be implemented now (v0.23.3). This has not reached the design freeze and will be added to from time to time.
|
||||
|
||||
## Goal
|
||||
|
||||
Share `Tweak values` between clients to match the chunk lengths, and match per-server configurations for better performance.
|
||||
|
||||
## Motivation
|
||||
|
||||
- In the current implementation, Self-hosted LiveSync splits documents into metadata and multiple chunks. In particular, chunks are split so that they do not exceed a certain length.
|
||||
- This is to optimise the transfer and take advantage of the properties of CouchDB. This also complies with the restriction of IBM Cloudant on the size of a single document.
|
||||
- The length of this chunk is adjusted according to a configured factor. Therefore, if this is inconsistent between clients, de-duplication will not work. This is because, in fact, they point to the same content in total, but are split in different places. This results in unnecessary transfers or storage consumption.
|
||||
- The same applies to hash algorithms.
|
||||
- There are more configurations which `preferred to be matched`, even if it is not required. such as the maximum size of files to be handled and the interval between requests to the remote database, unless there are specific circumstances.
|
||||
- To avoid the tragedy of "Too many toggles", "Unexpected transfer amount", or "Poor performance" at once, the plug-in should know these problems or potential problems and be able to let us know.
|
||||
|
||||
## Prerequisite
|
||||
- We must be informed of a discrepancy in a configured value that is required to be absolutely consistent and be able to make a decision on the spot.
|
||||
- We should be able to see on the configuration dialogue, that there is a discrepancy between configured values that should be matched, and it should be possible to adjust them to a specific one of them (or default).
|
||||
- We must not be exposed to unexpected; such as leaking credentials or their secrets.
|
||||
|
||||
## Outlined methods and implementation plans
|
||||
### Abstract
|
||||
- In the current implementation, each client checks the remote database for the existence of their node information, to detect whether the remote database accepts them.
|
||||
- This is what 'Lock' is all about.
|
||||
- To achieve this feature, the client will also send each configuration value. However, the configuration contains credentials and/or secret values. Hence we cannot send all of them.
|
||||
- With a favourable prediction, Self-hosted LiveSync will continue to increase in feature. Each time this happens, the number of configuration values to be kept secret will also increase. Therefore, they must be handled by an allow-list.
|
||||
- This allow-listed configuration are the `Tweak values`.
|
||||
- If the plug-in detects mismatched `Tweak values` on checking the remote database, the plug-in will ask us to decide which is win (Mine, or theirs).
|
||||
- Node information is one of the documents. Therefore, it will be replicated and saved locally. While showing dialogue, show the notice on each `Match preferred` configuration.
|
||||
|
||||
## Note
|
||||
This feature should be mostly harmless. We will not be able to disable this.
|
||||
|
||||
## Test strategy
|
||||
|
||||
A: During synchronisation.
|
||||
1. No message shall be displayed with all settings matched.
|
||||
2. Message shall be displayed when there are mismatched, required match items.
|
||||
1. The setting values can be changed according to the message.
|
||||
2. The message can be ignored.
|
||||
3. The message shall not be displayed even if there are mismatched items which is recommended to be matched.
|
||||
|
||||
B: On the setting dialogue.
|
||||
1. All mismatched items shall be highlighted in some way.
|
||||
|
||||
## Documentation strategy
|
||||
|
||||
- This document is published, and will be referred from the release note.
|
||||
- Indeed, we lack a fulfilled configuration table. Efforts will be made and, if they can be produced, this document will then be referenced. But not required while in the experimental or beta feature.
|
||||
- However, this might be an essential feature. Further efforts are desired.
|
||||
|
||||
### Consideration and Conclusion
|
||||
To be described after implemented, tested, and, released.
|
||||
129
docs/quick_setup.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Quick setup
|
||||
|
||||
[Japanese docs](./quick_setup_ja.md) - [Chinese docs](./quick_setup_cn.md).
|
||||
|
||||
The plugin has so many configuration options to deal with different circumstances. However, only a few settings are required in the normal cases. Therefore, `The Setup wizard` has been implemented to simplify the setup.
|
||||
|
||||

|
||||
|
||||
There are three methods to set up Self-hosted LiveSync.
|
||||
|
||||
1. [Using setup URIs](#1-using-setup-uris) *(Recommended)*
|
||||
2. [Minimal setup](#2-minimal-setup)
|
||||
3. [Full manually setup the and Enable on this dialogue](#3-manually-setup)
|
||||
|
||||
## At the first device
|
||||
|
||||
### 1. Using setup URIs
|
||||
|
||||
> [!TIP]
|
||||
> What is the setup URI? Why is it required?
|
||||
> The setup URI is the encrypted representation of Self-hosted LiveSync configuration as a URI. This starts `obsidian://setuplivesync?settings=`. This is encrypted with a passphrase, so that it can be shared relatively securely between devices. It is a bit long, but it is one line. This allows a series of settings to be set at once without any inconsistencies.
|
||||
>
|
||||
> If you have configured the remote database by [Automated setup on Fly.io](./setup_flyio.md#a-very-automated-setup) or [set up your server with the tool](./setup_own_server.md#1-generate-the-setup-uri-on-a-desktop-device-or-server), **you should have one of them**
|
||||
|
||||
In this procedure, [this video](https://youtu.be/7sa_I1832Xc?t=146) may help us.
|
||||
|
||||
1. Click `Use` button (Or launch `Use the copied setup URI` from Command palette).
|
||||
2. Paste the Setup URI into the dialogue
|
||||
3. Type the passphrase of the Setup URI
|
||||
4. Answer `yes` for `Importing LiveSync's conf, OK?`.
|
||||
5. Answer `Set it up as secondary or subsequent device` for `How would you like to set it up?`.
|
||||
6. Initialisation will begin, please hold a while.
|
||||
7. You will asked about the hidden file synchronisation, answer as you like.
|
||||
1. If you are new to Self-hosted LiveSync, we can configure it later so leave it once.
|
||||
8. Synchronisation has been started! `Reload app without saving` is recommended after the indicators of Self-hosted LiveSync disappear.
|
||||
|
||||
OK, we can proceed the [next step](#).
|
||||
|
||||
### 2. Minimal setup
|
||||
|
||||
If you do not have any setup URI, Press the `start` button. The setting dialogue turns into the wizard mode and will display only minimal items.
|
||||
|
||||
>[!TIP]
|
||||
> We can generate the setup URI with the tool in any time. Please use [this tool](./setup_own_server.md#1-generate-the-setup-uri-on-a-desktop-device-or-server).
|
||||
|
||||

|
||||
|
||||
|
||||
#### Select the remote type
|
||||
|
||||
1. Select the Remote Type from dropdown list.
|
||||
We now have a choice between CouchDB (and its compatibles) and object storage (MinIO, S3, R2). CouchDB is the first choice and is also recommended. And supporting Object Storage is an experimental feature.
|
||||
|
||||
#### Remote configuration
|
||||
|
||||
##### CouchDB
|
||||
|
||||
Enter the information for the database we have set up.
|
||||
|
||||

|
||||
|
||||
##### Object Storage
|
||||
|
||||
1. Enter the information for the S3 API and bucket.
|
||||
|
||||

|
||||
|
||||
Note 1: if you use S3, you can leave the Endpoint URL empty.
|
||||
Note 2: if your Object Storage cannot configure the CORS setting fully, you may able to connect to the server by enabling the `Use Custom HTTP Handler` toggle.
|
||||
|
||||
2. Press `Test` of `Test Connection` once and ensure you can connect to the Object Storage.
|
||||
|
||||
#### Only CouchDB: Test database connection and Check database configuration
|
||||
|
||||
We can check the connectivity to the database, and the database settings.
|
||||
|
||||

|
||||
|
||||
#### Only CouchDB: Check and Fix database configuration
|
||||
|
||||
Check the database settings and fix any problems on the spot.
|
||||
|
||||

|
||||
|
||||
This item may vary depending on the connection. In the above case, press all three Fix buttons.
|
||||
If the Fix buttons disappear and all become check marks, we are done.
|
||||
|
||||
#### Confidentiality configuration (Optional but very preferred)
|
||||
|
||||

|
||||
|
||||
Enable End-to-end encryption and the contents of your notes will be encrypted at the moment it leaves the device. We strongly recommend enabling it. And `Path Obfuscation` also obfuscates filenames. Now stable and recommended.
|
||||
|
||||
These setting can be disabled if you are inside a closed network and it is clear that you will not be accessed by third parties.
|
||||
|
||||
> [!TIP]
|
||||
> Encryption is based on 256-bit AES-GCM.
|
||||
|
||||
We should proceed to the Next step.
|
||||
|
||||
#### Sync Settings
|
||||
Finally, finish the wizard by selecting a preset for synchronisation.
|
||||
|
||||
Note: If you are going to use Object Storage, you cannot select `LiveSync`.
|
||||
|
||||

|
||||
|
||||
Select any synchronisation methods we want to use and `Apply`. If database initialisation is required, it will be performed at this time. When `All done!` is displayed, we are ready to synchronise.
|
||||
|
||||
The dialogue of `Copy settings as a new setup URI` will be open automatically. Please input a passphrase to encrypt the new `Setup URI`. (This passphrase is to encrypt the setup URI, not the vault).
|
||||
|
||||

|
||||
|
||||
The Setup URI will be copied to the clipboard, please make a note(Not in Obsidian) of this.
|
||||
|
||||
>[!TIP]
|
||||
We can copy this in any time by `Copy current settings as a new setup URI`.
|
||||
|
||||
### 3. Manually setup
|
||||
|
||||
It is strongly recommended to perform a "minimal set-up" first and set up the other contents after making sure has been synchronised.
|
||||
|
||||
However, if you have some specific reasons to configure it manually, please click the `Enable` button of `Enable LiveSync on this device as the set-up was completed manually`.
|
||||
And, please copy the setup URI by `Copy current settings as a new setup URI` and make a note(Not in Obsidian) of this.
|
||||
|
||||
## At the subsequent device
|
||||
After installing Self-hosted LiveSync on the first device, we should have a setup URI. **The first choice is to use it**. Please share it with the device you want to setup.
|
||||
|
||||
It is completely same as [Using setup URIs on the first device](#1-using-setup-uris). Please refer it.
|
||||
93
docs/quick_setup_cn.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# 快速配置 (Quick setup)
|
||||
|
||||
该插件有较多配置项, 可以应对不同的情况. 不过, 实际使用的设置并不多. 因此, 我们采用了 "设置向导 (The Setup wizard)" 来简化初始设置.
|
||||
|
||||
Note: 建议使用 `Copy setup URI` and `Open setup URI` 来设置后续设备.
|
||||
|
||||
## 设置向导 (The Setup wizard)
|
||||
|
||||
在设置对话框中打开 `🧙♂️ Setup wizard`. 如果之前未配置插件, 则会自动打开该页面.
|
||||
|
||||

|
||||
|
||||
- 放弃现有配置并进行设置
|
||||
如果您先前有过任何设置, 此按钮允许您在设置前放弃所有更改.
|
||||
|
||||
- 保留现有配置和设置
|
||||
快速重新配置. 请注意, 在向导模式下, 您无法看到所有已经配置过的配置项.
|
||||
|
||||
在上述选项中按下 `Next`, 配置对话框将进入向导模式 (wizard mode).
|
||||
|
||||
### 向导模式 (Wizard mode)
|
||||
|
||||

|
||||
|
||||
接下来将介绍如何逐步使用向导模式.
|
||||
|
||||
## 配置远程数据库
|
||||
|
||||
### 开始配置远程数据库
|
||||
|
||||
输入已部署好的数据库的信息.
|
||||
|
||||

|
||||
|
||||
#### 测试数据库连接并检查数据库配置
|
||||
|
||||
我们可以检查数据库的连接性和数据库设置.
|
||||
|
||||

|
||||
|
||||
#### 测试数据库连接
|
||||
|
||||
检查是否能成功连接数据库. 如果连接失败, 可能是多种原因导致的, 但请先点击 `Check database configuration` 来检查数据库配置是否有问题.
|
||||
|
||||
#### 检查数据库配置
|
||||
|
||||
检查数据库设置并修复问题.
|
||||
|
||||

|
||||
|
||||
Config check 的显示内容可能因不同连接而异. 在上图情况下, 按下所有三个修复按钮.
|
||||
如果修复按钮消失, 全部变为复选标记, 则表示修复完成.
|
||||
|
||||
### 加密配置
|
||||
|
||||

|
||||
|
||||
为您的数据库加密, 以防数据库意外曝光; 启用端到端加密后, 笔记内容在离开设备时就会被加密. 我们强烈建议启用该功能. `路径混淆 (Path Obfuscation)` 还能混淆文件名. 现已稳定并推荐使用.
|
||||
加密基于 256 位 AES-GCM.
|
||||
如果你在一个封闭的网络中, 而且很明显第三方不会访问你的文件, 则可以禁用这些设置.
|
||||
|
||||

|
||||
|
||||
#### Next
|
||||
|
||||
转到同步设置.
|
||||
|
||||
#### 放弃现有数据库并继续
|
||||
|
||||
清除远程数据库的内容, 然后转到同步设置.
|
||||
|
||||
### 同步设置
|
||||
|
||||
最后, 选择一个同步预设完成向导.
|
||||
|
||||

|
||||
|
||||
选择我们要使用的任何同步方法, 然后 `Apply` 初始化并按要求建立本地和远程数据库. 如果显示 `All done!`, 我们就完成了. `Copy setup URI` 将自动打开,并要求我们输入密码以加密 `Setup URI`.
|
||||
|
||||

|
||||
|
||||
根据需要设置密码。.
|
||||
设置 URI (Setup URI) 将被复制到剪贴板, 然后您可以通过某种方式将其传输到第二个及后续设备.
|
||||
|
||||
## 如何设置第二单元和后续单元 (the second and subsequent units)
|
||||
|
||||
在第一台设备上安装 Self-hosted LiveSync 后, 从命令面板上选择 `Open setup URI`, 然后输入您传输的设置 URI (Setup URI). 然后输入密码,安装向导就会打开.
|
||||
在弹窗中选择以下内容.
|
||||
|
||||
- `Importing LiveSync's conf, OK?` 选择 `Yes`
|
||||
- `How would you like to set it up?`. 选择 `Set it up as secondary or subsequent device`
|
||||
|
||||
然后, 配置将生效并开始复制. 您的文件很快就会同步! 您可能需要关闭设置对话框并重新打开, 才能看到设置字段正确填充, 但它们都将设置好.
|
||||
88
docs/quick_setup_ja.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Quick setup
|
||||
このプラグインには、いろいろな状況に対応するための非常に多くの設定オプションがあります。しかし、実際に使用する設定項目はそれほど多くはありません。そこで、初期設定を簡略化するために、「セットアップウィザード」を実装しています。
|
||||
※なお、次のデバイスからは、`Copy setup URI`と`Open setup URI`を使ってセットアップしてください。
|
||||
|
||||
|
||||
## Wizardの使い方
|
||||
`🧙♂️ Setup wizard` から開きます。もしセットアップされていなかったり、同期設定が何も有効になっていない場合はデフォルトで開いています。
|
||||
|
||||

|
||||
|
||||
### Discard the existing configuration and set up
|
||||
今設定されている内容をいったん全部消してから、ウィザードを始めます。
|
||||
|
||||
### Do not discard the existing configuration and set up
|
||||
今の設定を消さずにウィザードを開始します。
|
||||
たとえ設定されていたとしても、ウィザードモードではすべての設定を見ることができません。
|
||||
|
||||
いずれかのNextを押すと、設定画面がウィザードモードになります。
|
||||
|
||||
### Wizardモード
|
||||
|
||||

|
||||
|
||||
順番に設定を行っていきます。
|
||||
|
||||
## Remote Database configuration
|
||||
|
||||
### Remote databaseの設定
|
||||
セットアップしたデータベースの情報を入力していきます。
|
||||
|
||||

|
||||
|
||||
これらはデータベースをセットアップした際に決めた情報です。
|
||||
|
||||
### Test database connectionとCheck database configuration
|
||||
ここで、データベースへの接続状況と、データベース設定を確認します。
|
||||

|
||||
|
||||
#### Test Database Connection
|
||||
データベースに接続できるか自体を確認します。失敗する場合はいくつか理由がありますが、一度Check database configurationを行ってそちらでも失敗するか確認してください。
|
||||
|
||||
#### Check database configuration
|
||||
データベースの設定を確認し、不備がある場合はその場で修正します。
|
||||
|
||||

|
||||
|
||||
この項目は接続先によって異なる場合があります。上記の場合、みっつのFixボタンを順にすべて押してください。
|
||||
Fixボタンがなくなり、すべてチェックマークになれば完了です。
|
||||
|
||||
### 機密性設定
|
||||
|
||||

|
||||
|
||||
意図しないデータベースの暴露に備えて、End to End Encryptionを有効にします。この項目を有効にした場合、デバイスを出る瞬間にノートの内容が暗号化されます。`Path Obfuscation`を有効にすると、ファイル名も難読化されます。現在は安定しているため、こちらも推奨されます。
|
||||
暗号化には256bitのAES-GCMを採用しています。
|
||||
これらの設定は、あなたが閉じたネットワークの内側にいて、かつ第三者からアクセスされない事が明確な場合には無効にできます。
|
||||
|
||||
|
||||

|
||||
|
||||
### Next
|
||||
次へ進みます
|
||||
|
||||
### Discard exist database and proceed
|
||||
すでにRemote databaseがある場合、Remote databaseの内容を破棄してから次へ進みます
|
||||
|
||||
|
||||
## Sync Settings
|
||||
最後に同期方法の設定を行います。
|
||||
|
||||

|
||||
|
||||
Presetsから、いずれかの同期方法を選び`Apply`を行うと、必要に応じてローカル・リモートのデータベースを初期化・構築します。
|
||||
All done! と表示されれば完了です。自動的に、`Copy setup URI`が開き、`Setup URI`を暗号化するパスフレーズを聞かれます。
|
||||
|
||||

|
||||
|
||||
お好みのパスフレーズを設定してください。
|
||||
クリップボードにSetup URIが保存されますので、これを2台目以降のデバイスに何らかの方法で転送してください。
|
||||
|
||||
# 2台目以降の設定方法
|
||||
2台目の端末にSelf-hosted LiveSyncをインストールしたあと、コマンドパレットから`Open setup URI`を選択し、転送したsetup URIを入力します。その後、パスフレーズを入力するとセットアップ用のウィザードが開きます。
|
||||
下記のように答えてください。
|
||||
|
||||
- `Importing LiveSync's conf, OK?` に `Yes`
|
||||
- `How would you like to set it up?` に `Set it up as secondary or subsequent device`
|
||||
|
||||
これで設定が反映され、レプリケーションが開始されます。
|
||||
857
docs/settings.md
@@ -1,309 +1,750 @@
|
||||
NOTE: This document surely became outdated. I'll improve this doc in a while. but your contributions are always welcome.
|
||||
NOTE: This document not completed. I'll improve this doc in a while. but your contributions are always welcome.
|
||||
|
||||
# Settings of this plugin
|
||||
# Settings of Self-hosted LiveSync
|
||||
|
||||
The settings dialog has been quite long, so I split each configuration into tabs.
|
||||
If you feel something, please feel free to inform me.
|
||||
There are many settings in Self-hosted LiveSync. This document describes each setting in detail (not how-to). Configuration and settings are divided into several categories and indicated by icons. The icon is as follows:
|
||||
|
||||
| icon | description |
|
||||
| :---: | ----------------------------------------------------------------- |
|
||||
| 🛰️ | [Remote Database Configurations](#remote-database-configurations) |
|
||||
| 📦 | [Local Database Configurations](#local-database-configurations) |
|
||||
| ⚙️ | [General Settings](#general-settings) |
|
||||
| 🔁 | [Sync setting](#sync-setting) |
|
||||
| 🔧 | [Miscellaneous](#miscellaneous) |
|
||||
| 🧰 | [Hatch](#miscellaneous) |
|
||||
| 🔌 | [Plugin and its settings](#plugin-and-its-settings) |
|
||||
| 🚑 | [Corrupted data](#corrupted-data) |
|
||||
| Icon | Description |
|
||||
| :--: | ------------------------------------------------------------------ |
|
||||
| 💬 | [0. Update Information](#0-update-information) |
|
||||
| 🧙♂️ | [1. Setup](#1-setup) |
|
||||
| ⚙️ | [2. General Settings](#2-general-settings) |
|
||||
| 🛰️ | [3. Remote Configuration](#3-remote-configuration) |
|
||||
| 🔄 | [4. Sync Settings](#4-sync-settings) |
|
||||
| 🚦 | [5. Selector (Advanced)](#5-selector-advanced) |
|
||||
| 🔌 | [6. Customization sync (Advanced)](#6-customization-sync-advanced) |
|
||||
| 🧰 | [7. Hatch](#7-hatch) |
|
||||
| 🔧 | [8. Advanced (Advanced)](#8-advanced-advanced) |
|
||||
| 💪 | [9. Power users (Power User)](#9-power-users-power-user) |
|
||||
| 🩹 | [10. Patches (Edge Case)](#10-patches-edge-case) |
|
||||
| 🎛️ | [11. Maintenance](#11-maintenance) |
|
||||
|
||||
## Remote Database Configurations
|
||||
Configure settings of synchronize server. If any synchronization is enabled, you can't edit this section. Please disable all synchronization to change.
|
||||
## 0. Update Information
|
||||
|
||||
### URI
|
||||
URI of CouchDB. In the case of Cloudant, It's "External Endpoint(preferred)".
|
||||
**Do not end it up with a slash** when it doesn't contain the database name.
|
||||
This pane shows version up information. You can check what has been changed in recent versions.
|
||||
|
||||
### Username
|
||||
Your CouchDB's Username. With administrator's privilege is preferred.
|
||||
## 1. Setup
|
||||
|
||||
### Password
|
||||
Your CouchDB's Password.
|
||||
Note: This password is saved into your Obsidian's vault in plain text.
|
||||
This pane is used for setting up Self-hosted LiveSync. There are several options to set up Self-hosted LiveSync.
|
||||
|
||||
### Database Name
|
||||
The Database name to synchronize.
|
||||
⚠️If not exist, created automatically.
|
||||
### 1. Quick Setup
|
||||
|
||||
Most preferred method to setup Self-hosted LiveSync. You can setup Self-hosted LiveSync with a few clicks.
|
||||
|
||||
### End to End Encryption
|
||||
Encrypt your database. It affects only the database, your files are left as plain.
|
||||
#### Use the copied setup URI
|
||||
|
||||
The encryption algorithm is AES-GCM.
|
||||
Setup the Self-hosted LiveSync with the `setup URI` which is [copied from another device](#copy-current-settings-as-a-new-setup-uri) or the setup script.
|
||||
|
||||
Note: If you want to use "Plugins and their settings", you have to enable this.
|
||||
#### Minimal setup
|
||||
|
||||
### Passphrase
|
||||
The passphrase to used as the key of encryption. Please use the long text.
|
||||
Step-by-step setup for Self-hosted LiveSync. You can setup Self-hosted LiveSync manually with Minimal setting items.
|
||||
|
||||
### Apply
|
||||
Set the End to End encryption enabled and its passphrase for use in replication.
|
||||
If you change the passphrase with existen database, overwriting remote database is strongly recommended.
|
||||
#### Enable LiveSync on this device as the setup was completed manually
|
||||
|
||||
This button only appears when the setup was not completed. If you have completed the setup manually, you can enable LiveSync on this device by this button.
|
||||
|
||||
### Overwrite by local DB
|
||||
Overwrite the remote database by the local database using the passphrase you applied.
|
||||
### 2. To setup the other devices
|
||||
|
||||
#### Copy current settings as a new setup URI
|
||||
|
||||
### Rebuild
|
||||
Rebuild remote and local databases with local files. It will delete all document history and retained chunks, and shrink the database.
|
||||
You can copy the current settings as a new setup URI. And this URI can be used to setup the other devices as [Use the copied setup URI](#use-the-copied-setup-uri).
|
||||
|
||||
### Test Database connection
|
||||
You can check the connection by clicking this button.
|
||||
### 3. Reset
|
||||
|
||||
### Check database configuration
|
||||
You can check and modify your CouchDB's configuration from here directly.
|
||||
#### Discard existing settings and databases
|
||||
|
||||
### Lock remote database.
|
||||
Other devices are banned from the database when you have locked the database.
|
||||
If you have something troubled with other devices, you can protect the vault and remote database by your device.
|
||||
Reset the Self-hosted LiveSync settings and databases.
|
||||
**Hazardous operation. Please be careful when using this.**
|
||||
|
||||
## Local Database Configurations
|
||||
"Local Database" is created inside your obsidian.
|
||||
### 4. Enable extra and advanced features
|
||||
|
||||
### Batch database update
|
||||
Delay database update until raise replication, open another file, window visibility changed, or file events except for file modification.
|
||||
This option can not be used with LiveSync at the same time.
|
||||
To keep the set-up dialogue simple, some panes are hidden in default. You can enable them here.
|
||||
|
||||
### Garbage check
|
||||
This plugin saves the file by splitting it into chunks to speed replication up and keep low bandwidth.
|
||||
#### Enable advanced features
|
||||
|
||||
They share the chunk if you use the same paragraph in some notes. And if you change the file, only the paragraph you changed is transferred with metadata of the file. And I know that editing notes are not so straight. Sometimes paragraphs will be back into an old phrase. In these cases, we do not have to transfer the chunk again if the chunk will not be deleted. So all chunks will be reused.
|
||||
Setting key: useAdvancedMode
|
||||
|
||||
As the side effect of this, you can see history the file.
|
||||
Following panes will be shown when you enable this setting.
|
||||
| Icon | Description |
|
||||
| :--: | ------------------------------------------------------------------ |
|
||||
| 🚦 | [5. Selector (Advanced)](#5-selector-advanced) |
|
||||
| 🔌 | [6. Customization sync (Advanced)](#6-customization-sync-advanced) |
|
||||
| 🔧 | [8. Advanced (Advanced)](#8-advanced-advanced) |
|
||||
|
||||
The check will show the number of chunks used or retained. If there are so many retained chunks, you can rebuild the database.
|
||||
#### Enable power user features
|
||||
|
||||
### Fetch rebuilt DB.
|
||||
If one device rebuilds or locks the remote database, every other device will be locked out from the remote database until it fetches rebuilt DB.
|
||||
Setting key: usePowerUserMode
|
||||
|
||||
### minimum chunk size and LongLine threshold
|
||||
The configuration of chunk splitting.
|
||||
Following panes will be shown when you enable this setting.
|
||||
| Icon | Description |
|
||||
| :--: | ------------------------------------------------------------------ |
|
||||
| 💪 | [9. Power users (Power User)](#9-power-users-power-user) |
|
||||
|
||||
Self-hosted LiveSync splits the note into chunks for efficient synchronization. This chunk should be longer than "Minimum chunk size".
|
||||
#### Enable edge case treatment features
|
||||
|
||||
Specifically, the length of the chunk is determined by the following orders.
|
||||
Setting key: useEdgeCaseMode
|
||||
|
||||
1. Find the nearest newline character, and if it is farther than LongLineThreshold, this piece becomes an independent chunk.
|
||||
Following panes will be shown when you enable this setting.
|
||||
| Icon | Description |
|
||||
| :--: | ------------------------------------------------------------------ |
|
||||
| 🩹 | [10. Patches (Edge Case)](#10-patches-edge-case) |
|
||||
|
||||
2. If not, find nearest to these items.
|
||||
1. Newline character
|
||||
2. Empty line (Windows style)
|
||||
3. Empty line (non-Windows style)
|
||||
3. Compare the farther in these 3 positions and next "\[newline\]#" position, pick a shorter piece to as chunk.
|
||||
## 2. General Settings
|
||||
|
||||
This rule was made empirically from my dataset. If this rule acts as badly on your data. Please give me the information.
|
||||
### 1. Appearance
|
||||
|
||||
You can dump saved note structure to `Dump informations of this doc`. Replace every character to x except newline and "#" when sending information to me.
|
||||
#### Display Language
|
||||
|
||||
Default values are 20 letters and 250 letters.
|
||||
Setting key: displayLanguage
|
||||
|
||||
## General Settings
|
||||
You can change the display language. It is independent of the system language and/or Obsidian's language.
|
||||
Note: Not all messages have been translated. And, please revert to "Default" when reporting errors. Of course, your contribution to translation is always welcome!
|
||||
|
||||
### Do not show low-priority log
|
||||
If you enable this option, log only the entries with the popup.
|
||||
#### Show status inside the editor
|
||||
|
||||
### Verbose log
|
||||
Setting key: showStatusOnEditor
|
||||
|
||||
## Sync setting
|
||||
We can show the status of synchronisation inside the editor.
|
||||
|
||||
### LiveSync
|
||||
Do LiveSync.
|
||||
Reflected after reboot
|
||||
|
||||
It is the one of raison d'être of this plugin.
|
||||
#### Show status as icons only
|
||||
|
||||
Useful, but this method drains many batteries on the mobile and uses not the ignorable amount of data transfer.
|
||||
Setting key: showOnlyIconsOnEditor
|
||||
|
||||
This method is exclusive to other synchronization methods.
|
||||
Show status as icons only. This is useful when you want to save space on the status bar.
|
||||
|
||||
### Periodic Sync
|
||||
Synchronize periodically.
|
||||
#### Show status on the status bar
|
||||
|
||||
### Periodic Sync Interval
|
||||
Unit is seconds.
|
||||
Setting key: showStatusOnStatusbar
|
||||
|
||||
### Sync on Save
|
||||
Synchronize when the note has been modified or created.
|
||||
We can show the status of synchronisation on the status bar. (Default: On)
|
||||
|
||||
### Sync on File Open
|
||||
Synchronize when the note is opened.
|
||||
### 2. Logging
|
||||
|
||||
### Sync on Start
|
||||
Synchronize when Obsidian started.
|
||||
#### Show only notifications
|
||||
|
||||
### Use Trash for deleted files
|
||||
When the file has been deleted on remote devices, deletion will be replicated to the local device and the file will be deleted.
|
||||
Setting key: lessInformationInLog
|
||||
|
||||
If this option is enabled, move deleted files into the trash instead delete actually.
|
||||
Prevent logging and show only notification. Please disable when you report the logs
|
||||
|
||||
### Do not delete empty folder
|
||||
Self-hosted LiveSync will delete the folder when the folder becomes empty. If this option is enabled, leave it as an empty folder.
|
||||
#### Verbose Log
|
||||
|
||||
### Use newer file if conflicted (beta)
|
||||
Always use the newer file to resolve and overwrite when conflict has occurred.
|
||||
Setting key: showVerboseLog
|
||||
|
||||
Show verbose log. Please enable when you report the logs
|
||||
|
||||
### Experimental.
|
||||
### Sync hidden files
|
||||
## 3. Remote Configuration
|
||||
|
||||
Synchronize hidden files.
|
||||
### 1. Remote Server
|
||||
|
||||
- Scan hidden files before replication.
|
||||
If you enable this option, all hidden files are scanned once before replication.
|
||||
#### Remote Type
|
||||
|
||||
- Scan hidden files periodicaly.
|
||||
If you enable this option, all hidden files will be scanned each [n] seconds.
|
||||
Setting key: remoteType
|
||||
|
||||
Hidden files are not actively detected, so we need scanning.
|
||||
Remote server type
|
||||
|
||||
Each scan stores the file with their modification time. And if the file has been disappeared, the fact is also stored. Then, When the entry of the hidden file has been replicated, it will be reflected in the storage if the entry is newer than storage.
|
||||
### 2. Notification
|
||||
|
||||
Therefore, the clock must be adjusted. If the modification time is determined to be older, the changeset will be skipped or cancelled (It means, **deleted**), even if the file spawned in a hidden folder.
|
||||
#### Notify when the estimated remote storage size exceeds on start up
|
||||
|
||||
### Advanced settings
|
||||
Self-hosted LiveSync using PouchDB and synchronizes with the remote by [this protocol](https://docs.couchdb.org/en/stable/replication/protocol.html).
|
||||
So, it splits every entry into chunks to be acceptable by the database with limited payload size and document size.
|
||||
Setting key: notifyThresholdOfRemoteStorageSize
|
||||
|
||||
However, it was not enough.
|
||||
According to [2.4.2.5.2. Upload Batch of Changed Documents](https://docs.couchdb.org/en/stable/replication/protocol.html#upload-batch-of-changed-documents) in [Replicate Changes](https://docs.couchdb.org/en/stable/replication/protocol.html#replicate-changes), it might become a bigger request.
|
||||
MB (0 to disable). We can get a notification when the estimated remote storage size exceeds this value.
|
||||
|
||||
Unfortunately, there is no way to deal with this automatically by size for every request.
|
||||
Therefore, I made it possible to configure this.
|
||||
### 3. Confidentiality
|
||||
|
||||
Note: If you set these values lower number, the number of requests will increase.
|
||||
Therefore, if you are far from the server, the total throughput will be low, and the traffic will increase.
|
||||
#### End-to-End Encryption
|
||||
|
||||
### Batch size
|
||||
Number of change feed items to process at a time. Defaults to 250.
|
||||
Setting key: encrypt
|
||||
|
||||
### Batch limit
|
||||
Number of batches to process at a time. Defaults to 40. This along with batch size controls how many docs are kept in memory at a time.
|
||||
Enable end-to-end encryption. enabling this is recommend. If you change the passphrase, you need to rebuild databases (You will be informed).
|
||||
|
||||
## Miscellaneous
|
||||
#### Passphrase
|
||||
|
||||
### Show status inside editor
|
||||
Show information inside the editor pane.
|
||||
It would be useful for mobile.
|
||||
Setting key: passphrase
|
||||
|
||||
### Check integrity on saving
|
||||
Check all chunks are correctly saved on saving.
|
||||
Encrypting passphrase. If you change the passphrase, you need to rebuild databases (You will be informed).
|
||||
|
||||
### Presets
|
||||
You can set synchronization method at once as these pattern:
|
||||
- LiveSync
|
||||
- LiveSync : enabled
|
||||
- Batch database update : disabled
|
||||
- Periodic Sync : disabled
|
||||
- Sync on Save : disabled
|
||||
- Sync on File Open : disabled
|
||||
- Sync on Start : disabled
|
||||
- Periodic w/ batch
|
||||
- LiveSync : disabled
|
||||
- Batch database update : enabled
|
||||
- Periodic Sync : enabled
|
||||
- Sync on Save : disabled
|
||||
- Sync on File Open : enabled
|
||||
- Sync on Start : enabled
|
||||
- Disable all sync
|
||||
- LiveSync : disabled
|
||||
- Batch database update : disabled
|
||||
- Periodic Sync : disabled
|
||||
- Sync on Save : disabled
|
||||
- Sync on File Open : disabled
|
||||
- Sync on Start : disabled
|
||||
#### Path Obfuscation
|
||||
|
||||
Setting key: usePathObfuscation
|
||||
|
||||
## Hatch
|
||||
From here, everything is under the hood. Please handle it with care.
|
||||
In default, the path of the file is not obfuscated to improve the performance. If you enable this, the path of the file will be obfuscated. This is useful when you want to hide the path of the file.
|
||||
|
||||
When there are problems with synchronization, the warning message is shown Under this section header.
|
||||
#### Use dynamic iteration count (Experimental)
|
||||
|
||||
- Pattern 1
|
||||

|
||||
This message is shown when the remote database is locked and your device is not marked as "resolved".
|
||||
Almost it is happened by enabling End-to-End encryption or History has been dropped.
|
||||
If you enabled End-to-End encryption, you can unlock the remote database by "Apply and receive" automatically. Or "Drop and receive" when you dropped. If you want to unlock manually, click "mark this device as resolved".
|
||||
Setting key: useDynamicIterationCount
|
||||
|
||||
- Pattern 2
|
||||

|
||||
The remote database indicates that has been unlocked Pattern 1.
|
||||
When you mark all devices as resolved, you can unlock the database.
|
||||
But, there's no problem even if you leave it as it is.
|
||||
This is an experimental feature and not recommended. If you enable this, the iteration count of the encryption will be dynamically determined. This is useful when you want to improve the performance.
|
||||
|
||||
### Verify and repair all files
|
||||
read all files in the vault, and update them into the database if there's diff or could not read from the database.
|
||||
---
|
||||
|
||||
### Sanity check
|
||||
Make sure that all the files on the local database have all chunks.
|
||||
**now writing from here onwards, sorry**
|
||||
|
||||
### Drop history
|
||||
Drop all histories on the local database and the remote database, and initialize When synchronization time has been prolonged to the new device or new vault, or database size became to be much larger. Try this.
|
||||
---
|
||||
|
||||
Note: When CouchDB deletes entries, to merge confliction, there left old entries as deleted data before compaction. After compaction has been run, deleted data are become "tombstone". "tombstone" uses less disk, But still use some.
|
||||
### 4. Minio,S3,R2
|
||||
|
||||
It's the specification, to shrink database size from the root, re-initialization is required, even it's explicit or implicit.
|
||||
#### Endpoint URL
|
||||
|
||||
Same as a setting passphrase, database locking is also performed.
|
||||
Setting key: endpoint
|
||||
|
||||
#### Access Key
|
||||
|
||||
- Drop and send (Same as "Apply and send")
|
||||
1. Initialize the Local Database and set (or clear) passphrase, put all files into the database again.
|
||||
2. Initialize the Remote Database.
|
||||
3. Lock the Remote Database.
|
||||
4. Send it all.
|
||||
Setting key: accessKey
|
||||
|
||||
- Drop and receive (Same as "Apply and receive")
|
||||
1. Initialize the Local Database and set (or clear) the passphrase.
|
||||
2. Unlock the Remote Database.
|
||||
3. Retrieve all and decrypt to file.
|
||||
#### Secret Key
|
||||
|
||||
Setting key: secretKey
|
||||
|
||||
### Suspend file watching
|
||||
If enable this option, Self-hosted LiveSync dismisses every file change or deletes the event.
|
||||
#### Region
|
||||
|
||||
From here, these commands are used inside applying encryption passphrases or dropping histories.
|
||||
Setting key: region
|
||||
|
||||
Usually, doesn't use it so much. But sometimes it could be handy.
|
||||
#### Bucket Name
|
||||
|
||||
### Reset remote database
|
||||
Discard the data stored in the remote database.
|
||||
Setting key: bucket
|
||||
|
||||
### Reset local database
|
||||
Discard the data stored in the local database.
|
||||
#### Use Custom HTTP Handler
|
||||
|
||||
### Initialize local database again
|
||||
Discard the data stored in the local database and initialize and create the database from the files on storage.
|
||||
Setting key: useCustomRequestHandler
|
||||
If your Object Storage could not configured accepting CORS, enable this.
|
||||
|
||||
## Plugins and settings (beta)
|
||||
#### Test Connection
|
||||
|
||||
### Enable plugin synchronization
|
||||
If you want to use this feature, you have to activate this feature by this switch.
|
||||
#### Apply Settings
|
||||
|
||||
### Sweep plugins automatically
|
||||
Plugin sweep will run before replication automatically.
|
||||
### 5. CouchDB
|
||||
|
||||
### Sweep plugins periodically
|
||||
Plugin sweep will run each 1 minute.
|
||||
#### URI
|
||||
|
||||
### 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.
|
||||
Setting key: couchDB_URI
|
||||
|
||||
### Device and Vault name
|
||||
To save the plugins, you have to set a unique name every each device.
|
||||
#### Username
|
||||
|
||||
### Open
|
||||
Open the "Plugins and their settings" dialog.
|
||||
Setting key: couchDB_USER
|
||||
username
|
||||
|
||||
### Corrupted or missing data
|
||||

|
||||
#### Password
|
||||
|
||||
When Self-hosted LiveSync could not write to the file on the storage, the files are shown here. If you have the old data in your vault, change it once, it will be cured. Or you can use the "File History" plugin.
|
||||
Setting key: couchDB_PASSWORD
|
||||
password
|
||||
|
||||
#### Database name
|
||||
|
||||
Setting key: couchDB_DBNAME
|
||||
|
||||
#### Test Database Connection
|
||||
|
||||
Open database connection. If the remote database is not found and you have the privilege to create a database, the database will be created.
|
||||
|
||||
#### Check and fix database configuration
|
||||
|
||||
Check the database configuration, and fix if there are any problems.
|
||||
|
||||
#### Apply Settings
|
||||
|
||||
## 4. Sync Settings
|
||||
|
||||
### 1. Synchronization Preset
|
||||
|
||||
#### Presets
|
||||
|
||||
Setting key: preset
|
||||
Apply preset configuration
|
||||
|
||||
### 2. Synchronization Methods
|
||||
|
||||
#### Sync Mode
|
||||
|
||||
Setting key: syncMode
|
||||
|
||||
#### Periodic Sync interval
|
||||
|
||||
Setting key: periodicReplicationInterval
|
||||
Interval (sec)
|
||||
|
||||
#### Sync on Save
|
||||
|
||||
Setting key: syncOnSave
|
||||
When you save a file, sync automatically
|
||||
|
||||
#### Sync on Editor Save
|
||||
|
||||
Setting key: syncOnEditorSave
|
||||
When you save a file in the editor, sync automatically
|
||||
|
||||
#### Sync on File Open
|
||||
|
||||
Setting key: syncOnFileOpen
|
||||
When you open a file, sync automatically
|
||||
|
||||
#### Sync on Start
|
||||
|
||||
Setting key: syncOnStart
|
||||
Start synchronization after launching Obsidian.
|
||||
|
||||
#### Sync after merging file
|
||||
|
||||
Setting key: syncAfterMerge
|
||||
Sync automatically after merging files
|
||||
|
||||
### 3. Update thinning
|
||||
|
||||
#### Batch database update
|
||||
|
||||
Setting key: batchSave
|
||||
Reducing the frequency with which on-disk changes are reflected into the DB
|
||||
|
||||
#### Minimum delay for batch database updating
|
||||
|
||||
Setting key: batchSaveMinimumDelay
|
||||
Seconds. Saving to the local database will be delayed until this value after we stop typing or saving.
|
||||
|
||||
#### Maximum delay for batch database updating
|
||||
|
||||
Setting key: batchSaveMaximumDelay
|
||||
Saving will be performed forcefully after this number of seconds.
|
||||
|
||||
### 4. Deletion Propagation (Advanced)
|
||||
|
||||
#### Use the trash bin
|
||||
|
||||
Setting key: trashInsteadDelete
|
||||
Do not delete files that are deleted in remote, just move to trash.
|
||||
|
||||
#### Keep empty folder
|
||||
|
||||
Setting key: doNotDeleteFolder
|
||||
Normally, a folder is deleted when it becomes empty after a synchronization. Enabling this will prevent it from getting deleted
|
||||
|
||||
### 5. Conflict resolution (Advanced)
|
||||
|
||||
#### Always overwrite with a newer file (beta)
|
||||
|
||||
Setting key: resolveConflictsByNewerFile
|
||||
(Def off) Resolve conflicts by newer files automatically.
|
||||
|
||||
#### Postpone resolution of inactive files
|
||||
|
||||
Setting key: checkConflictOnlyOnOpen
|
||||
|
||||
#### Postpone manual resolution of inactive files
|
||||
|
||||
Setting key: showMergeDialogOnlyOnActive
|
||||
|
||||
### 6. Sync settings via markdown (Advanced)
|
||||
|
||||
#### Filename
|
||||
|
||||
Setting key: settingSyncFile
|
||||
If you set this, all settings are saved in a markdown file. You will be notified when new settings arrive. You can set different files by the platform.
|
||||
|
||||
#### Write credentials in the file
|
||||
|
||||
Setting key: writeCredentialsForSettingSync
|
||||
(Not recommended) If set, credentials will be stored in the file.
|
||||
|
||||
#### Notify all setting files
|
||||
|
||||
Setting key: notifyAllSettingSyncFile
|
||||
|
||||
### 7. Hidden files (Advanced)
|
||||
|
||||
#### Hidden file synchronization
|
||||
|
||||
#### Enable Hidden files sync
|
||||
|
||||
#### Scan for hidden files before replication
|
||||
|
||||
Setting key: syncInternalFilesBeforeReplication
|
||||
|
||||
#### Scan hidden files periodically
|
||||
|
||||
Setting key: syncInternalFilesInterval
|
||||
Seconds, 0 to disable
|
||||
|
||||
## 5. Selector (Advanced)
|
||||
|
||||
### 1. Normal Files
|
||||
|
||||
#### Synchronising files
|
||||
|
||||
(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.
|
||||
|
||||
#### Non-Synchronising files
|
||||
|
||||
(RegExp) If this is set, any changes to local and remote files that match this will be skipped.
|
||||
|
||||
#### Maximum file size
|
||||
|
||||
Setting key: syncMaxSizeInMB
|
||||
(MB) If this is set, changes to local and remote files that are larger than this will be skipped. If the file becomes smaller again, a newer one will be used.
|
||||
|
||||
#### (Beta) Use ignore files
|
||||
|
||||
Setting key: useIgnoreFiles
|
||||
If this is set, changes to local files which are matched by the ignore files will be skipped. Remote changes are determined using local ignore files.
|
||||
|
||||
#### Ignore files
|
||||
|
||||
Setting key: ignoreFiles
|
||||
We can use multiple ignore files, e.g.) `.gitignore, .dockerignore`
|
||||
|
||||
### 2. Hidden Files (Advanced)
|
||||
|
||||
#### Ignore patterns
|
||||
|
||||
#### Add default patterns
|
||||
|
||||
## 6. Customization sync (Advanced)
|
||||
|
||||
### 1. Customization Sync
|
||||
|
||||
#### Device name
|
||||
|
||||
Setting key: deviceAndVaultName
|
||||
Unique name between all synchronized devices. To edit this setting, please disable customization sync once.
|
||||
|
||||
#### Per-file-saved customization sync
|
||||
|
||||
Setting key: usePluginSyncV2
|
||||
If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.
|
||||
|
||||
#### Enable customization sync
|
||||
|
||||
Setting key: usePluginSync
|
||||
|
||||
#### Scan customization automatically
|
||||
|
||||
Setting key: autoSweepPlugins
|
||||
Scan customization before replicating.
|
||||
|
||||
#### Scan customization periodically
|
||||
|
||||
Setting key: autoSweepPluginsPeriodic
|
||||
Scan customization every 1 minute.
|
||||
|
||||
#### Notify customized
|
||||
|
||||
Setting key: notifyPluginOrSettingUpdated
|
||||
Notify when other device has newly customized.
|
||||
|
||||
#### Open
|
||||
|
||||
Open the dialog
|
||||
|
||||
## 7. Hatch
|
||||
|
||||
### 1. Reporting Issue
|
||||
|
||||
#### Make report to inform the issue
|
||||
|
||||
#### Write logs into the file
|
||||
|
||||
Setting key: writeLogToTheFile
|
||||
Warning! This will have a serious impact on performance. And the logs will not be synchronised under the default name. Please be careful with logs; they often contain your confidential information.
|
||||
|
||||
### 2. Scram Switches
|
||||
|
||||
#### Suspend file watching
|
||||
|
||||
Setting key: suspendFileWatching
|
||||
Stop watching for file change.
|
||||
|
||||
#### Suspend database reflecting
|
||||
|
||||
Setting key: suspendParseReplicationResult
|
||||
Stop reflecting database changes to storage files.
|
||||
|
||||
### 3. Recovery and Repair
|
||||
|
||||
#### Recreate missing chunks for all files
|
||||
|
||||
This will recreate chunks for all files. If there were missing chunks, this may fix the errors.
|
||||
|
||||
#### Verify and repair all files
|
||||
|
||||
Compare the content of files between on local database and storage. If not matched, you will be asked which one you want to keep.
|
||||
|
||||
#### Check and convert non-path-obfuscated files
|
||||
|
||||
### 4. Reset
|
||||
|
||||
#### Back to non-configured
|
||||
|
||||
#### Delete all customization sync data
|
||||
|
||||
## 8. Advanced (Advanced)
|
||||
|
||||
### 1. Memory cache
|
||||
|
||||
#### Memory cache size (by total items)
|
||||
|
||||
Setting key: hashCacheMaxCount
|
||||
|
||||
#### Memory cache size (by total characters)
|
||||
|
||||
Setting key: hashCacheMaxAmount
|
||||
(Mega chars)
|
||||
|
||||
### 2. Local Database Tweak
|
||||
|
||||
#### Enhance chunk size
|
||||
|
||||
Setting key: customChunkSize
|
||||
|
||||
#### Use splitting-limit-capped chunk splitter
|
||||
|
||||
Setting key: enableChunkSplitterV2
|
||||
If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker.
|
||||
|
||||
#### Use Segmented-splitter
|
||||
|
||||
Setting key: useSegmenter
|
||||
If this enabled, chunks will be split into semantically meaningful segments. Not all platforms support this feature.
|
||||
|
||||
### 3. Transfer Tweak
|
||||
|
||||
#### Fetch chunks on demand
|
||||
|
||||
Setting key: readChunksOnline
|
||||
(ex. Read chunks online) If this option is enabled, LiveSync reads chunks online directly instead of replicating them locally. Increasing Custom chunk size is recommended.
|
||||
|
||||
#### Batch size of on-demand fetching
|
||||
|
||||
Setting key: concurrencyOfReadChunksOnline
|
||||
|
||||
#### The delay for consecutive on-demand fetches
|
||||
|
||||
Setting key: minimumIntervalOfReadChunksOnline
|
||||
|
||||
#### Send chunks in bulk
|
||||
|
||||
Setting key: sendChunksBulk
|
||||
If this enabled, all chunks will be sent in bulk. This is useful for the environment that has a high latency.
|
||||
|
||||
#### Maximum size of chunks to send in one request
|
||||
|
||||
Setting key: sendChunksBulkMaxSize
|
||||
MB
|
||||
|
||||
## 9. Power users (Power User)
|
||||
|
||||
### 1. Remote Database Tweak
|
||||
|
||||
#### Incubate Chunks in Document (Beta)
|
||||
|
||||
Setting key: useEden
|
||||
If enabled, newly created chunks are temporarily kept within the document, and graduated to become independent chunks once stabilised.
|
||||
|
||||
#### Maximum Incubating Chunks
|
||||
|
||||
Setting key: maxChunksInEden
|
||||
The maximum number of chunks that can be incubated within the document. Chunks exceeding this number will immediately graduate to independent chunks.
|
||||
|
||||
#### Maximum Incubating Chunk Size
|
||||
|
||||
Setting key: maxTotalLengthInEden
|
||||
The maximum total size of chunks that can be incubated within the document. Chunks exceeding this size will immediately graduate to independent chunks.
|
||||
|
||||
#### Maximum Incubation Period
|
||||
|
||||
Setting key: maxAgeInEden
|
||||
The maximum duration for which chunks can be incubated within the document. Chunks exceeding this period will graduate to independent chunks.
|
||||
|
||||
#### Data Compression (Experimental)
|
||||
|
||||
Setting key: enableCompression
|
||||
|
||||
### 2. CouchDB Connection Tweak
|
||||
|
||||
#### Batch size
|
||||
|
||||
Setting key: batch_size
|
||||
Number of change feed items to process at a time. Defaults to 50. Minimum is 2.
|
||||
|
||||
#### Batch limit
|
||||
|
||||
Setting key: batches_limit
|
||||
Number of batches to process at a time. Defaults to 40. Minimum is 2. This along with batch size controls how many docs are kept in memory at a time.
|
||||
|
||||
#### Use timeouts instead of heartbeats
|
||||
|
||||
Setting key: useTimeouts
|
||||
If this option is enabled, PouchDB will hold the connection open for 60 seconds, and if no change arrives in that time, close and reopen the socket, instead of holding it open indefinitely. Useful when a proxy limits request duration but can increase resource usage.
|
||||
|
||||
### 3. Configuration Encryption
|
||||
|
||||
#### Encrypting sensitive configuration items
|
||||
|
||||
Setting key: configPassphraseStore
|
||||
|
||||
#### Passphrase of sensitive configuration items
|
||||
|
||||
Setting key: configPassphrase
|
||||
This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.
|
||||
|
||||
## 10. Patches (Edge Case)
|
||||
|
||||
### 1. Compatibility (Metadata)
|
||||
|
||||
#### Do not keep metadata of deleted files.
|
||||
|
||||
Setting key: deleteMetadataOfDeletedFiles
|
||||
|
||||
#### Delete old metadata of deleted files on start-up
|
||||
|
||||
Setting key: automaticallyDeleteMetadataOfDeletedFiles
|
||||
(Days passed, 0 to disable automatic-deletion)
|
||||
|
||||
### 2. Compatibility (Conflict Behaviour)
|
||||
|
||||
#### Always resolve conflicts manually
|
||||
|
||||
Setting key: disableMarkdownAutoMerge
|
||||
If this switch is turned on, a merge dialog will be displayed, even if the sensible-merge is possible automatically. (Turn on to previous behavior)
|
||||
|
||||
#### Always reflect synchronized changes even if the note has a conflict
|
||||
|
||||
Setting key: writeDocumentsIfConflicted
|
||||
Turn on to previous behavior
|
||||
|
||||
### 3. Compatibility (Database structure)
|
||||
|
||||
#### (Obsolete) Use an old adapter for compatibility (obsolete)
|
||||
|
||||
Setting key: useIndexedDBAdapter
|
||||
Before v0.17.16, we used an old adapter for the local database. Now the new adapter is preferred. However, it needs local database rebuilding. Please disable this toggle when you have enough time. If leave it enabled, also while fetching from the remote database, you will be asked to disable this.
|
||||
|
||||
#### Compute revisions for chunks (Previous behaviour)
|
||||
|
||||
Setting key: doNotUseFixedRevisionForChunks
|
||||
If this enabled, all chunks will be stored with the revision made from its content. (Previous behaviour)
|
||||
|
||||
#### Handle files as Case-Sensitive
|
||||
|
||||
Setting key: handleFilenameCaseSensitive
|
||||
If this enabled, All files are handled as case-Sensitive (Previous behaviour).
|
||||
|
||||
### 4. Compatibility (Internal API Usage)
|
||||
|
||||
#### Scan changes on customization sync
|
||||
|
||||
Setting key: watchInternalFileChanges
|
||||
Do not use internal API
|
||||
|
||||
### 5. Edge case addressing (Database)
|
||||
|
||||
#### Database suffix
|
||||
|
||||
Setting key: additionalSuffixOfDatabaseName
|
||||
LiveSync could not handle multiple vaults which have same name without different prefix, This should be automatically configured.
|
||||
|
||||
#### The Hash algorithm for chunk IDs (Experimental)
|
||||
|
||||
Setting key: hashAlg
|
||||
|
||||
### 6. Edge case addressing (Behaviour)
|
||||
|
||||
#### Fetch database with previous behaviour
|
||||
|
||||
Setting key: doNotSuspendOnFetching
|
||||
|
||||
#### Keep empty folder
|
||||
|
||||
Setting key: doNotDeleteFolder
|
||||
Normally, a folder is deleted when it becomes empty after a synchronization. Enabling this will prevent it from getting deleted
|
||||
|
||||
### 7. Edge case addressing (Processing)
|
||||
|
||||
#### Do not split chunks in the background
|
||||
|
||||
Setting key: disableWorkerForGeneratingChunks
|
||||
If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).
|
||||
|
||||
#### Process small files in the foreground
|
||||
|
||||
Setting key: processSmallFilesInUIThread
|
||||
If enabled, the file under 1kb will be processed in the UI thread.
|
||||
|
||||
### 8. Compatibility (Trouble addressed)
|
||||
|
||||
#### Do not check configuration mismatch before replication
|
||||
|
||||
Setting key: disableCheckingConfigMismatch
|
||||
|
||||
## 11. Maintenance
|
||||
|
||||
### 1. Scram!
|
||||
|
||||
#### Lock remote
|
||||
|
||||
Lock remote to prevent synchronization with other devices.
|
||||
|
||||
#### Emergency restart
|
||||
|
||||
place the flag file to prevent all operation and restart.
|
||||
|
||||
### 2. Data-complementary Operations
|
||||
|
||||
#### Resend
|
||||
|
||||
Resend all chunks to the remote.
|
||||
|
||||
#### Reset journal received history
|
||||
|
||||
Initialise journal received history. On the next sync, every item except this device sent will be downloaded again.
|
||||
|
||||
#### Reset journal sent history
|
||||
|
||||
Initialise journal sent history. On the next sync, every item except this device received will be sent again.
|
||||
|
||||
### 3. Rebuilding Operations (Local)
|
||||
|
||||
#### Fetch from remote
|
||||
|
||||
Restore or reconstruct local database from remote.
|
||||
|
||||
#### Fetch rebuilt DB (Save local documents before)
|
||||
|
||||
Restore or reconstruct local database from remote database but use local chunks.
|
||||
|
||||
### 4. Total Overhaul
|
||||
|
||||
#### Rebuild everything
|
||||
|
||||
Rebuild local and remote database with local files.
|
||||
|
||||
### 5. Rebuilding Operations (Remote Only)
|
||||
|
||||
#### Perform compaction
|
||||
|
||||
Compaction discards all of Eden in the non-latest revisions, reducing the storage usage. However, this operation requires the same free space on the remote as the current database.
|
||||
|
||||
#### Overwrite remote
|
||||
|
||||
Overwrite remote with local DB and passphrase.
|
||||
|
||||
#### Reset all journal counter
|
||||
|
||||
Initialise all journal history, On the next sync, every item will be received and sent.
|
||||
|
||||
#### Purge all journal counter
|
||||
|
||||
Purge all sending and downloading cache.
|
||||
|
||||
#### Make empty the bucket
|
||||
|
||||
Delete all data on the remote.
|
||||
|
||||
### 6. Niches
|
||||
|
||||
#### (Obsolete) Clean up databases
|
||||
|
||||
Delete unused chunks to shrink the database. However, this feature could be not effective in some cases. Please use rebuild everything instead.
|
||||
|
||||
### 7. Reset
|
||||
|
||||
#### Discard local database to reset or uninstall Self-hosted LiveSync
|
||||
|
||||
@@ -70,22 +70,6 @@ End to End 暗号化を行うに当たって、異なるパスフレーズで暗
|
||||
- ファイルの修正以外のファイル関連イベント
|
||||
このオプションはLiveSyncと同時には使用できません。
|
||||
|
||||
### Auto Garbage Collection delay
|
||||
Self-hosted LiveSyncはノートの変更時、ノートをmarkdownの構造を鑑みてチャンクに分割し、ファイルの情報と更新があったチャンクのみ保存していきます。この際、古いチャンクの削除は行いません。
|
||||
そのため、使わなくなったチャンクをどこかのタイミングで消去する必要があります。
|
||||
ただし、このチャンクはチャンクの内容から作成されるため、同一の内容からは同一のチャンクが作成され、同じノートだけではなく、すべてのノートから共有されます。これによってデータベースの使用容量とデバイス‐サーバー間での転送量を削減しています。
|
||||
執筆を繰り返す上で、元の文書に戻したりすることもあるため、一概に「すぐに不要になる」とは言い切れません。そこで、プラグインはObsidianを開いたまま操作しなくなってからこの設定値秒後、まとめて使用していないチャンクを削除します。
|
||||
この処理をGarbage Collectionと呼んでいます。
|
||||
この作業はすべてのファイル変更の反映を停止して一気に行う必要があります。そのため、一時的にObsidianの動作がかなり重くなります。
|
||||
|
||||
|
||||
Obsidianでのファイル操作が終わってから指定秒数が経過した際に実行されます。
|
||||
デフォルト値は300秒です。
|
||||
※ごく初期は30秒でした。初期から使用されている方は、是非300秒ぐらいまで伸ばしてください。ストレスが違います。
|
||||
|
||||
### Manual Garbage Collect
|
||||
上記のGarbage Collectionを手動で行います。
|
||||
|
||||
### minimum chunk size と LongLine threshold
|
||||
チャンクの分割についての設定です。
|
||||
Self-hosted LiveSyncは一つのチャンクのサイズを最低minimum chunk size文字確保した上で、できるだけ効率的に同期できるよう、ノートを分割してチャンクを作成します。
|
||||
@@ -223,16 +207,6 @@ Self-hosted LiveSyncはPouchDBを使用し、リモートと[このプロトコ
|
||||
### Verify and repair all files
|
||||
Vault内のファイルを全て読み込み直し、もし差分があったり、データベースから正常に読み込めなかったものに関して、データベースに反映します。
|
||||
|
||||
### Sanity check
|
||||
ローカルデータベースに保存されている全てのファイルが正しくチャンクを持っていることを確認します。
|
||||
|
||||
### Drop history
|
||||
データベースに記録されている履歴を削除し、データベースを初期化します。
|
||||
新しい端末や新しいVaultへの同期にやたらと時間がかかったり、データベースサイズが肥大化したりしてきた際に使用してください。
|
||||
備考:CouchDBは、データを削除する際、衝突の解決のために、削除した痕跡を保存します。そのため、Garbage Collectしていたとしても、データは必ず増え続けます。
|
||||
パスフレーズ設定と同様に、完全に同期されているのであれば、データを失う可能性は低いです。
|
||||
また、同様にデータベースのロック等の処理も行われます。
|
||||
|
||||
- Drop and send
|
||||
デバイスとリモートのデータベースを破棄し、ロックしてからデバイスのファイルでデータベースを構築後、リモートに上書きします。
|
||||
- Drop and receive
|
||||
@@ -246,20 +220,6 @@ Vault内のファイルを全て読み込み直し、もし差分があったり
|
||||
### Suspend file watching
|
||||
ファイルの更新の監視を止めます。
|
||||
|
||||
|
||||
これ以降の操作は、暗号化設定のApplyや、Drop Historyで行われる処理を手動で行うためのオプションです。
|
||||
|
||||
あまり使用することはありませんがいざというときに使用します。
|
||||
|
||||
### reset remote database
|
||||
リモートのデータベースを破棄します。
|
||||
|
||||
### reset local database
|
||||
ローカルのデータベースを破棄します。
|
||||
|
||||
### initialize local database again
|
||||
デバイスのデータベースを破棄し、実ファイルから再度データベースを構築します。
|
||||
|
||||
### Corrupted data
|
||||

|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ In these instructions, create IBM Cloudant Instance for trial.
|
||||
follow the figure, it's
|
||||
"apikey-v2-2unu15184f7o8emr90xlqgkm2ncwhbltml6tgnjl9sd5"<sup>(\*3)</sup> and "c2c11651d75497fa3d3c486e4c8bdf27"<sup>(\*4)</sup>
|
||||
|
||||
## Self-hosted LiveSync setting
|
||||
## Self-hosted LiveSync settings
|
||||
|
||||

|
||||
|
||||
|
||||
252
docs/setup_flyio.md
Normal file
@@ -0,0 +1,252 @@
|
||||
<!-- For translation: 20240209r0 -->
|
||||
# Setup CouchDB on fly.io
|
||||
|
||||
This is how to configure fly.io and CouchDB on it for Self-hosted LiveSync.
|
||||
|
||||
> [!WARNING]
|
||||
> It is **your** instance. In Obsidian, we have files locally. Hence, do not hesitate to destroy the remote database if you feel something have got weird. We can launch and switch to the new CouchDB instance anytime[^1].
|
||||
>
|
||||
[^1]: Actually, I am always building the database for reproduction of the issue like so.
|
||||
|
||||
> [!NOTE]
|
||||
> **What and why is the Fly.io?**
|
||||
> At some point, we started to experience problems related to our IBM Cloudant account. At the same time, Self-hosted LiveSync started to improve its functionality, requiring CouchDB in a more natural state to use all its features.
|
||||
>
|
||||
> Then we found Fly.io. Fly.io is the PaaS Platform, which can be useable for a very reasonable price. It generally falls within the `Free Allowances` range in most cases.
|
||||
|
||||
## Required materials
|
||||
|
||||
- A valid credit or debit card.
|
||||
|
||||
## Setup CouchDB instance
|
||||
|
||||
### A. Very automated setup
|
||||
|
||||
[](https://www.youtube.com/watch?v=7sa_I1832Xc)
|
||||
|
||||
1. Open [setup-flyio-on-the-fly-v2.ipynb](../setup-flyio-on-the-fly-v2.ipynb).
|
||||
2. Press the `Open in Colab` button.
|
||||
3. Choose a region and run all blocks (Refer to video).
|
||||
1. If you do not have the account yet, the sign-up page will be shown, please follow the instructions. The [Official document is here](https://fly.io/docs/hands-on/sign-up/).
|
||||
4. Copy the Setup-URI and Use it in the Obsidian.
|
||||
5. You have been synchronised. Use the Setup-URI in subsequent devices.
|
||||
|
||||
Steps 4 and 5 are detailed in the [Quick Setup](./quick_setup.md#1-using-setup-uris).
|
||||
|
||||
> [!NOTE]
|
||||
> Your automatically configured configurations will be shown on the result in the Colab note like below, and **it will not be saved**. Please make a note of it somewhere.
|
||||
> ```
|
||||
> -- YOUR CONFIGURATION --
|
||||
> URL : https://billowing-dawn-6619.fly.dev
|
||||
> username: billowing-cherry-22580
|
||||
> password: misty-dew-13571
|
||||
> region : nrt
|
||||
> ```
|
||||
|
||||
### B. Scripted Setup
|
||||
|
||||
Please refer to the document of [deploy-server.sh](../utils/readme.md#deploy-serversh).
|
||||
|
||||
### C. Manual Setup
|
||||
|
||||
| Used in the text | Meaning and where to use | Memo |
|
||||
| ---------------- | --------------------------- | ------------------------------------------------------------------------ |
|
||||
| campanella | Username | It is less likely to fail if it consists only of letters and numbers. |
|
||||
| dfusiuada9suy | Password | |
|
||||
| nrt | Region to make the instance | We can use any [region](https://fly.io/docs/reference/regions/) near us. |
|
||||
|
||||
#### 1. Install flyctl
|
||||
|
||||
- Mac or Linux
|
||||
|
||||
```sh
|
||||
$ curl -L https://fly.io/install.sh | sh
|
||||
```
|
||||
|
||||
- Windows
|
||||
|
||||
```powershell
|
||||
$ iwr https://fly.io/install.ps1 -useb | iex
|
||||
```
|
||||
|
||||
#### 2. Sign up or Sign in to fly.io
|
||||
|
||||
- Sign up
|
||||
|
||||
```bash
|
||||
$ fly auth signup
|
||||
```
|
||||
|
||||
- Sign in
|
||||
|
||||
```bash
|
||||
$ fly auth login
|
||||
```
|
||||
|
||||
For more information, please refer to [Sign up](https://fly.io/docs/hands-on/sign-up/) and [Sign in](https://fly.io/docs/hands-on/sign-in/).
|
||||
|
||||
#### 3. Make a configuration file
|
||||
|
||||
1. Make `fly.toml` from template `fly.template.toml`.
|
||||
We can simply copy and rename the file. The template is on [utils/flyio/fly.template.toml](../utils/flyio/fly.template.toml)
|
||||
2. Decide the instance name, initialize the App, and set credentials.
|
||||
|
||||
>[!TIP]
|
||||
> - The name `billowing-dawn-6619` is randomly decided name, and it will be a part of the CouchDB URL. It should be globally unique. Therefore, it is recommended to use something random for this name.
|
||||
> - Explicit naming is very good for humans. However, we do not often get the chance to actually enter this manually (have designed so). This database may contain important information for you. The needle should be hidden in the haystack.
|
||||
|
||||
|
||||
```bash
|
||||
$ fly launch --name=billowing-dawn-6619 --env="COUCHDB_USER=campanella" --copy-config=true --detach --no-deploy --region nrt --yes
|
||||
$ fly secrets set COUCHDB_PASSWORD=dfusiuada9suy
|
||||
```
|
||||
|
||||
#### 4. Deploy
|
||||
|
||||
```
|
||||
$ flyctl deploy
|
||||
An existing fly.toml file was found
|
||||
Using build strategies '[the "couchdb:latest" docker image]'. Remove [build] from fly.toml to force a rescan
|
||||
Creating app in /home/vorotamoroz/dev/obsidian-livesync/utils/flyio
|
||||
We're about to launch your app on Fly.io. Here's what you're getting:
|
||||
|
||||
Organization: vorotamoroz (fly launch defaults to the personal org)
|
||||
Name: billowing-dawn-6619 (specified on the command line)
|
||||
Region: Tokyo, Japan (specified on the command line)
|
||||
App Machines: shared-cpu-1x, 256MB RAM (specified on the command line)
|
||||
Postgres: <none> (not requested)
|
||||
Redis: <none> (not requested)
|
||||
|
||||
Created app 'billowing-dawn-6619' in organization 'personal'
|
||||
Admin URL: https://fly.io/apps/billowing-dawn-6619
|
||||
Hostname: billowing-dawn-6619.fly.dev
|
||||
Wrote config file fly.toml
|
||||
Validating /home/vorotamoroz/dev/obsidian-livesync/utils/flyio/fly.toml
|
||||
Platform: machines
|
||||
✓ Configuration is valid
|
||||
Your app is ready! Deploy with `flyctl deploy`
|
||||
Secrets are staged for the first deployment
|
||||
==> Verifying app config
|
||||
Validating /home/vorotamoroz/dev/obsidian-livesync/utils/flyio/fly.toml
|
||||
Platform: machines
|
||||
✓ Configuration is valid
|
||||
--> Verified app config
|
||||
==> Building image
|
||||
Searching for image 'couchdb:latest' remotely...
|
||||
image found: img_ox20prk63084j1zq
|
||||
|
||||
Watch your deployment at https://fly.io/apps/billowing-dawn-6619/monitoring
|
||||
|
||||
Provisioning ips for billowing-dawn-6619
|
||||
Dedicated ipv6: 2a09:8280:1::37:fde9
|
||||
Shared ipv4: 66.241.124.163
|
||||
Add a dedicated ipv4 with: fly ips allocate-v4
|
||||
|
||||
Creating a 1 GB volume named 'couchdata' for process group 'app'. Use 'fly vol extend' to increase its size
|
||||
This deployment will:
|
||||
* create 1 "app" machine
|
||||
|
||||
No machines in group app, launching a new machine
|
||||
|
||||
WARNING The app is not listening on the expected address and will not be reachable by fly-proxy.
|
||||
You can fix this by configuring your app to listen on the following addresses:
|
||||
- 0.0.0.0:5984
|
||||
Found these processes inside the machine with open listening sockets:
|
||||
PROCESS | ADDRESSES
|
||||
-----------------*---------------------------------------
|
||||
/.fly/hallpass | [fdaa:0:73b9:a7b:22e:3851:7f28:2]:22
|
||||
|
||||
Finished launching new machines
|
||||
|
||||
NOTE: The machines for [app] have services with 'auto_stop_machines = true' that will be stopped when idling
|
||||
|
||||
-------
|
||||
Checking DNS configuration for billowing-dawn-6619.fly.dev
|
||||
|
||||
Visit your newly deployed app at https://billowing-dawn-6619.fly.dev/
|
||||
```
|
||||
|
||||
#### 5. Apply CouchDB configuration
|
||||
|
||||
After the initial setup, CouchDB needs some more customisations to be used from Self-hosted LiveSync. It can be configured in browsers or by HTTP-REST APIs.
|
||||
|
||||
This section is set up using the REST API.
|
||||
|
||||
1. Prepare environment variables.
|
||||
|
||||
- Mac or Linux:
|
||||
|
||||
```bash
|
||||
export couchHost=https://billowing-dawn-6619.fly.dev
|
||||
export couchUser=campanella
|
||||
export couchPwd=dfusiuada9suy
|
||||
```
|
||||
|
||||
- Windows
|
||||
|
||||
```powershell
|
||||
set couchHost https://billowing-dawn-6619.fly.dev
|
||||
set couchUser campanella
|
||||
set couchPwd dfusiuada9suy
|
||||
$creds = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("${couchUser}:${couchPwd}"))
|
||||
```
|
||||
|
||||
2. Perform cluster setup
|
||||
|
||||
- Mac or Linux
|
||||
|
||||
```bash
|
||||
curl -X POST "${couchHost}/_cluster_setup" -H "Content-Type: application/json" -d "{\"action\":\"enable_single_node\",\"username\":\"${couchUser}\",\"password\":\"${couchPwd}\",\"bind_address\":\"0.0.0.0\",\"port\":5984,\"singlenode\":true}" --user "${couchUser}:${couchPwd}"
|
||||
```
|
||||
|
||||
- Windows
|
||||
|
||||
```powershell
|
||||
iwr -UseBasicParsing -Method 'POST' -ContentType 'application/json; charset=utf-8' -Headers @{ 'Authorization' = 'Basic ' + $creds } "${couchHost}/_cluster_setup" -Body "{""action"":""enable_single_node"",""username"":""${couchUser}"",""password"":""${couchPwd}"",""bind_address"":""0.0.0.0"",""port"":5984,""singlenode"":true}"
|
||||
```
|
||||
|
||||
Note: if the response code is not 200. We have to retry the request once again.
|
||||
If you run the request several times and it does not result in 200, something is wrong. Please report it.
|
||||
|
||||
3. Configure parameters
|
||||
|
||||
- Mac or Linux
|
||||
|
||||
```bash
|
||||
curl -X PUT "${couchHost}/_node/nonode@nohost/_config/chttpd/require_valid_user" -H "Content-Type: application/json" -d '"true"' --user "${couchUser}:${couchPwd}"
|
||||
curl -X PUT "${couchHost}/_node/nonode@nohost/_config/chttpd_auth/require_valid_user" -H "Content-Type: application/json" -d '"true"' --user "${couchUser}:${couchPwd}"
|
||||
curl -X PUT "${couchHost}/_node/nonode@nohost/_config/httpd/WWW-Authenticate" -H "Content-Type: application/json" -d '"Basic realm=\"couchdb\""' --user "${couchUser}:${couchPwd}"
|
||||
curl -X PUT "${couchHost}/_node/nonode@nohost/_config/httpd/enable_cors" -H "Content-Type: application/json" -d '"true"' --user "${couchUser}:${couchPwd}"
|
||||
curl -X PUT "${couchHost}/_node/nonode@nohost/_config/chttpd/enable_cors" -H "Content-Type: application/json" -d '"true"' --user "${couchUser}:${couchPwd}"
|
||||
curl -X PUT "${couchHost}/_node/nonode@nohost/_config/chttpd/max_http_request_size" -H "Content-Type: application/json" -d '"4294967296"' --user "${couchUser}:${couchPwd}"
|
||||
curl -X PUT "${couchHost}/_node/nonode@nohost/_config/couchdb/max_document_size" -H "Content-Type: application/json" -d '"50000000"' --user "${couchUser}:${couchPwd}"
|
||||
curl -X PUT "${couchHost}/_node/nonode@nohost/_config/cors/credentials" -H "Content-Type: application/json" -d '"true"' --user "${couchUser}:${couchPwd}"
|
||||
curl -X PUT "${couchHost}/_node/nonode@nohost/_config/cors/origins" -H "Content-Type: application/json" -d '"app://obsidian.md,capacitor://localhost,http://localhost"' --user "${couchUser}:${couchPwd}"
|
||||
```
|
||||
|
||||
- Windows
|
||||
|
||||
```powershell
|
||||
iwr -UseBasicParsing -Method 'PUT' -ContentType 'application/json; charset=utf-8' -Headers @{ 'Authorization' = 'Basic ' + $creds } "${couchHost}/_node/nonode@nohost/_config/chttpd/require_valid_user" -Body '"true"'
|
||||
iwr -UseBasicParsing -Method 'PUT' -ContentType 'application/json; charset=utf-8' -Headers @{ 'Authorization' = 'Basic ' + $creds } "${couchHost}/_node/nonode@nohost/_config/chttpd_auth/require_valid_user" -Body '"true"'
|
||||
iwr -UseBasicParsing -Method 'PUT' -ContentType 'application/json; charset=utf-8' -Headers @{ 'Authorization' = 'Basic ' + $creds } "${couchHost}/_node/nonode@nohost/_config/httpd/WWW-Authenticate" -Body '"Basic realm=\"couchdb\""'
|
||||
iwr -UseBasicParsing -Method 'PUT' -ContentType 'application/json; charset=utf-8' -Headers @{ 'Authorization' = 'Basic ' + $creds } "${couchHost}/_node/nonode@nohost/_config/httpd/enable_cors" -Body '"true"'
|
||||
iwr -UseBasicParsing -Method 'PUT' -ContentType 'application/json; charset=utf-8' -Headers @{ 'Authorization' = 'Basic ' + $creds } "${couchHost}/_node/nonode@nohost/_config/chttpd/enable_cors" -Body '"true"'
|
||||
iwr -UseBasicParsing -Method 'PUT' -ContentType 'application/json; charset=utf-8' -Headers @{ 'Authorization' = 'Basic ' + $creds } "${couchHost}/_node/nonode@nohost/_config/chttpd/max_http_request_size" -Body '"4294967296"'
|
||||
iwr -UseBasicParsing -Method 'PUT' -ContentType 'application/json; charset=utf-8' -Headers @{ 'Authorization' = 'Basic ' + $creds } "${couchHost}/_node/nonode@nohost/_config/couchdb/max_document_size" -Body '"50000000"'
|
||||
iwr -UseBasicParsing -Method 'PUT' -ContentType 'application/json; charset=utf-8' -Headers @{ 'Authorization' = 'Basic ' + $creds } "${couchHost}/_node/nonode@nohost/_config/cors/credentials" -Body '"true"'
|
||||
iwr -UseBasicParsing -Method 'PUT' -ContentType 'application/json; charset=utf-8' -Headers @{ 'Authorization' = 'Basic ' + $creds } "${couchHost}/_node/nonode@nohost/_config/cors/origins" -Body '"app://obsidian.md,capacitor://localhost,http://localhost"'
|
||||
```
|
||||
|
||||
Note: Each of these should also be repeated until finished in 200.
|
||||
|
||||
#### 6. Use it from Self-hosted LiveSync
|
||||
|
||||
Now the CouchDB is ready to use from Self-hosted LiveSync. We can use `https://billowing-dawn-6619.fly.dev` in URI, `campanella` in `Username` and `dfusiuada9suy` in `Password` on Self-hosted LiveSync. The `Database name` could be anything you want.
|
||||
Please refer to the [Minimal Setup of the Quick Setup](./quick_setup.md#2-minimal-setup).
|
||||
|
||||
## Delete the Instance
|
||||
|
||||
If you want to delete the CouchDB instance, you can do that in [fly.io Dashboard](https://fly.io/dashboard/personal)
|
||||
|
||||
If you have done with [B. Scripted Setup](#b-scripted-setup), we can use [delete-server.sh](../utils/readme.md#delete-serversh).
|
||||
@@ -1,88 +1,133 @@
|
||||
# Setup CouchDB to your server
|
||||
# Setup a CouchDB server
|
||||
|
||||
## Table of Contents
|
||||
|
||||
## Install CouchDB and access from PC or Mac
|
||||
- [Setup a CouchDB server](#setup-a-couchdb-server)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [1. Prepare CouchDB](#1-prepare-couchdb)
|
||||
- [A. Using Docker container](#a-using-docker-container)
|
||||
- [1. Prepare](#1-prepare)
|
||||
- [2. Run docker container](#2-run-docker-container)
|
||||
- [B. Install CouchDB directly](#b-install-couchdb-directly)
|
||||
- [2. Run couchdb-init.sh for initialise](#2-run-couchdb-initsh-for-initialise)
|
||||
- [3. Expose CouchDB to the Internet](#3-expose-couchdb-to-the-internet)
|
||||
- [4. Client Setup](#4-client-setup)
|
||||
- [1. Generate the setup URI on a desktop device or server](#1-generate-the-setup-uri-on-a-desktop-device-or-server)
|
||||
- [2. Setup Self-hosted LiveSync to Obsidian](#2-setup-self-hosted-livesync-to-obsidian)
|
||||
- [Manual setup information](#manual-setup-information)
|
||||
- [Setting up your domain](#setting-up-your-domain)
|
||||
- [Reverse Proxies](#reverse-proxies)
|
||||
- [Traefik](#traefik)
|
||||
---
|
||||
|
||||
The easiest way to set up the CouchDB is using the [docker image]((https://hub.docker.com/_/couchdb)).
|
||||
## 1. Prepare CouchDB
|
||||
### A. Using Docker container
|
||||
|
||||
But some additional configurations are required in `local.ini` to use from Self-hosted LiveSync, like below:
|
||||
#### 1. Prepare
|
||||
```bash
|
||||
|
||||
```
|
||||
[couchdb]
|
||||
single_node=true
|
||||
# Prepare environment variables.
|
||||
export hostname=localhost:5984
|
||||
export username=goojdasjdas #Please change as you like.
|
||||
export password=kpkdasdosakpdsa #Please change as you like
|
||||
|
||||
[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
|
||||
# Prepare directories which saving data and configurations.
|
||||
mkdir couchdb-data
|
||||
mkdir couchdb-etc
|
||||
```
|
||||
|
||||
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.
|
||||
#### 2. Run docker container
|
||||
|
||||
If you could confirm that Self-hosted LiveSync can sync with the server, launch docker image as background as you like.
|
||||
|
||||
example)
|
||||
1. Boot Check.
|
||||
```
|
||||
$ 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
|
||||
$ docker run --name couchdb-for-ols --rm -it -e COUCHDB_USER=${username} -e COUCHDB_PASSWORD=${password} -v ${PWD}/couchdb-data:/opt/couchdb/data -v ${PWD}/couchdb-etc:/opt/couchdb/etc/local.d -p 5984:5984 couchdb
|
||||
```
|
||||
If your container has been exited, please check the permission of couchdb-data, and couchdb-etc.
|
||||
Once CouchDB run, these directories will be owned by uid:`5984`. Please chown it for you again.
|
||||
|
||||
2. Enable it in background
|
||||
```
|
||||
$ docker run --name couchdb-for-ols -d --restart always -e COUCHDB_USER=${username} -e COUCHDB_PASSWORD=${password} -v ${PWD}/couchdb-data:/opt/couchdb/data -v ${PWD}/couchdb-etc:/opt/couchdb/etc/local.d -p 5984:5984 couchdb
|
||||
```
|
||||
### B. Install CouchDB directly
|
||||
Please refer the [official document](https://docs.couchdb.org/en/stable/install/index.html). However, we do not have to configure it fully. Just administrator needs to be configured.
|
||||
|
||||
## 2. Run couchdb-init.sh for initialise
|
||||
```
|
||||
$ curl -s https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/couchdb-init.sh | bash
|
||||
```
|
||||
|
||||
## 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)
|
||||
If it results like following:
|
||||
```
|
||||
$ 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.
|
||||
-- Configuring CouchDB by REST APIs... -->
|
||||
{"ok":true}
|
||||
""
|
||||
""
|
||||
""
|
||||
""
|
||||
""
|
||||
""
|
||||
""
|
||||
""
|
||||
""
|
||||
<-- Configuring CouchDB by REST APIs Done!
|
||||
```
|
||||
|
||||
https://xxxxxxxx.localhost.run is the temporary server address.
|
||||
Your CouchDB has been initialised successfully. If you want this manually, please read the script.
|
||||
|
||||
## 3. Expose CouchDB to the Internet
|
||||
|
||||
- You can skip this instruction if you using only in intranet and only with desktop devices.
|
||||
- For mobile devices, Obsidian requires a valid SSL certificate. Usually, it needs exposing the internet.
|
||||
|
||||
Whatever solutions we can use. For the simplicity, following sample uses Cloudflare Zero Trust for testing.
|
||||
|
||||
```
|
||||
$ cloudflared tunnel --url http://localhost:5984
|
||||
2024-02-14T10:35:25Z INF Thank you for trying Cloudflare Tunnel. Doing so, without a Cloudflare account, is a quick way to experiment and try it out. However, be aware that these account-less Tunnels have no uptime guarantee. If you intend to use Tunnels in production you should use a pre-created named tunnel by following: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps
|
||||
2024-02-14T10:35:25Z INF Requesting new quick Tunnel on trycloudflare.com...
|
||||
2024-02-14T10:35:26Z INF +--------------------------------------------------------------------------------------------+
|
||||
2024-02-14T10:35:26Z INF | Your quick Tunnel has been created! Visit it at (it may take some time to be reachable): |
|
||||
2024-02-14T10:35:26Z INF | https://tiles-photograph-routine-groundwater.trycloudflare.com |
|
||||
2024-02-14T10:35:26Z INF +--------------------------------------------------------------------------------------------+
|
||||
:
|
||||
:
|
||||
:
|
||||
```
|
||||
Now `https://tiles-photograph-routine-groundwater.trycloudflare.com` is our server. Make it into background once please.
|
||||
|
||||
|
||||
## 4. Client Setup
|
||||
> [!TIP]
|
||||
> Now manually configuration is not recommended for some reasons. However, if you want to do so, please use `Setup wizard`. The recommended extra configurations will be also set.
|
||||
|
||||
### 1. Generate the setup URI on a desktop device or server
|
||||
```bash
|
||||
$ export hostname=https://tiles-photograph-routine-groundwater.trycloudflare.com #Point to your vault
|
||||
$ export database=obsidiannotes #Please change as you like
|
||||
$ export passphrase=dfsapkdjaskdjasdas #Please change as you like
|
||||
$ export username=johndoe
|
||||
$ export password=abc123
|
||||
$ deno run -A https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/flyio/generate_setupuri.ts
|
||||
obsidian://setuplivesync?settings=%5B%22tm2DpsOE74nJAryprZO2M93wF%2Fvg.......4b26ed33230729%22%5D
|
||||
|
||||
Your passphrase of Setup-URI is: patient-haze
|
||||
This passphrase is never shown again, so please note it in a safe place.
|
||||
```
|
||||
|
||||
Please keep your passphrase of Setup-URI.
|
||||
|
||||
### 2. Setup Self-hosted LiveSync to Obsidian
|
||||
[This video](https://youtu.be/7sa_I1832Xc?t=146) may help us.
|
||||
1. Install Self-hosted LiveSync
|
||||
2. Choose `Use the copied setup URI` from the command palette and paste the setup URI. (obsidian://setuplivesync?settings=.....).
|
||||
3. Type the previously displayed passphrase (`patient-haze`) for setup-uri passphrase.
|
||||
4. Answer `yes` and `Set it up...`, and finish the first dialogue with `Keep them disabled`.
|
||||
5. `Reload app without save` once.
|
||||
|
||||
---
|
||||
|
||||
## Manual setup information
|
||||
|
||||
### Setting up your domain
|
||||
|
||||
@@ -90,6 +135,77 @@ Set the A record of your domain to point to your server, and host reverse proxy
|
||||
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.
|
||||
I have published [docker-compose.yml and ini files](https://github.com/vrtmrz/self-hosted-livesync-server) that launch Caddy and CouchDB at once. If you are using Traefik you can check the [Reverse Proxies](#reverse-proxies) section below.
|
||||
|
||||
And, be sure to check the server log and be careful of malicious access.
|
||||
And, be sure to check the server log and be careful of malicious access.
|
||||
|
||||
|
||||
## Reverse Proxies
|
||||
|
||||
### Traefik
|
||||
|
||||
If you are using Traefik, this [docker-compose.yml](https://github.com/vrtmrz/obsidian-livesync/blob/main/docker-compose.traefik.yml) file (also pasted below) has all the right CORS parameters set. It assumes you have an external network called `proxy`.
|
||||
|
||||
```yaml
|
||||
version: "2.1"
|
||||
services:
|
||||
couchdb:
|
||||
image: couchdb:latest
|
||||
container_name: obsidian-livesync
|
||||
user: 1000:1000
|
||||
environment:
|
||||
- COUCHDB_USER=username
|
||||
- COUCHDB_PASSWORD=password
|
||||
volumes:
|
||||
- ./data:/opt/couchdb/data
|
||||
- ./local.ini:/opt/couchdb/etc/local.ini
|
||||
# Ports not needed when already passed to Traefik
|
||||
#ports:
|
||||
# - 5984:5984
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- proxy
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# The Traefik Network
|
||||
- "traefik.docker.network=proxy"
|
||||
# Don't forget to replace 'obsidian-livesync.example.org' with your own domain
|
||||
- "traefik.http.routers.obsidian-livesync.rule=Host(`obsidian-livesync.example.org`)"
|
||||
# The 'websecure' entryPoint is basically your HTTPS entrypoint. Check the next code snippet if you are encountering problems only; you probably have a working traefik configuration if this is not your first container you are reverse proxying.
|
||||
- "traefik.http.routers.obsidian-livesync.entrypoints=websecure"
|
||||
- "traefik.http.routers.obsidian-livesync.service=obsidian-livesync"
|
||||
- "traefik.http.services.obsidian-livesync.loadbalancer.server.port=5984"
|
||||
- "traefik.http.routers.obsidian-livesync.tls=true"
|
||||
# Replace the string 'letsencrypt' with your own certificate resolver
|
||||
- "traefik.http.routers.obsidian-livesync.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.obsidian-livesync.middlewares=obsidiancors"
|
||||
# The part needed for CORS to work on Traefik 2.x starts here
|
||||
- "traefik.http.middlewares.obsidiancors.headers.accesscontrolallowmethods=GET,PUT,POST,HEAD,DELETE"
|
||||
- "traefik.http.middlewares.obsidiancors.headers.accesscontrolallowheaders=accept,authorization,content-type,origin,referer"
|
||||
- "traefik.http.middlewares.obsidiancors.headers.accesscontrolalloworiginlist=app://obsidian.md,capacitor://localhost,http://localhost"
|
||||
- "traefik.http.middlewares.obsidiancors.headers.accesscontrolmaxage=3600"
|
||||
- "traefik.http.middlewares.obsidiancors.headers.addvaryheader=true"
|
||||
- "traefik.http.middlewares.obsidiancors.headers.accessControlAllowCredentials=true"
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
```
|
||||
|
||||
Partial `traefik.yml` config file mentioned in above:
|
||||
```yml
|
||||
...
|
||||
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":80"
|
||||
http:
|
||||
redirections:
|
||||
entryPoint:
|
||||
to: "websecure"
|
||||
scheme: "https"
|
||||
websecure:
|
||||
address: ":443"
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
153
docs/setup_own_server_cn.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# 在你自己的服务器上设置 CouchDB
|
||||
|
||||
## 目录
|
||||
- [配置 CouchDB](#配置-CouchDB)
|
||||
- [运行 CouchDB](#运行-CouchDB)
|
||||
- [Docker CLI](#docker-cli)
|
||||
- [Docker Compose](#docker-compose)
|
||||
- [创建数据库](#创建数据库)
|
||||
- [从移动设备访问](#从移动设备访问)
|
||||
- [移动设备测试](#移动设备测试)
|
||||
- [设置你的域名](#设置你的域名)
|
||||
---
|
||||
|
||||
> 注:提供了 [docker-compose.yml 和 ini 文件](https://github.com/vrtmrz/self-hosted-livesync-server) 可以同时启动 Caddy 和 CouchDB。推荐直接使用该 docker-compose 配置进行搭建。(若使用,请查阅链接中的文档,而不是这个文档)
|
||||
|
||||
## 配置 CouchDB
|
||||
|
||||
设置 CouchDB 的最简单方法是使用 [CouchDB docker image]((https://hub.docker.com/_/couchdb)).
|
||||
|
||||
需要修改一些 `local.ini` 中的配置,以让它可以用于 Self-hosted LiveSync,如下:
|
||||
|
||||
```
|
||||
[couchdb]
|
||||
single_node=true
|
||||
max_document_size = 50000000
|
||||
|
||||
[chttpd]
|
||||
require_valid_user = true
|
||||
max_http_request_size = 4294967296
|
||||
|
||||
[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
|
||||
```
|
||||
|
||||
## 运行 CouchDB
|
||||
|
||||
### Docker CLI
|
||||
|
||||
你可以通过指定 `local.ini` 配置运行 CouchDB:
|
||||
|
||||
```
|
||||
$ docker run --rm -it -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password -v /path/to/local.ini:/opt/couchdb/etc/local.ini -p 5984:5984 couchdb
|
||||
```
|
||||
*记得将上述命令中的 local.ini 挂载路径替换成实际的存放路径*
|
||||
|
||||
后台运行:
|
||||
```
|
||||
$ docker run -d --restart always -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password -v /path/to/local.ini:/opt/couchdb/etc/local.ini -p 5984:5984 couchdb
|
||||
```
|
||||
*记得将上述命令中的 local.ini 挂载路径替换成实际的存放路径*
|
||||
|
||||
### Docker Compose
|
||||
创建一个文件夹, 将你的 `local.ini` 放在文件夹内, 然后在文件夹内创建 `docker-compose.yml`. 请确保对 `local.ini` 有读写权限并且确保在容器运行后能创建 `data` 文件夹. 文件夹结构大概如下:
|
||||
```
|
||||
obsidian-livesync
|
||||
├── docker-compose.yml
|
||||
└── local.ini
|
||||
```
|
||||
|
||||
可以参照以下内容编辑 `docker-compose.yml`:
|
||||
```yaml
|
||||
version: "2.1"
|
||||
services:
|
||||
couchdb:
|
||||
image: couchdb
|
||||
container_name: obsidian-livesync
|
||||
user: 1000:1000
|
||||
environment:
|
||||
- COUCHDB_USER=admin
|
||||
- COUCHDB_PASSWORD=password
|
||||
volumes:
|
||||
- ./data:/opt/couchdb/data
|
||||
- ./local.ini:/opt/couchdb/etc/local.ini
|
||||
ports:
|
||||
- 5984:5984
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
最后, 创建并启动容器:
|
||||
```
|
||||
# -d will launch detached so the container runs in background
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## 创建数据库
|
||||
|
||||
CouchDB 部署成功后, 需要手动创建一个数据库, 方便插件连接并同步.
|
||||
|
||||
1. 访问 `http://localhost:5984/_utils`, 输入帐号密码后进入管理页面
|
||||
2. 点击 Create Database, 然后根据个人喜好创建数据库
|
||||
|
||||
## 从移动设备访问
|
||||
如果你想要从移动设备访问 Self-hosted LiveSync,你需要一个合法的 SSL 证书。
|
||||
|
||||
### 移动设备测试
|
||||
测试时,[localhost.run](http://localhost.run/) 这一类的反向隧道服务很实用。(非必须,只是用于终端设备不方便 ssh 的时候的备选方案)
|
||||
|
||||
```
|
||||
$ 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 即为临时服务器地址。
|
||||
|
||||
### 设置你的域名
|
||||
|
||||
设置一个指向你服务器的 A 记录,并根据需要设置反向代理。
|
||||
|
||||
Note: 不推荐将 CouchDB 挂载到根目录
|
||||
可以使用 Caddy 很方便的给服务器加上 SSL 功能
|
||||
|
||||
提供了 [docker-compose.yml 和 ini 文件](https://github.com/vrtmrz/self-hosted-livesync-server) 可以同时启动 Caddy 和 CouchDB。
|
||||
|
||||
注意检查服务器日志,当心恶意访问。
|
||||
@@ -8,9 +8,11 @@ CouchDBを構築するには、[Dockerのイメージ](https://hub.docker.com/_/
|
||||
```
|
||||
[couchdb]
|
||||
single_node=true
|
||||
max_document_size = 50000000
|
||||
|
||||
[chttpd]
|
||||
require_valid_user = true
|
||||
max_http_request_size = 4294967296
|
||||
|
||||
[chttpd_auth]
|
||||
require_valid_user = true
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
3. Another device is watching remote CouchDB's changes, so retrieve new changes.
|
||||
4. Self-hosted LiveSync reflects replicated changeset into Obsidian's vault.
|
||||
|
||||
Note: The figure is drawn as single-directional, between two devices. But everything occurs bi-directionally between many devices at once in real.
|
||||
Note: The figure is drawn as single-directional, between two devices for demonstration purposes. Everything actually occurs bi-directionally between many devices at the same time.
|
||||
|
||||
## Techniques to keep bandwidth low.
|
||||
## Techniques to keep bandwidth consumption low.
|
||||
|
||||

|
||||
10
docs/terms.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Terms used in this project
|
||||
|
||||
## Terms
|
||||
|
||||
### Chunks
|
||||
<!-- TBW, sorry for the draft! -->
|
||||
|
||||
|
||||
<!-- Please feel free to write any terms that should be mentioned. And please make pull request. I would love to fill the rest. -->
|
||||
<!-- ### Chunks -->
|
||||
148
docs/troubleshooting.md
Normal file
@@ -0,0 +1,148 @@
|
||||
<!-- 2024-02-15 -->
|
||||
# Tips and Troubleshooting
|
||||
|
||||
|
||||
- [Tips and Troubleshooting](#tips-and-troubleshooting)
|
||||
- [Notable bugs and fixes](#notable-bugs-and-fixes)
|
||||
- [Binary files get bigger on iOS](#binary-files-get-bigger-on-ios)
|
||||
- [Some setting name has been changed](#some-setting-name-has-been-changed)
|
||||
- [FAQ](#faq)
|
||||
- [Why `Use an old adapter for compatibility` is somehow enabled in my vault?](#why-use-an-old-adapter-for-compatibility-is-somehow-enabled-in-my-vault)
|
||||
- [ZIP (or any extensions) files were not synchronised. Why?](#zip-or-any-extensions-files-were-not-synchronised-why)
|
||||
- [I hope to report the issue, but you said you needs `Report`. How to make it?](#i-hope-to-report-the-issue-but-you-said-you-needs-report-how-to-make-it)
|
||||
- [Where can I check the log?](#where-can-i-check-the-log)
|
||||
- [Why are the logs volatile and ephemeral?](#why-are-the-logs-volatile-and-ephemeral)
|
||||
- [Some network logs are not written into the file.](#some-network-logs-are-not-written-into-the-file)
|
||||
- [If a file were deleted or trimmed, the capacity of the database should be reduced, right?](#if-a-file-were-deleted-or-trimmed-the-capacity-of-the-database-should-be-reduced-right)
|
||||
- [How can I use the DevTools?](#how-can-i-use-the-devtools)
|
||||
- [Checking the network log](#checking-the-network-log)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [On the mobile device, cannot synchronise on the local network!](#on-the-mobile-device-cannot-synchronise-on-the-local-network)
|
||||
- [I think that something bad happening on the vault...](#i-think-that-something-bad-happening-on-the-vault)
|
||||
- [Tips](#tips)
|
||||
- [How to resolve `Tweaks Mismatched of Changed`](#how-to-resolve-tweaks-mismatched-of-changed)
|
||||
- [Old tips](#old-tips)
|
||||
|
||||
<!-- - -->
|
||||
|
||||
|
||||
## Notable bugs and fixes
|
||||
### Binary files get bigger on iOS
|
||||
- Reported at: v0.20.x
|
||||
- Fixed at: v0.21.2 (Fixed but not reviewed)
|
||||
- Required action: larger files will not be fixed automatically, please perform `Verify and repair all files`. If our local database and storage are not matched, we will be asked to apply which one.
|
||||
|
||||
### Some setting name has been changed
|
||||
- Fixed at: v0.22.6
|
||||
|
||||
| Previous name | New name |
|
||||
| ---------------------------- | ---------------------------------------- |
|
||||
| Open setup URI | Use the copied setup URI |
|
||||
| Copy setup URI | Copy current settings as a new setup URI |
|
||||
| Setup Wizard | Minimal Setup |
|
||||
| Check database configuration | Check and Fix database configuration |
|
||||
|
||||
## FAQ
|
||||
|
||||
### Why `Use an old adapter for compatibility` is somehow enabled in my vault?
|
||||
|
||||
Because you are a compassionate and experienced user. Before v0.17.16, we used an old adapter for the local database. At that time, current default adapter has not been stable.
|
||||
The new adapter has better performance and has a new feature like purging. Therefore, we should use new adapters and current default is so.
|
||||
|
||||
However, when switching from an old adapter to a new adapter, some converting or local database rebuilding is required, and it takes a few time. It was a long time ago now, but we once inconvenienced everyone in a hurry when we changed the format of our database.
|
||||
For these reasons, this toggle is automatically on if we have upgraded from vault which using an old adapter.
|
||||
|
||||
When you rebuild everything or fetch from the remote again, you will be asked to switch this.
|
||||
|
||||
Therefore, experienced users (especially those stable enough not to have to rebuild the database) may have this toggle enabled in their Vault.
|
||||
Please disable it when you have enough time.
|
||||
|
||||
### ZIP (or any extensions) files were not synchronised. Why?
|
||||
It depends on Obsidian detects. May toggling `Detect all extensions` of `File and links` (setting of Obsidian) will help us.
|
||||
|
||||
### I hope to report the issue, but you said you needs `Report`. How to make it?
|
||||
We can copy the report to the clipboard, by pressing the `Make report` button on the `Hatch` pane.
|
||||

|
||||
|
||||
### Where can I check the log?
|
||||
We can launch the log pane by `Show log` on the command palette.
|
||||
And if you have troubled something, please enable the `Verbose Log` on the `General Setting` pane.
|
||||
|
||||
However, the logs would not be kept so long and cleared when restarted. If you want to check the logs, please enable `Write logs into the file` temporarily.
|
||||
|
||||

|
||||
|
||||
> [!IMPORTANT]
|
||||
> - Writing logs into the file will impact the performance.
|
||||
> - Please make sure that you have erased all your confidential information before reporting issue.
|
||||
|
||||
### Why are the logs volatile and ephemeral?
|
||||
To avoid unexpected exposure to our confidential things.
|
||||
|
||||
### Some network logs are not written into the file.
|
||||
Especially the CORS error will be reported as a general error to the plug-in for security reasons. So we cannot detect and log it. We are only able to investigate them by [Checking the network log](#checking-the-network-log).
|
||||
|
||||
### If a file were deleted or trimmed, the capacity of the database should be reduced, right?
|
||||
No, even though if files were deleted, chunks were not deleted.
|
||||
Self-hosted LiveSync splits the files into multiple chunks and transfers only newly created. This behaviour enables us to less traffic. And, the chunks will be shared between the files to reduce the total usage of the database.
|
||||
|
||||
And one more thing, we can handle the conflicts on any device even though it has happened on other devices. This means that conflicts will happen in the past, after the time we have synchronised. Hence we cannot collect and delete the unused chunks even though if we are not currently referenced.
|
||||
|
||||
To shrink the database size, `Rebuild everything` only reliably and effectively. But do not worry, if we have synchronised well. We have the actual and real files. Only it takes a bit of time and traffics.
|
||||
|
||||
### How can I use the DevTools?
|
||||
|
||||
#### Checking the network log
|
||||
1. Open the network pane.
|
||||
2. Find the requests marked in red.
|
||||

|
||||
3. Capture the `Headers`, `Payload`, and, `Response`. **Please be sure to keep important information confidential**. If the `Response` contains secrets, you can omitted that.
|
||||
Note: Headers contains a some credentials. **The path of the request URL, Remote Address, authority, and authorization must be concealed.**
|
||||

|
||||
|
||||
## Troubleshooting
|
||||
<!-- Add here -->
|
||||
|
||||
### On the mobile device, cannot synchronise on the local network!
|
||||
Obsidian mobile is not able to connect to the non-secure end-point, such as starting with `http://`. Make sure your URI of CouchDB. Also not able to use a self-signed certificate.
|
||||
|
||||
### I think that something bad happening on the vault...
|
||||
Place `redflag.md` on top of the vault, and restart Obsidian. The most simple way is to create a new note and rename it to `redflag`. Of course, we can put it without Obsidian.
|
||||
|
||||
If there is `redflag.md`, Self-hosted LiveSync suspends all database and storage processes.
|
||||
|
||||
## Tips
|
||||
|
||||
### How to resolve `Tweaks Mismatched of Changed`
|
||||
|
||||
(Since v0.23.17)
|
||||
|
||||
If you have changed some configurations or tweaks which should be unified between the devices, you will be asked how to reflect (or not) other devices at the next synchronisation. It also occurs on the device itself, where changes are made, to prevent unexpected configuration changes from unwanted propagation.
|
||||
(We may thank this behaviour if we have synchronised or backed up and restored Self-hosted LiveSync. At least, for me so).
|
||||
|
||||
Following dialogue will be shown:
|
||||

|
||||
|
||||
- If we want to propagate the setting of the device, we should choose `Update with mine`.
|
||||
- On other devices, we should choose `Use configured` to accept and use the configured configuration.
|
||||
- `Dismiss` can postpone a decision. However, we cannot synchronise until we have decided.
|
||||
|
||||
Rest assured that in most cases we can choose `Use configured`. (Unless you are certain that you have not changed the configuration).
|
||||
|
||||
If we see it for the first time, it reflects the settings of the device that has been synchronised with the remote for the first time since the upgrade. Probably, we can accept that.
|
||||
|
||||
<!-- Add here -->
|
||||
|
||||
|
||||
### Old tips
|
||||
- Rarely, a file in the database could be corrupted. The plugin will not write to local storage when a file looks corrupted. If a local version of the file is on your device, the corruption could be fixed by editing the local file and synchronizing it. But if the file does not exist on any of your devices, then it can not be rescued. In this case, you can delete these items from the settings dialog.
|
||||
- To stop the boot-up sequence (eg. for fixing problems on databases), you can put a `redflag.md` file (or directory) at the root of your vault.
|
||||
Tip for iOS: a redflag directory can be created at the root of the vault using the File application.
|
||||
- Also, with `redflag2.md` placed, we can automatically rebuild both the local and the remote databases during the boot-up sequence. With `redflag3.md`, we can discard only the local database and fetch from the remote again.
|
||||
- Q: The database is growing, how can I shrink it down?
|
||||
A: each of the docs is saved with their past 100 revisions for detecting and resolving conflicts. Picturing that one device has been offline for a while, and comes online again. The device has to compare its notes with the remotely saved ones. If there exists a historic revision in which the note used to be identical, it could be updated safely (like git fast-forward). Even if that is not in revision histories, we only have to check the differences after the revision that both devices commonly have. This is like git's conflict-resolving method. So, We have to make the database again like an enlarged git repo if you want to solve the root of the problem.
|
||||
- And more technical Information is in the [Technical Information](tech_info.md)
|
||||
- If you want to synchronize files without obsidian, you can use [filesystem-livesync](https://github.com/vrtmrz/filesystem-livesync).
|
||||
- WebClipper is also available on Chrome Web Store:[obsidian-livesync-webclip](https://chrome.google.com/webstore/detail/obsidian-livesync-webclip/jfpaflmpckblieefkegjncjoceapakdf)
|
||||
|
||||
Repo is here: [obsidian-livesync-webclip](https://github.com/vrtmrz/obsidian-livesync-webclip). (Docs are a work in progress.)
|
||||
BIN
docs/tweak_mismatch_dialogue.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
@@ -1,42 +1,97 @@
|
||||
//@ts-check
|
||||
|
||||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
import sveltePlugin from "esbuild-svelte";
|
||||
import sveltePreprocess from "svelte-preprocess";
|
||||
import fs from "node:fs";
|
||||
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
|
||||
*/
|
||||
`;
|
||||
// import terser from "terser";
|
||||
import { minify } from "terser";
|
||||
import inlineWorkerPlugin from "esbuild-plugin-inline-worker";
|
||||
import { terserOption } from "./terser.config.mjs";
|
||||
|
||||
const prod = process.argv[2] === "production";
|
||||
const manifestJson = JSON.parse(fs.readFileSync("./manifest.json"));
|
||||
const packageJson = JSON.parse(fs.readFileSync("./package.json"));
|
||||
esbuild
|
||||
.build({
|
||||
banner: {
|
||||
js: banner,
|
||||
const keepTest = true; //!prod;
|
||||
|
||||
const manifestJson = JSON.parse(fs.readFileSync("./manifest.json") + "");
|
||||
const packageJson = JSON.parse(fs.readFileSync("./package.json") + "");
|
||||
const updateInfo = JSON.stringify(fs.readFileSync("./updates.md") + "");
|
||||
|
||||
/** @type esbuild.Plugin[] */
|
||||
const plugins = [
|
||||
{
|
||||
name: "my-plugin",
|
||||
setup(build) {
|
||||
let count = 0;
|
||||
build.onEnd(async (result) => {
|
||||
if (count++ === 0) {
|
||||
console.log("first build:", result);
|
||||
} else {
|
||||
console.log("subsequent build:");
|
||||
}
|
||||
if (prod) {
|
||||
console.log("Performing terser");
|
||||
const src = fs.readFileSync("./main_org.js").toString();
|
||||
// @ts-ignore
|
||||
const ret = await minify(src, terserOption);
|
||||
if (ret && ret.code) {
|
||||
fs.writeFileSync("./main.js", ret.code);
|
||||
}
|
||||
console.log("Finished terser");
|
||||
} else {
|
||||
fs.copyFileSync("./main_org.js", "./main.js");
|
||||
}
|
||||
});
|
||||
},
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
define: {
|
||||
"MANIFEST_VERSION": `"${manifestJson.version}"`,
|
||||
"PACKAGE_VERSION": `"${packageJson.version}"`,
|
||||
},
|
||||
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));
|
||||
},
|
||||
];
|
||||
|
||||
const externals = ["obsidian", "electron", "crypto", "@codemirror/autocomplete", "@codemirror/collab", "@codemirror/commands", "@codemirror/language", "@codemirror/lint", "@codemirror/search", "@codemirror/state", "@codemirror/view", "@lezer/common", "@lezer/highlight", "@lezer/lr"];
|
||||
const context = await esbuild.context({
|
||||
banner: {
|
||||
js: "// Leave it all to terser",
|
||||
},
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
define: {
|
||||
MANIFEST_VERSION: `"${manifestJson.version}"`,
|
||||
PACKAGE_VERSION: `"${packageJson.version}"`,
|
||||
UPDATE_INFO: `${updateInfo}`,
|
||||
global: "window",
|
||||
},
|
||||
external: externals,
|
||||
// minifyWhitespace: true,
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
platform: "browser",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: false,
|
||||
outfile: "main_org.js",
|
||||
mainFields: ["browser", "module", "main"],
|
||||
minifyWhitespace: false,
|
||||
minifySyntax: false,
|
||||
minifyIdentifiers: false,
|
||||
minify: false,
|
||||
dropLabels: prod && !keepTest ? ["TEST", "DEV"] : [],
|
||||
// keepNames: true,
|
||||
plugins: [
|
||||
inlineWorkerPlugin({
|
||||
external: externals,
|
||||
treeShaking: true,
|
||||
}),
|
||||
sveltePlugin({
|
||||
preprocess: sveltePreprocess(),
|
||||
compilerOptions: { css: "injected", preserveComments: false },
|
||||
}),
|
||||
...plugins,
|
||||
],
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
|
||||
BIN
images/devtools1.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
images/devtools2.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
images/hatch.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
images/quick_setup_1.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
images/quick_setup_10.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
images/quick_setup_2.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
images/quick_setup_3.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
images/quick_setup_3b.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
images/quick_setup_4.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
images/quick_setup_5.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
images/quick_setup_6.png
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
images/quick_setup_8.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
images/quick_setup_9_1.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
images/quick_setup_9_2.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
images/write_logs_into_the_file.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
10
manifest-beta.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "0.24.0",
|
||||
"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",
|
||||
"authorUrl": "https://github.com/vrtmrz",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "0.12.3",
|
||||
"version": "0.24.1",
|
||||
"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",
|
||||
"authorUrl": "https://github.com/vrtmrz",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
}
|
||||
|
||||
10664
package-lock.json
generated
90
package.json
@@ -1,46 +1,80 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "0.12.3",
|
||||
"version": "0.24.1",
|
||||
"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": "node esbuild.config.mjs",
|
||||
"build": "node esbuild.config.mjs production",
|
||||
"lint": "eslint src"
|
||||
"buildDev": "node esbuild.config.mjs dev",
|
||||
"lint": "eslint src",
|
||||
"svelte-check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"tsc-check": "tsc --noEmit",
|
||||
"pretty": "npm run prettyNoWrite -- --write --log-level error",
|
||||
"prettyCheck": "npm run prettyNoWrite -- --check",
|
||||
"prettyNoWrite": "prettier --config ./.prettierrc \"**/*.js\" \"**/*.ts\" \"**/*.json\" ",
|
||||
"check": "npm run lint && npm run svelte-check && npm run tsc-check"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "vorotamoroz",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^18.0.0",
|
||||
"@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.7.0",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-airbnb-base": "^14.2.1",
|
||||
"eslint-plugin-import": "^2.25.2",
|
||||
"obsidian": "^0.15.4",
|
||||
"postcss": "^8.4.14",
|
||||
"postcss-load-config": "^3.1.4",
|
||||
"rollup": "^2.32.1",
|
||||
"svelte-preprocess": "^4.10.5",
|
||||
"tslib": "^2.2.0",
|
||||
"typescript": "^4.2.4"
|
||||
"@chialab/esbuild-plugin-worker": "^0.18.1",
|
||||
"@tsconfig/svelte": "^5.0.4",
|
||||
"@types/diff-match-patch": "^1.0.36",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/pouchdb": "^6.4.2",
|
||||
"@types/pouchdb-adapter-http": "^6.1.6",
|
||||
"@types/pouchdb-adapter-idb": "^6.1.7",
|
||||
"@types/pouchdb-browser": "^6.1.5",
|
||||
"@types/pouchdb-core": "^7.0.15",
|
||||
"@types/pouchdb-mapreduce": "^6.1.10",
|
||||
"@types/pouchdb-replication": "^6.4.7",
|
||||
"@types/transform-pouch": "^1.0.6",
|
||||
"@typescript-eslint/eslint-plugin": "^8.4.0",
|
||||
"@typescript-eslint/parser": "^8.4.0",
|
||||
"builtin-modules": "^4.0.0",
|
||||
"esbuild": "0.23.1",
|
||||
"esbuild-svelte": "^0.8.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-plugin-import": "^2.30.0",
|
||||
"events": "^3.3.0",
|
||||
"obsidian": "^1.6.6",
|
||||
"postcss": "^8.4.45",
|
||||
"postcss-load-config": "^6.0.1",
|
||||
"pouchdb-adapter-http": "^9.0.0",
|
||||
"pouchdb-adapter-idb": "^9.0.0",
|
||||
"pouchdb-adapter-indexeddb": "^9.0.0",
|
||||
"pouchdb-core": "^9.0.0",
|
||||
"pouchdb-errors": "^9.0.0",
|
||||
"pouchdb-find": "^9.0.0",
|
||||
"pouchdb-mapreduce": "^9.0.0",
|
||||
"pouchdb-merge": "^9.0.0",
|
||||
"pouchdb-replication": "^9.0.0",
|
||||
"pouchdb-utils": "^9.0.0",
|
||||
"prettier": "^3.3.3",
|
||||
"svelte": "^4.2.19",
|
||||
"svelte-preprocess": "^6.0.2",
|
||||
"terser": "^5.31.6",
|
||||
"transform-pouch": "^2.0.0",
|
||||
"tslib": "^2.7.0",
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.645.0",
|
||||
"@smithy/fetch-http-handler": "^3.2.9",
|
||||
"@smithy/protocol-http": "^4.1.0",
|
||||
"@smithy/querystring-builder": "^3.0.3",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"esbuild": "0.13.12",
|
||||
"esbuild-svelte": "^0.7.0",
|
||||
"idb": "^7.0.2",
|
||||
"svelte-preprocess": "^4.10.5",
|
||||
"xxhash-wasm": "^0.4.2"
|
||||
"esbuild-plugin-inline-worker": "^0.1.1",
|
||||
"fflate": "^0.8.2",
|
||||
"idb": "^8.0.0",
|
||||
"minimatch": "^10.0.1",
|
||||
"octagonal-wheels": "^0.1.15",
|
||||
"svelte-check": "^4.0.4",
|
||||
"xxhash-wasm": "0.4.2",
|
||||
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
1
pouchdb-browser-webpack/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
node_modules
|
||||
@@ -1,4 +0,0 @@
|
||||
# PouchDB-browser
|
||||
|
||||
Just webpacked.
|
||||
(Rollup couldn't pack pouchdb-browser into browser bundle)
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "pouchdb-browser-webpack",
|
||||
"version": "1.0.0",
|
||||
"description": "pouchdb-browser webpack",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"build": "webpack --mode=production --node-env=production",
|
||||
"build:dev": "webpack --mode=development",
|
||||
"build:prod": "webpack --mode=production --node-env=production",
|
||||
"watch": "webpack --watch"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"pouchdb-browser": "^7.3.0",
|
||||
"transform-pouch": "^2.0.0",
|
||||
"pouchdb-find": "^7.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"webpack": "^5.58.1",
|
||||
"webpack-cli": "^4.9.0"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
/* eslint-disable no-undef */
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
// This module just webpacks pouchdb-browser
|
||||
// import * as PouchDB_src from "pouchdb-browser";
|
||||
const pouch = require("pouchdb-browser").default;
|
||||
const find = require("pouchdb-find").default;
|
||||
const transform = require("transform-pouch");
|
||||
const PouchDB = pouch.plugin(find).plugin(transform);
|
||||
|
||||
export { PouchDB };
|
||||
@@ -1,30 +0,0 @@
|
||||
// Generated using webpack-cli https://github.com/webpack/webpack-cli
|
||||
|
||||
const path = require("path");
|
||||
|
||||
const isProduction = process.env.NODE_ENV == "production";
|
||||
|
||||
const config = {
|
||||
entry: "./src/index.js",
|
||||
output: {
|
||||
filename: "pouchdb-browser.js",
|
||||
path: path.resolve(__dirname, "dist"),
|
||||
library: {
|
||||
type: "module",
|
||||
},
|
||||
},
|
||||
experiments: {
|
||||
outputModule: true,
|
||||
},
|
||||
plugins: [],
|
||||
module: {},
|
||||
};
|
||||
|
||||
module.exports = () => {
|
||||
if (isProduction) {
|
||||
config.mode = "production";
|
||||
} else {
|
||||
config.mode = "development";
|
||||
}
|
||||
return config;
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
import typescript from "@rollup/plugin-typescript";
|
||||
import { nodeResolve } from "@rollup/plugin-node-resolve";
|
||||
import commonjs from "@rollup/plugin-commonjs";
|
||||
|
||||
const isProd = process.env.BUILD === "production";
|
||||
|
||||
const banner = `/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ROLLUP
|
||||
if you want to view the source visit the plugins github repository
|
||||
*/
|
||||
`;
|
||||
|
||||
export default {
|
||||
input: "./src/main.ts",
|
||||
output: {
|
||||
dir: ".",
|
||||
sourcemap: "inline",
|
||||
sourcemapExcludeSources: isProd,
|
||||
format: "cjs",
|
||||
exports: "default",
|
||||
banner,
|
||||
},
|
||||
external: ["obsidian"],
|
||||
plugins: [
|
||||
typescript({ exclude: ["pouchdb-browser.js", "pouchdb-browser-webpack"] }),
|
||||
nodeResolve({
|
||||
browser: true,
|
||||
}),
|
||||
commonjs(),
|
||||
],
|
||||
};
|
||||
151
setup-flyio-on-the-fly-v2.ipynb
Normal file
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {
|
||||
"colab_type": "text",
|
||||
"id": "view-in-github"
|
||||
},
|
||||
"source": [
|
||||
"<a href=\"https://colab.research.google.com/gist/vrtmrz/9402b101746e08e969b1a4f5f0deb465/setup-flyio-on-the-fly-v2.ipynb\" target=\"_parent\"><img src=\"https://colab.research.google.com/assets/colab-badge.svg\" alt=\"Open In Colab\"/></a>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {
|
||||
"id": "AzLlAcLFRO5A"
|
||||
},
|
||||
"source": [
|
||||
"- Initial version 7th Feb. 2024"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "z1x8DQpa9opC"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Install prerequesties\n",
|
||||
"!curl -L https://fly.io/install.sh | sh\n",
|
||||
"!curl -fsSL https://deno.land/x/install/install.sh | sh\n",
|
||||
"!apt update && apt -y install jq\n",
|
||||
"import os\n",
|
||||
"%env PATH=/root/.fly/bin:/root/.deno/bin/:{os.environ[\"PATH\"]}\n",
|
||||
"!git clone --recursive https://github.com/vrtmrz/obsidian-livesync"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "mGN08BaFDviy"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Login up sign up\n",
|
||||
"!flyctl auth signup"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {
|
||||
"id": "BBFTFOP6vA8m"
|
||||
},
|
||||
"source": [
|
||||
"Select a region and execute the block."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "TNl0A603EF9E"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# see https://fly.io/docs/reference/regions/\n",
|
||||
"region = \"nrt/Tokyo, Japan\" #@param [\"ams/Amsterdam, Netherlands\",\"arn/Stockholm, Sweden\",\"atl/Atlanta, Georgia (US)\",\"bog/Bogotá, Colombia\",\"bos/Boston, Massachusetts (US)\",\"cdg/Paris, France\",\"den/Denver, Colorado (US)\",\"dfw/Dallas, Texas (US)\",\"ewr/Secaucus, NJ (US)\",\"eze/Ezeiza, Argentina\",\"gdl/Guadalajara, Mexico\",\"gig/Rio de Janeiro, Brazil\",\"gru/Sao Paulo, Brazil\",\"hkg/Hong Kong, Hong Kong\",\"iad/Ashburn, Virginia (US)\",\"jnb/Johannesburg, South Africa\",\"lax/Los Angeles, California (US)\",\"lhr/London, United Kingdom\",\"mad/Madrid, Spain\",\"mia/Miami, Florida (US)\",\"nrt/Tokyo, Japan\",\"ord/Chicago, Illinois (US)\",\"otp/Bucharest, Romania\",\"phx/Phoenix, Arizona (US)\",\"qro/Querétaro, Mexico\",\"scl/Santiago, Chile\",\"sea/Seattle, Washington (US)\",\"sin/Singapore, Singapore\",\"sjc/San Jose, California (US)\",\"syd/Sydney, Australia\",\"waw/Warsaw, Poland\",\"yul/Montreal, Canada\",\"yyz/Toronto, Canada\" ] {allow-input: true}\n",
|
||||
"%env region={region.split(\"/\")[0]}\n",
|
||||
"#%env appame=\n",
|
||||
"#%env username=\n",
|
||||
"#%env password=\n",
|
||||
"#%env database=\n",
|
||||
"#%env passphrase=\n",
|
||||
"\n",
|
||||
"# automatic setup leave it -->\n",
|
||||
"%cd obsidian-livesync/utils/flyio\n",
|
||||
"!./deploy-server.sh | tee deploy-result.txt\n",
|
||||
"\n",
|
||||
"## Show result button\n",
|
||||
"from IPython.display import HTML\n",
|
||||
"last_line=\"\"\n",
|
||||
"with open('deploy-result.txt', 'r') as f:\n",
|
||||
" last_line = f.readlines()[-1]\n",
|
||||
" last_line = str.strip(last_line)\n",
|
||||
"\n",
|
||||
"if last_line.startswith(\"obsidian://\"):\n",
|
||||
" result = HTML(f\"Copy your setup-URI with this button! -> <button onclick=\\\"navigator.clipboard.writeText('{last_line}')\\\">Copy setup uri</button><br>Importing passphrase is displayed one. <br>If you want to synchronise in live mode, please apply a preset after ensuring the imported configuration works.\")\n",
|
||||
"else:\n",
|
||||
" result = \"Failed to encrypt the setup URI\"\n",
|
||||
"result"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {
|
||||
"id": "oeIzExnEKhFp"
|
||||
},
|
||||
"source": [
|
||||
"If you see the `Copy setup URI` button, Congratulations! Your CouchDB is ready to use! Please click the button. And open this on Obsidian.\n",
|
||||
"\n",
|
||||
"And, you should keep the output to your secret memo.\n",
|
||||
"\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {
|
||||
"id": "sdQrqOjERN3K"
|
||||
},
|
||||
"source": [
|
||||
"\n",
|
||||
"\n",
|
||||
"---\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"If you want to delete this CouchDB instance, you can do it by executing next cell. \n",
|
||||
"If your fly.toml has been gone, access https://fly.io/dashboard and check the existing app."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "7JMSkNvVIIfg"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"!./delete-server.sh"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"colab": {
|
||||
"authorship_tag": "ABX9TyMexQ5pErH5LBG2tENtEVWf",
|
||||
"include_colab_link": true,
|
||||
"private_outputs": true,
|
||||
"provenance": []
|
||||
},
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 0
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { App, Modal } from "obsidian";
|
||||
import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT } from "diff-match-patch";
|
||||
import { diff_result } from "./lib/src/types";
|
||||
import { escapeStringToHTML } from "./lib/src/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.callback = null;
|
||||
this.close();
|
||||
});
|
||||
});
|
||||
contentEl.createEl("button", { text: "Keep B" }, (e) => {
|
||||
e.addEventListener("click", async () => {
|
||||
await this.callback(this.result.left.rev);
|
||||
this.callback = null;
|
||||
this.close();
|
||||
});
|
||||
});
|
||||
contentEl.createEl("button", { text: "Concat both" }, (e) => {
|
||||
e.addEventListener("click", async () => {
|
||||
await this.callback("");
|
||||
this.callback = null;
|
||||
this.close();
|
||||
});
|
||||
});
|
||||
contentEl.createEl("button", { text: "Not now" }, (e) => {
|
||||
e.addEventListener("click", () => {
|
||||
this.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
if (this.callback != null) {
|
||||
this.callback(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
import { TFile, Modal, App } from "obsidian";
|
||||
import { path2id } from "./utils";
|
||||
import { base64ToArrayBuffer, base64ToString, escapeStringToHTML, isValidPath } from "./lib/src/utils";
|
||||
import ObsidianLiveSyncPlugin from "./main";
|
||||
import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_patch } from "diff-match-patch";
|
||||
import { LoadedEntry, 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[] = [];
|
||||
currentDoc: LoadedEntry;
|
||||
currentText = "";
|
||||
|
||||
constructor(app: App, plugin: ObsidianLiveSyncPlugin, file: TFile | string) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.file = (file instanceof TFile) ? file.path : file;
|
||||
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.currentDoc = w;
|
||||
this.info.innerHTML = `Modified:${new Date(w.mtime).toLocaleString()}`;
|
||||
let result = "";
|
||||
const w1data = w.datatype == "plain" ? w.data : base64ToString(w.data);
|
||||
|
||||
this.currentText = w1data;
|
||||
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 w2data = w2.datatype == "plain" ? w2.data : base64ToString(w2.data);
|
||||
const diff = dmp.diff_main(w2data, w1data);
|
||||
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(w1data);
|
||||
}
|
||||
} else {
|
||||
result = escapeStringToHTML(w1data);
|
||||
}
|
||||
} else {
|
||||
result = escapeStringToHTML(w1data);
|
||||
}
|
||||
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);
|
||||
});
|
||||
});
|
||||
buttons.createEl("button", { text: "Back to this revision" }, (e) => {
|
||||
e.addClass("mod-cta");
|
||||
e.addEventListener("click", async () => {
|
||||
const pathToWrite = this.file.startsWith("i:") ? this.file.substring("i:".length) : this.file;
|
||||
if (!isValidPath(pathToWrite)) {
|
||||
Logger("Path is not vaild to write content.", LOG_LEVEL.INFO);
|
||||
}
|
||||
if (this.currentDoc?.datatype == "plain") {
|
||||
await this.app.vault.adapter.write(pathToWrite, this.currentDoc.data);
|
||||
this.close();
|
||||
} else if (this.currentDoc?.datatype == "newnote") {
|
||||
await this.app.vault.adapter.writeBinary(pathToWrite, base64ToArrayBuffer(this.currentDoc.data));
|
||||
this.close();
|
||||
} else {
|
||||
|
||||
Logger(`Could not parse entry`, LOG_LEVEL.NOTICE);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
1245
src/LocalPouchDB.ts
@@ -1,37 +0,0 @@
|
||||
import { App, Modal } from "obsidian";
|
||||
import { escapeStringToHTML } from "./lib/src/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;
|
||||
}
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
<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[]][] = null;
|
||||
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}
|
||||
<tr>
|
||||
<td colspan="3" class="sls-table-tail tcenter"> Retrieving... </td>
|
||||
</tr>
|
||||
{:else if devicePluginList.length == 0}
|
||||
<tr>
|
||||
<td colspan="3" class="sls-table-tail tcenter"> No plugins found. </td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#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}
|
||||
{/if}
|
||||
</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>
|
||||
@@ -1,12 +1,12 @@
|
||||
import { deleteDB, IDBPDatabase, openDB } from "idb";
|
||||
import { deleteDB, type IDBPDatabase, openDB } from "idb";
|
||||
export interface KeyValueDatabase {
|
||||
get<T>(key: string): Promise<T>;
|
||||
set<T>(key: string, value: T): Promise<IDBValidKey>;
|
||||
del(key: string): Promise<void>;
|
||||
get<T>(key: IDBValidKey): Promise<T>;
|
||||
set<T>(key: IDBValidKey, value: T): Promise<IDBValidKey>;
|
||||
del(key: IDBValidKey): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
keys(query?: IDBValidKey | IDBKeyRange, count?: number): Promise<IDBValidKey[]>;
|
||||
close(): void;
|
||||
destroy(): void;
|
||||
destroy(): Promise<void>;
|
||||
}
|
||||
const databaseCache: { [key: string]: IDBPDatabase<any> } = {};
|
||||
export const OpenKeyValueDatabase = async (dbKey: string): Promise<KeyValueDatabase> => {
|
||||
@@ -20,24 +20,23 @@ export const OpenKeyValueDatabase = async (dbKey: string): Promise<KeyValueDatab
|
||||
db.createObjectStore(storeKey);
|
||||
},
|
||||
});
|
||||
let db: IDBPDatabase<any> = null;
|
||||
db = await dbPromise;
|
||||
const db = await dbPromise;
|
||||
databaseCache[dbKey] = db;
|
||||
return {
|
||||
get<T>(key: string): Promise<T> {
|
||||
return db.get(storeKey, key);
|
||||
async get<T>(key: IDBValidKey): Promise<T> {
|
||||
return await db.get(storeKey, key);
|
||||
},
|
||||
set<T>(key: string, value: T) {
|
||||
return db.put(storeKey, value, key);
|
||||
async set<T>(key: IDBValidKey, value: T) {
|
||||
return await db.put(storeKey, value, key);
|
||||
},
|
||||
del(key: string) {
|
||||
return db.delete(storeKey, key);
|
||||
async del(key: IDBValidKey) {
|
||||
return await db.delete(storeKey, key);
|
||||
},
|
||||
clear() {
|
||||
return db.clear(storeKey);
|
||||
async clear() {
|
||||
return await db.clear(storeKey);
|
||||
},
|
||||
keys(query?: IDBValidKey | IDBKeyRange, count?: number) {
|
||||
return db.getAllKeys(storeKey, query, count);
|
||||
async keys(query?: IDBValidKey | IDBKeyRange, count?: number) {
|
||||
return await db.getAllKeys(storeKey, query, count);
|
||||
},
|
||||
close() {
|
||||
delete databaseCache[dbKey];
|
||||
49
src/common/events.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { FilePathWithPrefix, ObsidianLiveSyncSettings } from "../lib/src/common/types";
|
||||
import { eventHub } from "../lib/src/hub/hub";
|
||||
import type ObsidianLiveSyncPlugin from "../main";
|
||||
|
||||
export const EVENT_LAYOUT_READY = "layout-ready";
|
||||
export const EVENT_PLUGIN_LOADED = "plugin-loaded";
|
||||
export const EVENT_PLUGIN_UNLOADED = "plugin-unloaded";
|
||||
export const EVENT_SETTING_SAVED = "setting-saved";
|
||||
export const EVENT_FILE_RENAMED = "file-renamed";
|
||||
export const EVENT_FILE_SAVED = "file-saved";
|
||||
export const EVENT_LEAF_ACTIVE_CHANGED = "leaf-active-changed";
|
||||
|
||||
export const EVENT_LOG_ADDED = "log-added";
|
||||
|
||||
export const EVENT_REQUEST_OPEN_SETTINGS = "request-open-settings";
|
||||
export const EVENT_REQUEST_OPEN_SETTING_WIZARD = "request-open-setting-wizard";
|
||||
export const EVENT_REQUEST_OPEN_SETUP_URI = "request-open-setup-uri";
|
||||
export const EVENT_REQUEST_COPY_SETUP_URI = "request-copy-setup-uri";
|
||||
|
||||
export const EVENT_REQUEST_RELOAD_SETTING_TAB = "reload-setting-tab";
|
||||
|
||||
export const EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG = "request-open-plugin-sync-dialog";
|
||||
|
||||
// export const EVENT_FILE_CHANGED = "file-changed";
|
||||
|
||||
declare global {
|
||||
interface LSEvents {
|
||||
[EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG]: undefined;
|
||||
[EVENT_FILE_SAVED]: undefined;
|
||||
[EVENT_REQUEST_OPEN_SETUP_URI]: undefined;
|
||||
[EVENT_REQUEST_COPY_SETUP_URI]: undefined;
|
||||
[EVENT_REQUEST_RELOAD_SETTING_TAB]: undefined;
|
||||
[EVENT_PLUGIN_UNLOADED]: undefined;
|
||||
[EVENT_SETTING_SAVED]: ObsidianLiveSyncSettings;
|
||||
[EVENT_PLUGIN_LOADED]: ObsidianLiveSyncPlugin;
|
||||
[EVENT_LAYOUT_READY]: undefined;
|
||||
"event-file-changed": { file: FilePathWithPrefix; automated: boolean };
|
||||
"document-stub-created": {
|
||||
toc: Set<string>;
|
||||
stub: { [key: string]: { [key: string]: Map<string, Record<string, string>> } };
|
||||
};
|
||||
[EVENT_REQUEST_OPEN_SETTINGS]: undefined;
|
||||
[EVENT_REQUEST_OPEN_SETTING_WIZARD]: undefined;
|
||||
[EVENT_FILE_RENAMED]: { newPath: FilePathWithPrefix; old: FilePathWithPrefix };
|
||||
[EVENT_LEAF_ACTIVE_CHANGED]: undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export { eventHub };
|
||||
12
src/common/obsidianEvents.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { TFile } from "../deps";
|
||||
import type { FilePathWithPrefix, LoadedEntry } from "../lib/src/common/types";
|
||||
|
||||
export const EVENT_REQUEST_SHOW_HISTORY = "show-history";
|
||||
|
||||
declare global {
|
||||
interface LSEvents {
|
||||
[EVENT_REQUEST_SHOW_HISTORY]:
|
||||
| { file: TFile; fileOnDB: LoadedEntry }
|
||||
| { file: FilePathWithPrefix; fileOnDB: LoadedEntry };
|
||||
}
|
||||
}
|
||||
7
src/common/stores.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { PersistentMap } from "../lib/src/dataobject/PersistentMap.ts";
|
||||
|
||||
export let sameChangePairs: PersistentMap<number[]>;
|
||||
|
||||
export function initializeStores(vaultName: string) {
|
||||
sameChangePairs = new PersistentMap<number[]>(`ls-persist-same-changes-${vaultName}`);
|
||||
}
|
||||
90
src/common/types.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { type PluginManifest, TFile } from "../deps.ts";
|
||||
import {
|
||||
type DatabaseEntry,
|
||||
type EntryBody,
|
||||
type FilePath,
|
||||
type UXFileInfoStub,
|
||||
type UXInternalFileInfoStub,
|
||||
} from "../lib/src/common/types.ts";
|
||||
|
||||
export interface PluginDataEntry extends DatabaseEntry {
|
||||
deviceVaultName: string;
|
||||
mtime: number;
|
||||
manifest: PluginManifest;
|
||||
mainJs: string;
|
||||
manifestJson: string;
|
||||
styleCss?: string;
|
||||
// it must be encrypted.
|
||||
dataJson?: string;
|
||||
_conflicts?: string[];
|
||||
type: "plugin";
|
||||
}
|
||||
|
||||
export interface PluginList {
|
||||
[key: string]: PluginDataEntry[];
|
||||
}
|
||||
|
||||
export interface DevicePluginList {
|
||||
[key: string]: PluginDataEntry;
|
||||
}
|
||||
export const PERIODIC_PLUGIN_SWEEP = 60;
|
||||
|
||||
export interface InternalFileInfo {
|
||||
path: FilePath;
|
||||
mtime: number;
|
||||
ctime: number;
|
||||
size: number;
|
||||
deleted?: boolean;
|
||||
}
|
||||
|
||||
export interface FileInfo {
|
||||
path: FilePath;
|
||||
mtime: number;
|
||||
ctime: number;
|
||||
size: number;
|
||||
deleted?: boolean;
|
||||
file: TFile;
|
||||
}
|
||||
|
||||
export type queueItem = {
|
||||
entry: EntryBody;
|
||||
missingChildren: string[];
|
||||
timeout?: number;
|
||||
done?: boolean;
|
||||
warned?: boolean;
|
||||
};
|
||||
|
||||
export type CacheData = string | ArrayBuffer;
|
||||
export type FileEventType = "CREATE" | "DELETE" | "CHANGED" | "INTERNAL";
|
||||
export type FileEventArgs = {
|
||||
file: UXFileInfoStub | UXInternalFileInfoStub;
|
||||
cache?: CacheData;
|
||||
oldPath?: string;
|
||||
ctx?: any;
|
||||
};
|
||||
export type FileEventItem = {
|
||||
type: FileEventType;
|
||||
args: FileEventArgs;
|
||||
key: string;
|
||||
skipBatchWait?: boolean;
|
||||
cancelled?: boolean;
|
||||
batched?: boolean;
|
||||
};
|
||||
|
||||
// Hidden items (Now means `chunk`)
|
||||
export const CHeader = "h:";
|
||||
|
||||
// Plug-in Stored Container (Obsolete)
|
||||
export const PSCHeader = "ps:";
|
||||
export const PSCHeaderEnd = "ps;";
|
||||
|
||||
// Internal data Container
|
||||
export const ICHeader = "i:";
|
||||
export const ICHeaderEnd = "i;";
|
||||
export const ICHeaderLength = ICHeader.length;
|
||||
|
||||
// Internal data Container (eXtended)
|
||||
export const ICXHeader = "ix:";
|
||||
|
||||
export const FileWatchEventQueueMax = 10;
|
||||
export const configURIBase = "obsidian://setuplivesync?settings=";
|
||||
379
src/common/utils.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
import { normalizePath, Platform, TAbstractFile, type RequestUrlParam, requestUrl } from "../deps.ts";
|
||||
import {
|
||||
path2id_base,
|
||||
id2path_base,
|
||||
isValidFilenameInLinux,
|
||||
isValidFilenameInDarwin,
|
||||
isValidFilenameInWidows,
|
||||
isValidFilenameInAndroid,
|
||||
stripAllPrefixes,
|
||||
} from "../lib/src/string_and_binary/path.ts";
|
||||
|
||||
import { Logger } from "../lib/src/common/logger.ts";
|
||||
import {
|
||||
LOG_LEVEL_VERBOSE,
|
||||
type AnyEntry,
|
||||
type DocumentID,
|
||||
type EntryHasPath,
|
||||
type FilePath,
|
||||
type FilePathWithPrefix,
|
||||
type UXFileInfo,
|
||||
type UXFileInfoStub,
|
||||
} from "../lib/src/common/types.ts";
|
||||
import { CHeader, ICHeader, ICHeaderLength, ICXHeader, PSCHeader } from "./types.ts";
|
||||
import type ObsidianLiveSyncPlugin from "../main.ts";
|
||||
import { writeString } from "../lib/src/string_and_binary/convert.ts";
|
||||
import { fireAndForget } from "../lib/src/common/utils.ts";
|
||||
import { sameChangePairs } from "./stores.ts";
|
||||
|
||||
export {
|
||||
scheduleTask,
|
||||
setPeriodicTask,
|
||||
cancelTask,
|
||||
cancelAllTasks,
|
||||
cancelPeriodicTask,
|
||||
cancelAllPeriodicTask,
|
||||
} from "../lib/src/concurrency/task.ts";
|
||||
|
||||
// For backward compatibility, using the path for determining id.
|
||||
// Only CouchDB unacceptable ID (that starts with an underscore) has been prefixed with "/".
|
||||
// The first slash will be deleted when the path is normalized.
|
||||
export async function path2id(
|
||||
filename: FilePathWithPrefix | FilePath,
|
||||
obfuscatePassphrase: string | false,
|
||||
caseInsensitive: boolean
|
||||
): Promise<DocumentID> {
|
||||
const temp = filename.split(":");
|
||||
const path = temp.pop();
|
||||
const normalizedPath = normalizePath(path as FilePath);
|
||||
temp.push(normalizedPath);
|
||||
const fixedPath = temp.join(":") as FilePathWithPrefix;
|
||||
|
||||
const out = await path2id_base(fixedPath, obfuscatePassphrase, caseInsensitive);
|
||||
return out;
|
||||
}
|
||||
export function id2path(id: DocumentID, entry?: EntryHasPath): FilePathWithPrefix {
|
||||
const filename = id2path_base(id, entry);
|
||||
const temp = filename.split(":");
|
||||
const path = temp.pop();
|
||||
const normalizedPath = normalizePath(path as FilePath);
|
||||
temp.push(normalizedPath);
|
||||
const fixedPath = temp.join(":") as FilePathWithPrefix;
|
||||
return fixedPath;
|
||||
}
|
||||
export function getPath(entry: AnyEntry) {
|
||||
return id2path(entry._id, entry);
|
||||
}
|
||||
export function getPathWithoutPrefix(entry: AnyEntry) {
|
||||
const f = getPath(entry);
|
||||
return stripAllPrefixes(f);
|
||||
}
|
||||
|
||||
export function getPathFromTFile(file: TAbstractFile) {
|
||||
return file.path as FilePath;
|
||||
}
|
||||
export function getPathFromUXFileInfo(file: UXFileInfoStub | string | FilePathWithPrefix) {
|
||||
if (typeof file == "string") return file as FilePathWithPrefix;
|
||||
return file.path;
|
||||
}
|
||||
|
||||
const memos: { [key: string]: any } = {};
|
||||
export function memoObject<T>(key: string, obj: T): T {
|
||||
memos[key] = obj;
|
||||
return memos[key] as T;
|
||||
}
|
||||
export async function memoIfNotExist<T>(key: string, func: () => T | Promise<T>): Promise<T> {
|
||||
if (!(key in memos)) {
|
||||
const w = func();
|
||||
const v = w instanceof Promise ? await w : w;
|
||||
memos[key] = v;
|
||||
}
|
||||
return memos[key] as T;
|
||||
}
|
||||
export function retrieveMemoObject<T>(key: string): T | false {
|
||||
if (key in memos) {
|
||||
return memos[key];
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export function disposeMemoObject(key: string) {
|
||||
delete memos[key];
|
||||
}
|
||||
|
||||
export function isValidPath(filename: string) {
|
||||
if (Platform.isDesktop) {
|
||||
// if(Platform.isMacOS) return isValidFilenameInDarwin(filename);
|
||||
if (process.platform == "darwin") return isValidFilenameInDarwin(filename);
|
||||
if (process.platform == "linux") return isValidFilenameInLinux(filename);
|
||||
return isValidFilenameInWidows(filename);
|
||||
}
|
||||
if (Platform.isAndroidApp) return isValidFilenameInAndroid(filename);
|
||||
if (Platform.isIosApp) return isValidFilenameInDarwin(filename);
|
||||
//Fallback
|
||||
Logger("Could not determine platform for checking filename", LOG_LEVEL_VERBOSE);
|
||||
return isValidFilenameInWidows(filename);
|
||||
}
|
||||
|
||||
export function trimPrefix(target: string, prefix: string) {
|
||||
return target.startsWith(prefix) ? target.substring(prefix.length) : target;
|
||||
}
|
||||
|
||||
/**
|
||||
* returns is internal chunk of file
|
||||
* @param id ID
|
||||
* @returns
|
||||
*/
|
||||
export function isInternalMetadata(id: FilePath | FilePathWithPrefix | DocumentID): boolean {
|
||||
return id.startsWith(ICHeader);
|
||||
}
|
||||
export function stripInternalMetadataPrefix<T extends FilePath | FilePathWithPrefix | DocumentID>(id: T): T {
|
||||
return id.substring(ICHeaderLength) as T;
|
||||
}
|
||||
export function id2InternalMetadataId(id: DocumentID): DocumentID {
|
||||
return (ICHeader + id) as DocumentID;
|
||||
}
|
||||
|
||||
// const CHeaderLength = CHeader.length;
|
||||
export function isChunk(str: string): boolean {
|
||||
return str.startsWith(CHeader);
|
||||
}
|
||||
|
||||
export function isPluginMetadata(str: string): boolean {
|
||||
return str.startsWith(PSCHeader);
|
||||
}
|
||||
export function isCustomisationSyncMetadata(str: string): boolean {
|
||||
return str.startsWith(ICXHeader);
|
||||
}
|
||||
|
||||
export class PeriodicProcessor {
|
||||
_process: () => Promise<any>;
|
||||
_timer?: number;
|
||||
_plugin: ObsidianLiveSyncPlugin;
|
||||
constructor(plugin: ObsidianLiveSyncPlugin, process: () => Promise<any>) {
|
||||
this._plugin = plugin;
|
||||
this._process = process;
|
||||
}
|
||||
async process() {
|
||||
try {
|
||||
await this._process();
|
||||
} catch (ex) {
|
||||
Logger(ex);
|
||||
}
|
||||
}
|
||||
enable(interval: number) {
|
||||
this.disable();
|
||||
if (interval == 0) return;
|
||||
this._timer = window.setInterval(
|
||||
() =>
|
||||
fireAndForget(async () => {
|
||||
await this.process();
|
||||
if (this._plugin.$$isUnloaded()) {
|
||||
this.disable();
|
||||
}
|
||||
}),
|
||||
interval
|
||||
);
|
||||
this._plugin.registerInterval(this._timer);
|
||||
}
|
||||
disable() {
|
||||
if (this._timer !== undefined) {
|
||||
window.clearInterval(this._timer);
|
||||
this._timer = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const _requestToCouchDBFetch = async (
|
||||
baseUri: string,
|
||||
username: string,
|
||||
password: string,
|
||||
path?: string,
|
||||
body?: string | any,
|
||||
method?: string
|
||||
) => {
|
||||
const utf8str = String.fromCharCode.apply(null, [...writeString(`${username}:${password}`)]);
|
||||
const encoded = window.btoa(utf8str);
|
||||
const authHeader = "Basic " + encoded;
|
||||
const transformedHeaders: Record<string, string> = {
|
||||
authorization: authHeader,
|
||||
"content-type": "application/json",
|
||||
};
|
||||
const uri = `${baseUri}/${path}`;
|
||||
const requestParam = {
|
||||
url: uri,
|
||||
method: method || (body ? "PUT" : "GET"),
|
||||
headers: new Headers(transformedHeaders),
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
return await fetch(uri, requestParam);
|
||||
};
|
||||
|
||||
export const _requestToCouchDB = async (
|
||||
baseUri: string,
|
||||
username: string,
|
||||
password: string,
|
||||
origin: string,
|
||||
path?: string,
|
||||
body?: any,
|
||||
method?: string
|
||||
) => {
|
||||
const utf8str = String.fromCharCode.apply(null, [...writeString(`${username}:${password}`)]);
|
||||
const encoded = window.btoa(utf8str);
|
||||
const authHeader = "Basic " + encoded;
|
||||
const transformedHeaders: Record<string, string> = { authorization: authHeader, origin: origin };
|
||||
const uri = `${baseUri}/${path}`;
|
||||
const requestParam: RequestUrlParam = {
|
||||
url: uri,
|
||||
method: method || (body ? "PUT" : "GET"),
|
||||
headers: transformedHeaders,
|
||||
contentType: "application/json",
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
};
|
||||
return await requestUrl(requestParam);
|
||||
};
|
||||
export const requestToCouchDB = async (
|
||||
baseUri: string,
|
||||
username: string,
|
||||
password: string,
|
||||
origin: string = "",
|
||||
key?: string,
|
||||
body?: string,
|
||||
method?: string
|
||||
) => {
|
||||
const uri = `_node/_local/_config${key ? "/" + key : ""}`;
|
||||
return await _requestToCouchDB(baseUri, username, password, origin, uri, body, method);
|
||||
};
|
||||
|
||||
export const BASE_IS_NEW = Symbol("base");
|
||||
export const TARGET_IS_NEW = Symbol("target");
|
||||
export const EVEN = Symbol("even");
|
||||
|
||||
// Why 2000? : ZIP FILE Does not have enough resolution.
|
||||
const resolution = 2000;
|
||||
export function compareMTime(
|
||||
baseMTime: number,
|
||||
targetMTime: number
|
||||
): typeof BASE_IS_NEW | typeof TARGET_IS_NEW | typeof EVEN {
|
||||
const truncatedBaseMTime = ~~(baseMTime / resolution) * resolution;
|
||||
const truncatedTargetMTime = ~~(targetMTime / resolution) * resolution;
|
||||
// Logger(`Resolution MTime ${truncatedBaseMTime} and ${truncatedTargetMTime} `, LOG_LEVEL_VERBOSE);
|
||||
if (truncatedBaseMTime == truncatedTargetMTime) return EVEN;
|
||||
if (truncatedBaseMTime > truncatedTargetMTime) return BASE_IS_NEW;
|
||||
if (truncatedBaseMTime < truncatedTargetMTime) return TARGET_IS_NEW;
|
||||
throw new Error("Unexpected error");
|
||||
}
|
||||
|
||||
export function markChangesAreSame(file: AnyEntry | string | UXFileInfoStub, mtime1: number, mtime2: number) {
|
||||
if (mtime1 === mtime2) return true;
|
||||
const key = typeof file == "string" ? file : "_id" in file ? file._id : file.path;
|
||||
const pairs = sameChangePairs.get(key, []) || [];
|
||||
if (pairs.some((e) => e == mtime1 || e == mtime2)) {
|
||||
sameChangePairs.set(key, [...new Set([...pairs, mtime1, mtime2])]);
|
||||
} else {
|
||||
sameChangePairs.set(key, [mtime1, mtime2]);
|
||||
}
|
||||
}
|
||||
export function isMarkedAsSameChanges(file: UXFileInfoStub | AnyEntry | string, mtimes: number[]) {
|
||||
const key = typeof file == "string" ? file : "_id" in file ? file._id : file.path;
|
||||
const pairs = sameChangePairs.get(key, []) || [];
|
||||
if (mtimes.every((e) => pairs.indexOf(e) !== -1)) {
|
||||
return EVEN;
|
||||
}
|
||||
}
|
||||
export function compareFileFreshness(
|
||||
baseFile: UXFileInfoStub | AnyEntry | undefined,
|
||||
checkTarget: UXFileInfo | AnyEntry | undefined
|
||||
): typeof BASE_IS_NEW | typeof TARGET_IS_NEW | typeof EVEN {
|
||||
if (baseFile === undefined && checkTarget == undefined) return EVEN;
|
||||
if (baseFile == undefined) return TARGET_IS_NEW;
|
||||
if (checkTarget == undefined) return BASE_IS_NEW;
|
||||
|
||||
const modifiedBase = "stat" in baseFile ? (baseFile?.stat?.mtime ?? 0) : (baseFile?.mtime ?? 0);
|
||||
const modifiedTarget = "stat" in checkTarget ? (checkTarget?.stat?.mtime ?? 0) : (checkTarget?.mtime ?? 0);
|
||||
|
||||
if (modifiedBase && modifiedTarget && isMarkedAsSameChanges(baseFile, [modifiedBase, modifiedTarget])) {
|
||||
return EVEN;
|
||||
}
|
||||
return compareMTime(modifiedBase, modifiedTarget);
|
||||
}
|
||||
|
||||
const _cached = new Map<
|
||||
string,
|
||||
{
|
||||
value: any;
|
||||
context: Map<string, any>;
|
||||
}
|
||||
>();
|
||||
|
||||
export type MemoOption = {
|
||||
key: string;
|
||||
forceUpdate?: boolean;
|
||||
validator?: (context: Map<string, any>) => boolean;
|
||||
};
|
||||
|
||||
export function useMemo<T>(
|
||||
{ key, forceUpdate, validator }: MemoOption,
|
||||
updateFunc: (context: Map<string, any>, prev: T) => T
|
||||
): T {
|
||||
const cached = _cached.get(key);
|
||||
const context = cached?.context || new Map<string, any>();
|
||||
if (cached && !forceUpdate && (!validator || (validator && !validator(context)))) {
|
||||
return cached.value;
|
||||
}
|
||||
const value = updateFunc(context, cached?.value);
|
||||
if (value !== cached?.value) {
|
||||
_cached.set(key, { value, context });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// const _static = new Map<string, any>();
|
||||
const _staticObj = new Map<
|
||||
string,
|
||||
{
|
||||
value: any;
|
||||
}
|
||||
>();
|
||||
|
||||
export function useStatic<T>(key: string): { value: T | undefined };
|
||||
export function useStatic<T>(key: string, initial: T): { value: T };
|
||||
export function useStatic<T>(key: string, initial?: T) {
|
||||
// if (!_static.has(key) && initial) {
|
||||
// _static.set(key, initial);
|
||||
// }
|
||||
const obj = _staticObj.get(key);
|
||||
if (obj !== undefined) {
|
||||
return obj;
|
||||
} else {
|
||||
// let buf = initial;
|
||||
const obj = {
|
||||
_buf: initial,
|
||||
get value() {
|
||||
return this._buf as T;
|
||||
},
|
||||
set value(value: T) {
|
||||
this._buf = value;
|
||||
},
|
||||
};
|
||||
_staticObj.set(key, obj);
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
export function disposeMemo(key: string) {
|
||||
_cached.delete(key);
|
||||
}
|
||||
|
||||
export function disposeAllMemo() {
|
||||
_cached.clear();
|
||||
}
|
||||
|
||||
export function displayRev(rev: string) {
|
||||
const [number, hash] = rev.split("-");
|
||||
return `${number}-${hash.substring(0, 6)}`;
|
||||
}
|
||||
|
||||
// export function getPathFromUXFileInfo(file: UXFileInfoStub | UXFileInfo | string) {
|
||||
// return (typeof file == "string" ? file : file.path) as FilePathWithPrefix;
|
||||
// }
|
||||
39
src/deps.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { type FilePath } from "./lib/src/common/types.ts";
|
||||
|
||||
export {
|
||||
addIcon,
|
||||
App,
|
||||
debounce,
|
||||
Editor,
|
||||
FuzzySuggestModal,
|
||||
MarkdownRenderer,
|
||||
MarkdownView,
|
||||
Modal,
|
||||
Notice,
|
||||
Platform,
|
||||
Plugin,
|
||||
PluginSettingTab,
|
||||
requestUrl,
|
||||
sanitizeHTMLToDom,
|
||||
Setting,
|
||||
stringifyYaml,
|
||||
TAbstractFile,
|
||||
TextAreaComponent,
|
||||
TFile,
|
||||
TFolder,
|
||||
parseYaml,
|
||||
ItemView,
|
||||
WorkspaceLeaf,
|
||||
} from "obsidian";
|
||||
export type {
|
||||
DataWriteOptions,
|
||||
PluginManifest,
|
||||
RequestUrlParam,
|
||||
RequestUrlResponse,
|
||||
MarkdownFileInfo,
|
||||
ListedFiles,
|
||||
} from "obsidian";
|
||||
import { normalizePath as normalizePath_ } from "obsidian";
|
||||
const normalizePath = normalizePath_ as <T extends string | FilePath>(from: T) => T;
|
||||
export { normalizePath };
|
||||
export { type Diff, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_patch } from "diff-match-patch";
|
||||
1791
src/features/ConfigSync/CmdConfigSync.ts
Normal file
446
src/features/ConfigSync/PluginCombo.svelte
Normal file
@@ -0,0 +1,446 @@
|
||||
<script lang="ts">
|
||||
import { ConfigSync, PluginDataExDisplayV2, type IPluginDataExDisplay, type PluginDataExFile } from "./CmdConfigSync.ts";
|
||||
import { Logger } from "../../lib/src/common/logger";
|
||||
import { type FilePath, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "../../lib/src/common/types";
|
||||
import { getDocData, timeDeltaToHumanReadable, unique } from "../../lib/src/common/utils";
|
||||
import type ObsidianLiveSyncPlugin from "../../main";
|
||||
// import { askString } from "../../common/utils";
|
||||
import { Menu } from "obsidian";
|
||||
|
||||
export let list: IPluginDataExDisplay[] = [];
|
||||
export let thisTerm = "";
|
||||
export let hideNotApplicable = false;
|
||||
export let selectNewest = 0;
|
||||
export let selectNewestStyle = 0;
|
||||
export let applyAllPluse = 0;
|
||||
|
||||
export let applyData: (data: IPluginDataExDisplay) => Promise<boolean>;
|
||||
export let compareData: (dataA: IPluginDataExDisplay, dataB: IPluginDataExDisplay, compareEach?: boolean) => Promise<boolean>;
|
||||
export let deleteData: (data: IPluginDataExDisplay) => Promise<boolean>;
|
||||
export let hidden: boolean;
|
||||
export let plugin: ObsidianLiveSyncPlugin;
|
||||
export let isMaintenanceMode: boolean = false;
|
||||
export let isFlagged: boolean = false;
|
||||
const addOn = plugin.getAddOn<ConfigSync>(ConfigSync.name)!;
|
||||
if (!addOn) {
|
||||
Logger(`Could not load the add-on ${ConfigSync.name}`, LOG_LEVEL_INFO);
|
||||
throw new Error(`Could not load the add-on ${ConfigSync.name}`);
|
||||
}
|
||||
|
||||
export let selected = "";
|
||||
let freshness = "";
|
||||
let equivalency = "";
|
||||
let version = "";
|
||||
let canApply: boolean = false;
|
||||
let canCompare: boolean = false;
|
||||
let pickToCompare: boolean = false;
|
||||
let currentSelectNewest = 0;
|
||||
let currentApplyAll = 0;
|
||||
|
||||
// Selectable terminals
|
||||
let terms = [] as string[];
|
||||
|
||||
async function comparePlugin(local: IPluginDataExDisplay | undefined, remote: IPluginDataExDisplay | undefined) {
|
||||
let freshness = "";
|
||||
let equivalency = "";
|
||||
let version = "";
|
||||
let contentCheck = false;
|
||||
let canApply: boolean = false;
|
||||
let canCompare = false;
|
||||
if (!local && !remote) {
|
||||
// NO OP. what's happened?
|
||||
freshness = "";
|
||||
} else if (local && !remote) {
|
||||
freshness = "Local only";
|
||||
} else if (remote && !local) {
|
||||
freshness = "Remote only";
|
||||
canApply = true;
|
||||
} else {
|
||||
const dtDiff = (local?.mtime ?? 0) - (remote?.mtime ?? 0);
|
||||
const diff = timeDeltaToHumanReadable(Math.abs(dtDiff));
|
||||
if (dtDiff / 1000 < -10) {
|
||||
// freshness = "✓ Newer";
|
||||
freshness = `Newer (${diff})`;
|
||||
canApply = true;
|
||||
contentCheck = true;
|
||||
} else if (dtDiff / 1000 > 10) {
|
||||
// freshness = "⚠ Older";
|
||||
freshness = `Older (${diff})`;
|
||||
canApply = true;
|
||||
contentCheck = true;
|
||||
} else {
|
||||
freshness = "Same";
|
||||
canApply = false;
|
||||
contentCheck = true;
|
||||
}
|
||||
}
|
||||
const localVersionStr = local?.version || "0.0.0";
|
||||
const remoteVersionStr = remote?.version || "0.0.0";
|
||||
if (local?.version || remote?.version) {
|
||||
const compare = `${localVersionStr}`.localeCompare(remoteVersionStr, undefined, { numeric: true });
|
||||
if (compare == 0) {
|
||||
version = "Same";
|
||||
} else if (compare < 0) {
|
||||
version = `Lower (${localVersionStr} < ${remoteVersionStr})`;
|
||||
} else if (compare > 0) {
|
||||
version = `Higher (${localVersionStr} > ${remoteVersionStr})`;
|
||||
}
|
||||
}
|
||||
|
||||
if (contentCheck) {
|
||||
if (local && remote) {
|
||||
const { canApply, equivalency, canCompare } = await checkEquivalency(local, remote);
|
||||
return { canApply, freshness, equivalency, version, canCompare };
|
||||
}
|
||||
}
|
||||
return { canApply, freshness, equivalency, version, canCompare };
|
||||
}
|
||||
|
||||
async function checkEquivalency(local: IPluginDataExDisplay, remote: IPluginDataExDisplay) {
|
||||
let equivalency = "";
|
||||
let canApply = false;
|
||||
let canCompare = false;
|
||||
const filenames = [...new Set([...local.files.map((e) => e.filename), ...remote.files.map((e) => e.filename)])];
|
||||
const matchingStatus = filenames
|
||||
.map((filename) => {
|
||||
const localFile = local.files.find((e) => e.filename == filename);
|
||||
const remoteFile = remote.files.find((e) => e.filename == filename);
|
||||
if (!localFile && !remoteFile) {
|
||||
return 0b0000000;
|
||||
} else if (localFile && !remoteFile) {
|
||||
return 0b0000010; //"LOCAL_ONLY";
|
||||
} else if (!localFile && remoteFile) {
|
||||
return 0b0001000; //"REMOTE ONLY"
|
||||
} else if (localFile && remoteFile) {
|
||||
const localDoc = getDocData(localFile.data);
|
||||
const remoteDoc = getDocData(remoteFile.data);
|
||||
if (localDoc == remoteDoc) {
|
||||
return 0b0000100; //"EVEN"
|
||||
} else {
|
||||
return 0b0010000; //"DIFFERENT";
|
||||
}
|
||||
} else {
|
||||
return 0b0010000; //"DIFFERENT";
|
||||
}
|
||||
})
|
||||
.reduce((p, c) => p | (c as number), 0 as number);
|
||||
if (matchingStatus == 0b0000100) {
|
||||
equivalency = "Same";
|
||||
canApply = false;
|
||||
} else if (matchingStatus <= 0b0000100) {
|
||||
equivalency = "Same or local only";
|
||||
canApply = false;
|
||||
} else if (matchingStatus == 0b0010000) {
|
||||
canApply = true;
|
||||
canCompare = true;
|
||||
equivalency = "Different";
|
||||
} else {
|
||||
canApply = true;
|
||||
canCompare = true;
|
||||
equivalency = "Mixed";
|
||||
}
|
||||
return { equivalency, canApply, canCompare };
|
||||
}
|
||||
|
||||
async function performCompare(local: IPluginDataExDisplay | undefined, remote: IPluginDataExDisplay | undefined) {
|
||||
const result = await comparePlugin(local, remote);
|
||||
canApply = result.canApply;
|
||||
freshness = result.freshness;
|
||||
equivalency = result.equivalency;
|
||||
version = result.version;
|
||||
canCompare = result.canCompare;
|
||||
pickToCompare = false;
|
||||
if (canCompare) {
|
||||
if (local?.files.length == remote?.files.length && local?.files.length == 1 && local?.files[0].filename == remote?.files[0].filename) {
|
||||
pickToCompare = false;
|
||||
} else {
|
||||
pickToCompare = true;
|
||||
// pickToCompare = false;
|
||||
// canCompare = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTerms(list: IPluginDataExDisplay[], selectNewest: boolean, isMaintenanceMode: boolean) {
|
||||
const local = list.find((e) => e.term == thisTerm);
|
||||
// selected = "";
|
||||
if (isMaintenanceMode) {
|
||||
terms = [...new Set(list.map((e) => e.term))];
|
||||
} else if (hideNotApplicable) {
|
||||
const termsTmp = [];
|
||||
const wk = [...new Set(list.map((e) => e.term))];
|
||||
for (const termName of wk) {
|
||||
const remote = list.find((e) => e.term == termName);
|
||||
if ((await comparePlugin(local, remote)).canApply) {
|
||||
termsTmp.push(termName);
|
||||
}
|
||||
}
|
||||
terms = [...termsTmp];
|
||||
} else {
|
||||
terms = [...new Set(list.map((e) => e.term))].filter((e) => e != thisTerm);
|
||||
}
|
||||
let newest: IPluginDataExDisplay | undefined = local;
|
||||
if (selectNewest) {
|
||||
for (const term of terms) {
|
||||
const remote = list.find((e) => e.term == term);
|
||||
if (remote && remote.mtime && (newest?.mtime || 0) < remote.mtime) {
|
||||
newest = remote;
|
||||
}
|
||||
}
|
||||
if (newest && newest.term != thisTerm) {
|
||||
selected = newest.term;
|
||||
}
|
||||
// selectNewest = false;
|
||||
}
|
||||
if (terms.indexOf(selected) < 0) {
|
||||
selected = "";
|
||||
}
|
||||
}
|
||||
$: {
|
||||
// React pulse and select
|
||||
let doSelectNewest = false;
|
||||
if (selectNewest != currentSelectNewest) {
|
||||
if (selectNewestStyle == 1) {
|
||||
doSelectNewest = true;
|
||||
} else if (selectNewestStyle == 2) {
|
||||
doSelectNewest = isFlagged;
|
||||
} else if (selectNewestStyle == 3) {
|
||||
selected = "";
|
||||
}
|
||||
// currentSelectNewest = selectNewest;
|
||||
}
|
||||
updateTerms(list, doSelectNewest, isMaintenanceMode);
|
||||
currentSelectNewest = selectNewest;
|
||||
}
|
||||
$: {
|
||||
// React pulse and apply
|
||||
const doApply = applyAllPluse != currentApplyAll;
|
||||
currentApplyAll = applyAllPluse;
|
||||
if (doApply && selected) {
|
||||
if (!hidden) {
|
||||
applySelected();
|
||||
}
|
||||
}
|
||||
}
|
||||
$: {
|
||||
freshness = "";
|
||||
equivalency = "";
|
||||
version = "";
|
||||
canApply = false;
|
||||
if (selected == "") {
|
||||
// NO OP.
|
||||
} else if (selected == thisTerm) {
|
||||
freshness = "This device";
|
||||
canApply = false;
|
||||
} else {
|
||||
const local = list.find((e) => e.term == thisTerm);
|
||||
const remote = list.find((e) => e.term == selected);
|
||||
performCompare(local, remote);
|
||||
}
|
||||
}
|
||||
async function applySelected() {
|
||||
const local = list.find((e) => e.term == thisTerm);
|
||||
const selectedItem = list.find((e) => e.term == selected);
|
||||
if (selectedItem && (await applyData(selectedItem))) {
|
||||
addOn.updatePluginList(true, local?.documentPath);
|
||||
}
|
||||
}
|
||||
async function compareSelected() {
|
||||
const local = list.find((e) => e.term == thisTerm);
|
||||
const selectedItem = list.find((e) => e.term == selected);
|
||||
await compareItems(local, selectedItem);
|
||||
}
|
||||
async function compareItems(local: IPluginDataExDisplay | undefined, remote: IPluginDataExDisplay | undefined, filename?: string) {
|
||||
if (local && remote) {
|
||||
if (!filename) {
|
||||
if (await compareData(local, remote)) {
|
||||
addOn.updatePluginList(true, local.documentPath);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
const localCopy = local instanceof PluginDataExDisplayV2 ? new PluginDataExDisplayV2(local) : { ...local };
|
||||
const remoteCopy = remote instanceof PluginDataExDisplayV2 ? new PluginDataExDisplayV2(remote) : { ...remote };
|
||||
localCopy.files = localCopy.files.filter((e) => e.filename == filename);
|
||||
remoteCopy.files = remoteCopy.files.filter((e) => e.filename == filename);
|
||||
if (await compareData(localCopy, remoteCopy, true)) {
|
||||
addOn.updatePluginList(true, local.documentPath);
|
||||
}
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
if (!remote && !local) {
|
||||
Logger(`Could not find both remote and local item`, LOG_LEVEL_INFO);
|
||||
} else if (!remote) {
|
||||
Logger(`Could not find remote item`, LOG_LEVEL_INFO);
|
||||
} else if (!local) {
|
||||
Logger(`Could not locally item`, LOG_LEVEL_INFO);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function pickCompareItem(evt: MouseEvent) {
|
||||
const local = list.find((e) => e.term == thisTerm);
|
||||
const selectedItem = list.find((e) => e.term == selected);
|
||||
if (!local) return;
|
||||
if (!selectedItem) return;
|
||||
const menu = new Menu();
|
||||
menu.addItem((item) => item.setTitle("Compare file").setIsLabel(true));
|
||||
menu.addSeparator();
|
||||
const files = unique(local.files.map((e) => e.filename).concat(selectedItem.files.map((e) => e.filename)));
|
||||
const convDate = (dt: PluginDataExFile | undefined) => {
|
||||
if (!dt) return "(Missing)";
|
||||
const d = new Date(dt.mtime);
|
||||
return d.toLocaleString();
|
||||
};
|
||||
for (const filename of files) {
|
||||
menu.addItem((item) => {
|
||||
const localFile = local.files.find((e) => e.filename == filename);
|
||||
const remoteFile = selectedItem.files.find((e) => e.filename == filename);
|
||||
const title = `${filename} (${convDate(localFile)} <--> ${convDate(remoteFile)})`;
|
||||
item.setTitle(title).onClick((e) => compareItems(local, selectedItem, filename));
|
||||
});
|
||||
}
|
||||
menu.showAtMouseEvent(evt);
|
||||
}
|
||||
async function deleteSelected() {
|
||||
const selectedItem = list.find((e) => e.term == selected);
|
||||
// const deletedPath = selectedItem.documentPath;
|
||||
if (selectedItem && (await deleteData(selectedItem))) {
|
||||
addOn.reloadPluginList(true);
|
||||
}
|
||||
}
|
||||
async function duplicateItem() {
|
||||
const local = list.find((e) => e.term == thisTerm);
|
||||
if (!local) {
|
||||
Logger(`Could not find local item`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
const duplicateTermName = await plugin.confirm.askString("Duplicate", "device name", "");
|
||||
if (duplicateTermName) {
|
||||
if (duplicateTermName.contains("/")) {
|
||||
Logger(`We can not use "/" to the device name`, LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
const key = `${plugin.app.vault.configDir}/${local.files[0].filename}`;
|
||||
await addOn.storeCustomizationFiles(key as FilePath, duplicateTermName);
|
||||
await addOn.updatePluginList(false, addOn.filenameToUnifiedKey(key, duplicateTermName));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if terms.length > 0}
|
||||
<span class="spacer" />
|
||||
{#if !hidden}
|
||||
<span class="chip-wrap">
|
||||
<span class="chip modified">{freshness}</span>
|
||||
<span class="chip content">{equivalency}</span>
|
||||
<span class="chip version">{version}</span>
|
||||
</span>
|
||||
<select bind:value={selected}>
|
||||
<option value={""}>-</option>
|
||||
{#each terms as term}
|
||||
<option value={term}>{term}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if canApply || (isMaintenanceMode && selected != "")}
|
||||
{#if canCompare}
|
||||
{#if pickToCompare}
|
||||
<button on:click={pickCompareItem}>🗃️</button>
|
||||
{:else}
|
||||
<!--🔍 -->
|
||||
<button on:click={compareSelected}>⮂</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<button disabled />
|
||||
{/if}
|
||||
<button on:click={applySelected}>✓</button>
|
||||
{:else}
|
||||
<button disabled />
|
||||
<button disabled />
|
||||
{/if}
|
||||
{#if isMaintenanceMode}
|
||||
{#if selected != ""}
|
||||
<button on:click={deleteSelected}>🗑️</button>
|
||||
{:else}
|
||||
<button on:click={duplicateItem}>📑</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="spacer" />
|
||||
<span class="message even">All the same or non-existent</span>
|
||||
<button disabled />
|
||||
<button disabled />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.spacer {
|
||||
min-width: 1px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
button {
|
||||
margin: 2px 4px;
|
||||
min-width: 3em;
|
||||
max-width: 4em;
|
||||
}
|
||||
button:disabled {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
background-color: transparent;
|
||||
visibility: collapse;
|
||||
}
|
||||
button:disabled:hover {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
background-color: transparent;
|
||||
visibility: collapse;
|
||||
}
|
||||
span.message {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-smaller);
|
||||
padding: 0 1em;
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
/* span.messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
} */
|
||||
:global(.is-mobile) .spacer {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.chip-wrap {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.chip {
|
||||
display: inline-block;
|
||||
border-radius: 2px;
|
||||
font-size: 0.8em;
|
||||
padding: 0 4px;
|
||||
margin: 0 2px;
|
||||
border-color: var(--tag-border-color);
|
||||
background-color: var(--tag-background);
|
||||
color: var(--tag-color);
|
||||
}
|
||||
.chip:empty {
|
||||
display: none;
|
||||
}
|
||||
.chip:not(:empty)::before {
|
||||
min-width: 1.8em;
|
||||
display: inline-block;
|
||||
}
|
||||
.chip.content:not(:empty)::before {
|
||||
content: "📄: ";
|
||||
}
|
||||
.chip.version:not(:empty)::before {
|
||||
content: "🏷️: ";
|
||||
}
|
||||
.chip.modified:not(:empty)::before {
|
||||
content: "📅: ";
|
||||
}
|
||||
</style>
|
||||
36
src/features/ConfigSync/PluginDialogModal.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { App, Modal } from "../../deps.ts";
|
||||
import ObsidianLiveSyncPlugin from "../../main.ts";
|
||||
import PluginPane from "./PluginPane.svelte";
|
||||
export class PluginDialogModal extends Modal {
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
component: PluginPane | undefined;
|
||||
isOpened() {
|
||||
return this.component != undefined;
|
||||
}
|
||||
|
||||
constructor(app: App, plugin: ObsidianLiveSyncPlugin) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
this.contentEl.style.overflow = "auto";
|
||||
this.contentEl.style.display = "flex";
|
||||
this.contentEl.style.flexDirection = "column";
|
||||
this.titleEl.setText("Customization Sync (Beta3)");
|
||||
if (!this.component) {
|
||||
this.component = new PluginPane({
|
||||
target: contentEl,
|
||||
props: { plugin: this.plugin },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onClose() {
|
||||
if (this.component) {
|
||||
this.component.$destroy();
|
||||
this.component = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
591
src/features/ConfigSync/PluginPane.svelte
Normal file
@@ -0,0 +1,591 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import ObsidianLiveSyncPlugin from "../../main";
|
||||
import { ConfigSync, type IPluginDataExDisplay, pluginIsEnumerating, pluginList, pluginManifestStore, pluginV2Progress } from "./CmdConfigSync.ts";
|
||||
import PluginCombo from "./PluginCombo.svelte";
|
||||
import { Menu, type PluginManifest } from "obsidian";
|
||||
import { unique } from "../../lib/src/common/utils";
|
||||
import { MODE_SELECTIVE, MODE_AUTOMATIC, MODE_PAUSED, type SYNC_MODE, type PluginSyncSettingEntry, MODE_SHINY } from "../../lib/src/common/types";
|
||||
import { normalizePath } from "../../deps";
|
||||
import { HiddenFileSync } from "../HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
|
||||
export let plugin: ObsidianLiveSyncPlugin;
|
||||
|
||||
$: hideNotApplicable = false;
|
||||
$: thisTerm = plugin.$$getDeviceAndVaultName();
|
||||
|
||||
const addOn = plugin.getAddOn(ConfigSync.name) as ConfigSync;
|
||||
if (!addOn) {
|
||||
const msg = "AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.";
|
||||
Logger(msg, LOG_LEVEL_NOTICE);
|
||||
throw new Error(msg);
|
||||
}
|
||||
const addOnHiddenFileSync = plugin.getAddOn(HiddenFileSync.name) as HiddenFileSync;
|
||||
if (!addOnHiddenFileSync) {
|
||||
const msg = "AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.";
|
||||
Logger(msg, LOG_LEVEL_NOTICE);
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
let list: IPluginDataExDisplay[] = [];
|
||||
|
||||
let selectNewestPulse = 0;
|
||||
let selectNewestStyle = 0;
|
||||
let hideEven = false;
|
||||
let loading = false;
|
||||
let applyAllPluse = 0;
|
||||
let isMaintenanceMode = false;
|
||||
async function requestUpdate() {
|
||||
await addOn.updatePluginList(true);
|
||||
}
|
||||
async function requestReload() {
|
||||
await addOn.reloadPluginList(true);
|
||||
}
|
||||
let allTerms = [] as string[];
|
||||
pluginList.subscribe((e) => {
|
||||
list = e;
|
||||
allTerms = unique(list.map((e) => e.term));
|
||||
});
|
||||
pluginIsEnumerating.subscribe((e) => {
|
||||
loading = e;
|
||||
});
|
||||
onMount(async () => {
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
function filterList(list: IPluginDataExDisplay[], categories: string[]) {
|
||||
const w = list.filter((e) => categories.indexOf(e.category) !== -1);
|
||||
return w.sort((a, b) => `${a.category}-${a.name}`.localeCompare(`${b.category}-${b.name}`));
|
||||
}
|
||||
|
||||
function groupBy(items: IPluginDataExDisplay[], key: string) {
|
||||
let ret = {} as Record<string, IPluginDataExDisplay[]>;
|
||||
for (const v of items) {
|
||||
//@ts-ignore
|
||||
const k = (key in v ? v[key] : "") as string;
|
||||
ret[k] = ret[k] || [];
|
||||
ret[k].push(v);
|
||||
}
|
||||
for (const k in ret) {
|
||||
ret[k] = ret[k].sort((a, b) => `${a.category}-${a.name}`.localeCompare(`${b.category}-${b.name}`));
|
||||
}
|
||||
const w = Object.entries(ret);
|
||||
return w.sort(([a], [b]) => `${a}`.localeCompare(`${b}`));
|
||||
}
|
||||
|
||||
const displays = {
|
||||
CONFIG: "Configuration",
|
||||
THEME: "Themes",
|
||||
SNIPPET: "Snippets",
|
||||
};
|
||||
async function scanAgain() {
|
||||
await addOn.scanAllConfigFiles(true);
|
||||
await requestUpdate();
|
||||
}
|
||||
async function replicate() {
|
||||
await plugin.$$replicate(true);
|
||||
}
|
||||
function selectAllNewest(selectMode: boolean) {
|
||||
selectNewestPulse++;
|
||||
selectNewestStyle = selectMode ? 1 : 2;
|
||||
}
|
||||
function resetSelectNewest() {
|
||||
selectNewestPulse++;
|
||||
selectNewestStyle = 3;
|
||||
}
|
||||
function applyAll() {
|
||||
applyAllPluse++;
|
||||
}
|
||||
async function applyData(data: IPluginDataExDisplay): Promise<boolean> {
|
||||
return await addOn.applyData(data);
|
||||
}
|
||||
async function compareData(docA: IPluginDataExDisplay, docB: IPluginDataExDisplay, compareEach = false): Promise<boolean> {
|
||||
return await addOn.compareUsingDisplayData(docA, docB, compareEach);
|
||||
}
|
||||
async function deleteData(data: IPluginDataExDisplay): Promise<boolean> {
|
||||
return await addOn.deleteData(data);
|
||||
}
|
||||
function askMode(evt: MouseEvent, title: string, key: string) {
|
||||
const menu = new Menu();
|
||||
menu.addItem((item) => item.setTitle(title).setIsLabel(true));
|
||||
menu.addSeparator();
|
||||
const prevMode = automaticList.get(key) ?? MODE_SELECTIVE;
|
||||
for (const mode of [MODE_SELECTIVE, MODE_AUTOMATIC, MODE_PAUSED, MODE_SHINY]) {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(`${getIcon(mode as SYNC_MODE)}:${TITLES[mode]}`)
|
||||
.onClick((e) => {
|
||||
if (mode === MODE_AUTOMATIC) {
|
||||
askOverwriteModeForAutomatic(evt, key);
|
||||
} else {
|
||||
setMode(key, mode as SYNC_MODE);
|
||||
}
|
||||
})
|
||||
.setChecked(prevMode == mode)
|
||||
.setDisabled(prevMode == mode);
|
||||
});
|
||||
}
|
||||
menu.showAtMouseEvent(evt);
|
||||
}
|
||||
function applyAutomaticSync(key: string, direction: "pushForce" | "pullForce" | "safe") {
|
||||
setMode(key, MODE_AUTOMATIC);
|
||||
const configDir = normalizePath(plugin.app.vault.configDir);
|
||||
const files = (plugin.settings.pluginSyncExtendedSetting[key]?.files ?? []).map((e) => `${configDir}/${e}`);
|
||||
addOnHiddenFileSync.syncInternalFilesAndDatabase(direction, true, false, files);
|
||||
}
|
||||
function askOverwriteModeForAutomatic(evt: MouseEvent, key: string) {
|
||||
const menu = new Menu();
|
||||
menu.addItem((item) => item.setTitle("Initial Action").setIsLabel(true));
|
||||
menu.addSeparator();
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(`↑: Overwrite Remote`).onClick((e) => {
|
||||
applyAutomaticSync(key, "pushForce");
|
||||
});
|
||||
})
|
||||
.addItem((item) => {
|
||||
item.setTitle(`↓: Overwrite Local`).onClick((e) => {
|
||||
applyAutomaticSync(key, "pullForce");
|
||||
});
|
||||
})
|
||||
.addItem((item) => {
|
||||
item.setTitle(`⇅: Use newer`).onClick((e) => {
|
||||
applyAutomaticSync(key, "safe");
|
||||
});
|
||||
});
|
||||
menu.showAtMouseEvent(evt);
|
||||
}
|
||||
|
||||
$: options = {
|
||||
thisTerm,
|
||||
hideNotApplicable,
|
||||
selectNewest: selectNewestPulse,
|
||||
selectNewestStyle,
|
||||
applyAllPluse,
|
||||
applyData,
|
||||
compareData,
|
||||
deleteData,
|
||||
plugin,
|
||||
isMaintenanceMode,
|
||||
};
|
||||
|
||||
const ICON_EMOJI_PAUSED = `⛔`;
|
||||
const ICON_EMOJI_AUTOMATIC = `✨`;
|
||||
const ICON_EMOJI_SELECTIVE = `🔀`;
|
||||
const ICON_EMOJI_FLAGGED = `🚩`;
|
||||
|
||||
const ICONS: { [key: number]: string } = {
|
||||
[MODE_SELECTIVE]: ICON_EMOJI_SELECTIVE,
|
||||
[MODE_PAUSED]: ICON_EMOJI_PAUSED,
|
||||
[MODE_AUTOMATIC]: ICON_EMOJI_AUTOMATIC,
|
||||
[MODE_SHINY]: ICON_EMOJI_FLAGGED,
|
||||
};
|
||||
const TITLES: { [key: number]: string } = {
|
||||
[MODE_SELECTIVE]: "Selective",
|
||||
[MODE_PAUSED]: "Ignore",
|
||||
[MODE_AUTOMATIC]: "Automatic",
|
||||
[MODE_SHINY]: "Flagged Selective",
|
||||
};
|
||||
const PREFIX_PLUGIN_ALL = "PLUGIN_ALL";
|
||||
const PREFIX_PLUGIN_DATA = "PLUGIN_DATA";
|
||||
const PREFIX_PLUGIN_MAIN = "PLUGIN_MAIN";
|
||||
const PREFIX_PLUGIN_ETC = "PLUGIN_ETC";
|
||||
function setMode(key: string, mode: SYNC_MODE) {
|
||||
if (key.startsWith(PREFIX_PLUGIN_ALL + "/")) {
|
||||
setMode(PREFIX_PLUGIN_DATA + key.substring(PREFIX_PLUGIN_ALL.length), mode);
|
||||
setMode(PREFIX_PLUGIN_MAIN + key.substring(PREFIX_PLUGIN_ALL.length), mode);
|
||||
return;
|
||||
}
|
||||
const files = unique(
|
||||
list
|
||||
.filter((e) => `${e.category}/${e.name}` == key)
|
||||
.map((e) => e.files)
|
||||
.flat()
|
||||
.map((e) => e.filename),
|
||||
);
|
||||
if (mode == MODE_SELECTIVE) {
|
||||
automaticList.delete(key);
|
||||
delete plugin.settings.pluginSyncExtendedSetting[key];
|
||||
automaticListDisp = automaticList;
|
||||
} else {
|
||||
automaticList.set(key, mode);
|
||||
automaticListDisp = automaticList;
|
||||
if (!(key in plugin.settings.pluginSyncExtendedSetting)) {
|
||||
plugin.settings.pluginSyncExtendedSetting[key] = {
|
||||
key,
|
||||
mode,
|
||||
files: [],
|
||||
};
|
||||
}
|
||||
plugin.settings.pluginSyncExtendedSetting[key].files = files;
|
||||
plugin.settings.pluginSyncExtendedSetting[key].mode = mode;
|
||||
}
|
||||
plugin.$$saveSettingData();
|
||||
}
|
||||
function getIcon(mode: SYNC_MODE) {
|
||||
if (mode in ICONS) {
|
||||
return ICONS[mode];
|
||||
} else {
|
||||
("");
|
||||
}
|
||||
}
|
||||
let automaticList = new Map<string, SYNC_MODE>();
|
||||
let automaticListDisp = new Map<string, SYNC_MODE>();
|
||||
|
||||
// apply current configuration to the dialogue
|
||||
for (const { key, mode } of Object.values(plugin.settings.pluginSyncExtendedSetting)) {
|
||||
automaticList.set(key, mode);
|
||||
}
|
||||
|
||||
automaticListDisp = automaticList;
|
||||
|
||||
let displayKeys: Record<string, string[]> = {};
|
||||
|
||||
function computeDisplayKeys(list: IPluginDataExDisplay[]) {
|
||||
const extraKeys = Object.keys(plugin.settings.pluginSyncExtendedSetting);
|
||||
return [
|
||||
...list,
|
||||
...extraKeys
|
||||
.map((e) => `${e}///`.split("/"))
|
||||
.filter((e) => e[0] && e[1])
|
||||
.map((e) => ({ category: e[0], name: e[1], displayName: e[1] })),
|
||||
]
|
||||
.sort((a, b) => (a.displayName ?? a.name).localeCompare(b.displayName ?? b.name))
|
||||
.reduce((p, c) => ({ ...p, [c.category]: unique(c.category in p ? [...p[c.category], c.displayName ?? c.name] : [c.displayName ?? c.name]) }), {} as Record<string, string[]>);
|
||||
}
|
||||
$: {
|
||||
displayKeys = computeDisplayKeys(list);
|
||||
}
|
||||
|
||||
let deleteTerm = "";
|
||||
|
||||
async function deleteAllItems(term: string) {
|
||||
const deleteItems = list.filter((e) => e.term == term);
|
||||
for (const item of deleteItems) {
|
||||
await deleteData(item);
|
||||
}
|
||||
addOn.reloadPluginList(true);
|
||||
}
|
||||
|
||||
let nameMap = new Map<string, string>();
|
||||
function updateNameMap(e: Map<string, PluginManifest>) {
|
||||
const items = [...e.entries()].map(([k, v]) => [k.split("/").slice(-2).join("/"), v.name] as [string, string]);
|
||||
const newMap = new Map(items);
|
||||
if (newMap.size == nameMap.size) {
|
||||
let diff = false;
|
||||
for (const [k, v] of newMap) {
|
||||
if (nameMap.get(k) != v) {
|
||||
diff = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!diff) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
nameMap = newMap;
|
||||
}
|
||||
$: updateNameMap($pluginManifestStore);
|
||||
|
||||
let displayEntries = [] as [string, string][];
|
||||
$: {
|
||||
displayEntries = Object.entries(displays).filter(([key, _]) => key in displayKeys);
|
||||
}
|
||||
|
||||
let pluginEntries = [] as [string, IPluginDataExDisplay[]][];
|
||||
$: {
|
||||
pluginEntries = groupBy(filterList(list, ["PLUGIN_MAIN", "PLUGIN_DATA", "PLUGIN_ETC"]), "name");
|
||||
}
|
||||
let useSyncPluginEtc = plugin.settings.usePluginEtc;
|
||||
</script>
|
||||
|
||||
<div class="buttonsWrap">
|
||||
<div class="buttons">
|
||||
<button on:click={() => scanAgain()}>Scan changes</button>
|
||||
<button on:click={() => replicate()}>Sync once</button>
|
||||
<button on:click={() => requestUpdate()}>Refresh</button>
|
||||
{#if isMaintenanceMode}
|
||||
<button on:click={() => requestReload()}>Reload</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<button on:click={() => selectAllNewest(true)}>Select All Shiny</button>
|
||||
<button on:click={() => selectAllNewest(false)}>{ICON_EMOJI_FLAGGED} Select Flagged Shiny</button>
|
||||
<button on:click={() => resetSelectNewest()}>Deselect all</button>
|
||||
<button on:click={() => applyAll()} class="mod-cta">Apply All Selected</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="loading">
|
||||
{#if loading || $pluginV2Progress !== 0}
|
||||
<span>Updating list...{$pluginV2Progress == 0 ? "" : ` (${$pluginV2Progress})`}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="list">
|
||||
{#if list.length == 0}
|
||||
<div class="center">No Items.</div>
|
||||
{:else}
|
||||
{#each displayEntries as [key, label]}
|
||||
<div>
|
||||
<h3>{label}</h3>
|
||||
{#each displayKeys[key] as name}
|
||||
{@const bindKey = `${key}/${name}`}
|
||||
{@const mode = automaticListDisp.get(bindKey) ?? MODE_SELECTIVE}
|
||||
<div class="labelrow {hideEven ? 'hideeven' : ''}">
|
||||
<div class="title">
|
||||
<button class="status" on:click={(evt) => askMode(evt, `${key}/${name}`, bindKey)}>
|
||||
{getIcon(mode)}
|
||||
</button>
|
||||
<span class="name">{(key == "THEME" && nameMap.get(`themes/${name}`)) || name}</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
{#if mode == MODE_SELECTIVE || mode == MODE_SHINY}
|
||||
<PluginCombo {...options} isFlagged={mode == MODE_SHINY} list={list.filter((e) => e.category == key && e.name == name)} hidden={false} />
|
||||
{:else}
|
||||
<div class="statusnote">{TITLES[mode]}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
<div>
|
||||
<h3>Plugins</h3>
|
||||
{#each pluginEntries as [name, listX]}
|
||||
{@const bindKeyAll = `${PREFIX_PLUGIN_ALL}/${name}`}
|
||||
{@const modeAll = automaticListDisp.get(bindKeyAll) ?? MODE_SELECTIVE}
|
||||
{@const bindKeyMain = `${PREFIX_PLUGIN_MAIN}/${name}`}
|
||||
{@const modeMain = automaticListDisp.get(bindKeyMain) ?? MODE_SELECTIVE}
|
||||
{@const bindKeyData = `${PREFIX_PLUGIN_DATA}/${name}`}
|
||||
{@const modeData = automaticListDisp.get(bindKeyData) ?? MODE_SELECTIVE}
|
||||
{@const bindKeyETC = `${PREFIX_PLUGIN_ETC}/${name}`}
|
||||
{@const modeEtc = automaticListDisp.get(bindKeyETC) ?? MODE_SELECTIVE}
|
||||
<div class="labelrow {hideEven ? 'hideeven' : ''}">
|
||||
<div class="title">
|
||||
<button class="status" on:click={(evt) => askMode(evt, `${PREFIX_PLUGIN_ALL}/${name}`, bindKeyAll)}>
|
||||
{getIcon(modeAll)}
|
||||
</button>
|
||||
<span class="name">{nameMap.get(`plugins/${name}`) || name}</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
{#if modeAll == MODE_SELECTIVE || modeAll == MODE_SHINY}
|
||||
<PluginCombo {...options} isFlagged={modeAll == MODE_SHINY} list={listX} hidden={true} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if modeAll == MODE_SELECTIVE || modeAll == MODE_SHINY}
|
||||
<div class="filerow {hideEven ? 'hideeven' : ''}">
|
||||
<div class="filetitle">
|
||||
<button class="status" on:click={(evt) => askMode(evt, `${PREFIX_PLUGIN_MAIN}/${name}/MAIN`, bindKeyMain)}>
|
||||
{getIcon(modeMain)}
|
||||
</button>
|
||||
<span class="name">MAIN</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
{#if modeMain == MODE_SELECTIVE || modeMain == MODE_SHINY}
|
||||
<PluginCombo {...options} isFlagged={modeMain == MODE_SHINY} list={filterList(listX, ["PLUGIN_MAIN"])} hidden={false} />
|
||||
{:else}
|
||||
<div class="statusnote">{TITLES[modeMain]}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="filerow {hideEven ? 'hideeven' : ''}">
|
||||
<div class="filetitle">
|
||||
<button class="status" on:click={(evt) => askMode(evt, `${PREFIX_PLUGIN_DATA}/${name}`, bindKeyData)}>
|
||||
{getIcon(modeData)}
|
||||
</button>
|
||||
<span class="name">DATA</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
{#if modeData == MODE_SELECTIVE || modeData == MODE_SHINY}
|
||||
<PluginCombo {...options} isFlagged={modeData == MODE_SHINY} list={filterList(listX, ["PLUGIN_DATA"])} hidden={false} />
|
||||
{:else}
|
||||
<div class="statusnote">{TITLES[modeData]}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if useSyncPluginEtc}
|
||||
<div class="filerow {hideEven ? 'hideeven' : ''}">
|
||||
<div class="filetitle">
|
||||
<button class="status" on:click={(evt) => askMode(evt, `${PREFIX_PLUGIN_ETC}/${name}`, bindKeyETC)}>
|
||||
{getIcon(modeEtc)}
|
||||
</button>
|
||||
<span class="name">Other files</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
{#if modeEtc == MODE_SELECTIVE || modeEtc == MODE_SHINY}
|
||||
<PluginCombo {...options} isFlagged={modeEtc == MODE_SHINY} list={filterList(listX, ["PLUGIN_ETC"])} hidden={false} />
|
||||
{:else}
|
||||
<div class="statusnote">{TITLES[modeEtc]}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="noterow">
|
||||
<div class="statusnote">{TITLES[modeAll]}</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isMaintenanceMode}
|
||||
<div class="buttons">
|
||||
<div>
|
||||
<h3>Maintenance Commands</h3>
|
||||
<div class="maintenancerow">
|
||||
<label for="">Delete All of </label>
|
||||
<select bind:value={deleteTerm}>
|
||||
{#each allTerms as term}
|
||||
<option value={term}>{term}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
class="status"
|
||||
on:click={(evt) => {
|
||||
deleteAllItems(deleteTerm);
|
||||
}}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="buttons">
|
||||
<label><span>Hide not applicable items</span><input type="checkbox" bind:checked={hideEven} /></label>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<label><span>Maintenance mode</span><input type="checkbox" bind:checked={isMaintenanceMode} /></label>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.buttonsWrap {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
h3 {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: var(--modal-background);
|
||||
}
|
||||
.labelrow {
|
||||
margin-left: 0.4em;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
padding: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filerow {
|
||||
margin-left: 1.25em;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding-right: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filerow.hideeven:has(.even),
|
||||
.labelrow.hideeven:has(.even) {
|
||||
display: none;
|
||||
}
|
||||
.noterow {
|
||||
min-height: 2em;
|
||||
display: flex;
|
||||
}
|
||||
button.status {
|
||||
flex-grow: 0;
|
||||
margin: 2px 4px;
|
||||
min-width: 3em;
|
||||
max-width: 4em;
|
||||
}
|
||||
.statusnote {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-right: var(--size-4-12);
|
||||
align-items: center;
|
||||
min-width: 10em;
|
||||
flex-grow: 1;
|
||||
}
|
||||
.list {
|
||||
overflow-y: auto;
|
||||
}
|
||||
.title {
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-ui-medium);
|
||||
line-height: var(--line-height-tight);
|
||||
margin-right: auto;
|
||||
}
|
||||
.body {
|
||||
/* margin-left: 0.4em; */
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
/* flex-wrap: wrap; */
|
||||
}
|
||||
.filetitle {
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-ui-medium);
|
||||
line-height: var(--line-height-tight);
|
||||
margin-right: auto;
|
||||
}
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.buttons > button {
|
||||
margin-left: 4px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
label > span {
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
:global(.is-mobile) .title,
|
||||
:global(.is-mobile) .filetitle {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 3em;
|
||||
}
|
||||
.maintenancerow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
.maintenancerow label {
|
||||
margin-right: 0.5em;
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
.loading {
|
||||
transition: height 0.25s ease-in-out;
|
||||
transition-delay: 4ms;
|
||||
overflow-y: hidden;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
.loading:empty {
|
||||
height: 0px;
|
||||
transition: height 0.25s ease-in-out;
|
||||
transition-delay: 1s;
|
||||
}
|
||||
.loading:not(:empty) {
|
||||
height: 2em;
|
||||
transition: height 0.25s ease-in-out;
|
||||
transition-delay: 0;
|
||||
}
|
||||
</style>
|
||||
88
src/features/HiddenFileCommon/JsonResolveModal.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { App, Modal } from "../../deps.ts";
|
||||
import { type FilePath, type LoadedEntry } from "../../lib/src/common/types.ts";
|
||||
import JsonResolvePane from "./JsonResolvePane.svelte";
|
||||
import { waitForSignal } from "../../lib/src/common/utils.ts";
|
||||
|
||||
export class JsonResolveModal extends Modal {
|
||||
// result: Array<[number, string]>;
|
||||
filename: FilePath;
|
||||
callback?: (keepRev?: string, mergedStr?: string) => Promise<void>;
|
||||
docs: LoadedEntry[];
|
||||
component?: JsonResolvePane;
|
||||
nameA: string;
|
||||
nameB: string;
|
||||
defaultSelect: string;
|
||||
keepOrder: boolean;
|
||||
hideLocal: boolean;
|
||||
title: string = "Conflicted Setting";
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
filename: FilePath,
|
||||
docs: LoadedEntry[],
|
||||
callback: (keepRev?: string, mergedStr?: string) => Promise<void>,
|
||||
nameA?: string,
|
||||
nameB?: string,
|
||||
defaultSelect?: string,
|
||||
keepOrder?: boolean,
|
||||
hideLocal?: boolean,
|
||||
title: string = "Conflicted Setting"
|
||||
) {
|
||||
super(app);
|
||||
this.callback = callback;
|
||||
this.filename = filename;
|
||||
this.docs = docs;
|
||||
this.nameA = nameA || "";
|
||||
this.nameB = nameB || "";
|
||||
this.keepOrder = keepOrder || false;
|
||||
this.defaultSelect = defaultSelect || "";
|
||||
this.title = title;
|
||||
this.hideLocal = hideLocal ?? false;
|
||||
void waitForSignal(`cancel-internal-conflict:${filename}`).then(() => this.close());
|
||||
}
|
||||
|
||||
async UICallback(keepRev?: string, mergedStr?: string) {
|
||||
if (this.callback) {
|
||||
await this.callback(keepRev, mergedStr);
|
||||
}
|
||||
this.close();
|
||||
this.callback = undefined;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
this.titleEl.setText(this.title);
|
||||
contentEl.empty();
|
||||
|
||||
if (this.component == undefined) {
|
||||
this.component = new JsonResolvePane({
|
||||
target: contentEl,
|
||||
props: {
|
||||
docs: this.docs,
|
||||
filename: this.filename,
|
||||
nameA: this.nameA,
|
||||
nameB: this.nameB,
|
||||
defaultSelect: this.defaultSelect,
|
||||
keepOrder: this.keepOrder,
|
||||
hideLocal: this.hideLocal,
|
||||
callback: (keepRev: string | undefined, mergedStr: string | undefined) =>
|
||||
this.UICallback(keepRev, mergedStr),
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
// contentEl.empty();
|
||||
if (this.callback != undefined) {
|
||||
void this.callback(undefined);
|
||||
}
|
||||
if (this.component != undefined) {
|
||||
this.component.$destroy();
|
||||
this.component = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
214
src/features/HiddenFileCommon/JsonResolvePane.svelte
Normal file
@@ -0,0 +1,214 @@
|
||||
<script lang="ts">
|
||||
import { type Diff, DIFF_DELETE, DIFF_INSERT, diff_match_patch } from "../../deps.ts";
|
||||
import type { FilePath, LoadedEntry } from "../../lib/src/common/types.ts";
|
||||
import { decodeBinary, readString } from "../../lib/src/string_and_binary/convert.ts";
|
||||
import { getDocData, mergeObject } from "../../lib/src/common/utils.ts";
|
||||
|
||||
export let docs: LoadedEntry[] = [];
|
||||
export let callback: (keepRev?: string, mergedStr?: string) => Promise<void> = async (_, __) => {
|
||||
Promise.resolve();
|
||||
};
|
||||
export let filename: FilePath = "" as FilePath;
|
||||
export let nameA: string = "A";
|
||||
export let nameB: string = "B";
|
||||
export let defaultSelect: string = "";
|
||||
export let keepOrder = false;
|
||||
export let hideLocal: boolean = false;
|
||||
let docA: LoadedEntry;
|
||||
let docB: LoadedEntry;
|
||||
let docAContent = "";
|
||||
let docBContent = "";
|
||||
let objA: any = {};
|
||||
let objB: any = {};
|
||||
let objAB: any = {};
|
||||
let objBA: any = {};
|
||||
let diffs: Diff[];
|
||||
type SelectModes = "" | "A" | "B" | "AB" | "BA";
|
||||
let mode: SelectModes = defaultSelect as SelectModes;
|
||||
|
||||
function docToString(doc: LoadedEntry) {
|
||||
return doc.datatype == "plain" ? getDocData(doc.data) : readString(new Uint8Array(decodeBinary(doc.data)));
|
||||
}
|
||||
function revStringToRevNumber(rev?: string) {
|
||||
if (!rev) return "";
|
||||
return rev.split("-")[0];
|
||||
}
|
||||
|
||||
function getDiff(left: string, right: string) {
|
||||
const dmp = new diff_match_patch();
|
||||
const mapLeft = dmp.diff_linesToChars_(left, right);
|
||||
const diffLeftSrc = dmp.diff_main(mapLeft.chars1, mapLeft.chars2, false);
|
||||
dmp.diff_charsToLines_(diffLeftSrc, mapLeft.lineArray);
|
||||
return diffLeftSrc;
|
||||
}
|
||||
function getJsonDiff(a: object, b: object) {
|
||||
return getDiff(JSON.stringify(a, null, 2), JSON.stringify(b, null, 2));
|
||||
}
|
||||
function apply() {
|
||||
if (docA._id == docB._id) {
|
||||
if (mode == "A") return callback(docA._rev!, undefined);
|
||||
if (mode == "B") return callback(docB._rev!, undefined);
|
||||
} else {
|
||||
if (mode == "A") return callback(undefined, docToString(docA));
|
||||
if (mode == "B") return callback(undefined, docToString(docB));
|
||||
}
|
||||
if (mode == "BA") return callback(undefined, JSON.stringify(objBA, null, 2));
|
||||
if (mode == "AB") return callback(undefined, JSON.stringify(objAB, null, 2));
|
||||
callback(undefined, undefined);
|
||||
}
|
||||
function cancel() {
|
||||
callback(undefined, undefined);
|
||||
}
|
||||
$: {
|
||||
if (docs && docs.length >= 1) {
|
||||
if (keepOrder || docs[0].mtime < docs[1].mtime) {
|
||||
docA = docs[0];
|
||||
docB = docs[1];
|
||||
} else {
|
||||
docA = docs[1];
|
||||
docB = docs[0];
|
||||
}
|
||||
docAContent = docToString(docA);
|
||||
docBContent = docToString(docB);
|
||||
|
||||
try {
|
||||
objA = false;
|
||||
objB = false;
|
||||
objA = JSON.parse(docAContent);
|
||||
objB = JSON.parse(docBContent);
|
||||
objAB = mergeObject(objA, objB);
|
||||
objBA = mergeObject(objB, objA);
|
||||
if (JSON.stringify(objAB) == JSON.stringify(objBA)) {
|
||||
objBA = false;
|
||||
}
|
||||
} catch (ex) {
|
||||
objBA = false;
|
||||
objAB = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
$: mergedObjs = {
|
||||
"": false,
|
||||
A: objA,
|
||||
B: objB,
|
||||
AB: objAB,
|
||||
BA: objBA,
|
||||
};
|
||||
|
||||
$: selectedObj = mode in mergedObjs ? mergedObjs[mode] : {};
|
||||
$: {
|
||||
diffs = getJsonDiff(objA, selectedObj);
|
||||
}
|
||||
|
||||
let modes = [] as ["" | "A" | "B" | "AB" | "BA", string][];
|
||||
$: {
|
||||
let newModes = [] as typeof modes;
|
||||
|
||||
if (!hideLocal) {
|
||||
newModes.push(["", "Not now"]);
|
||||
newModes.push(["A", nameA || "A"]);
|
||||
}
|
||||
newModes.push(["B", nameB || "B"]);
|
||||
newModes.push(["AB", `${nameA || "A"} + ${nameB || "B"}`]);
|
||||
newModes.push(["BA", `${nameB || "B"} + ${nameA || "A"}`]);
|
||||
modes = newModes;
|
||||
}
|
||||
</script>
|
||||
|
||||
<h2>{filename}</h2>
|
||||
{#if !docA || !docB}
|
||||
<div class="message">Just for a minute, please!</div>
|
||||
<div class="buttons">
|
||||
<button on:click={apply}>Dismiss</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="options">
|
||||
{#each modes as m}
|
||||
{#if m[0] == "" || mergedObjs[m[0]] != false}
|
||||
<label class={`sls-setting-label ${m[0] == mode ? "selected" : ""}`}
|
||||
><input type="radio" name="disp" bind:group={mode} value={m[0]} class="sls-setting-tab" />
|
||||
<div class="sls-setting-menu-btn">{m[1]}</div></label
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if selectedObj != false}
|
||||
<div class="op-scrollable json-source">
|
||||
{#each diffs as diff}
|
||||
<span class={diff[0] == DIFF_DELETE ? "deleted" : diff[0] == DIFF_INSERT ? "added" : "normal"}>{diff[1]}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
NO PREVIEW
|
||||
{/if}
|
||||
|
||||
<div class="infos">
|
||||
<table>
|
||||
<tr>
|
||||
<th>{nameA}</th>
|
||||
<td
|
||||
>{#if docA._id == docB._id}
|
||||
Rev:{revStringToRevNumber(docA._rev)}
|
||||
{/if}
|
||||
{new Date(docA.mtime).toLocaleString()}</td
|
||||
>
|
||||
<td>
|
||||
{docAContent.length} letters
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{nameB}</th>
|
||||
<td
|
||||
>{#if docA._id == docB._id}
|
||||
Rev:{revStringToRevNumber(docB._rev)}
|
||||
{/if}
|
||||
{new Date(docB.mtime).toLocaleString()}</td
|
||||
>
|
||||
<td>
|
||||
{docBContent.length} letters
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
{#if hideLocal}
|
||||
<button on:click={cancel}>Cancel</button>
|
||||
{/if}
|
||||
<button on:click={apply}>Apply</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.infos {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 4px 0.5em;
|
||||
}
|
||||
|
||||
.deleted {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.scroller {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: scroll;
|
||||
max-height: 60vh;
|
||||
user-select: text;
|
||||
}
|
||||
.json-source {
|
||||
white-space: pre;
|
||||
height: auto;
|
||||
overflow: auto;
|
||||
min-height: var(--font-ui-medium);
|
||||
flex-grow: 1;
|
||||
}
|
||||
</style>
|
||||
1193
src/features/HiddenFileSync/CmdHiddenFileSync.ts
Normal file
60
src/features/LiveSyncCommands.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Logger } from "octagonal-wheels/common/logger";
|
||||
import { getPath } from "../common/utils.ts";
|
||||
import {
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
type AnyEntry,
|
||||
type DocumentID,
|
||||
type EntryHasPath,
|
||||
type FilePath,
|
||||
type FilePathWithPrefix,
|
||||
type LOG_LEVEL,
|
||||
} from "../lib/src/common/types.ts";
|
||||
import type ObsidianLiveSyncPlugin from "../main.ts";
|
||||
|
||||
export abstract class LiveSyncCommands {
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
get app() {
|
||||
return this.plugin.app;
|
||||
}
|
||||
get settings() {
|
||||
return this.plugin.settings;
|
||||
}
|
||||
get localDatabase() {
|
||||
return this.plugin.localDatabase;
|
||||
}
|
||||
|
||||
id2path(id: DocumentID, entry?: EntryHasPath, stripPrefix?: boolean): FilePathWithPrefix {
|
||||
return this.plugin.$$id2path(id, entry, stripPrefix);
|
||||
}
|
||||
async path2id(filename: FilePathWithPrefix | FilePath, prefix?: string): Promise<DocumentID> {
|
||||
return await this.plugin.$$path2id(filename, prefix);
|
||||
}
|
||||
getPath(entry: AnyEntry): FilePathWithPrefix {
|
||||
return getPath(entry);
|
||||
}
|
||||
|
||||
constructor(plugin: ObsidianLiveSyncPlugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
abstract onunload(): void;
|
||||
abstract onload(): void | Promise<void>;
|
||||
|
||||
_isMainReady() {
|
||||
return this.plugin.$$isReady();
|
||||
}
|
||||
_isMainSuspended() {
|
||||
return this.plugin.$$isSuspended();
|
||||
}
|
||||
_isDatabaseReady() {
|
||||
return this.plugin.$$isDatabaseReady();
|
||||
}
|
||||
|
||||
_log = (msg: any, level: LOG_LEVEL = LOG_LEVEL_INFO, key?: string) => {
|
||||
if (typeof msg === "string" && level !== LOG_LEVEL_NOTICE) {
|
||||
msg = `[${this.constructor.name}]\u{200A} ${msg}`;
|
||||
}
|
||||
// console.log(msg);
|
||||
Logger(msg, level, key);
|
||||
};
|
||||
}
|
||||
2
src/lib
3042
src/main.ts
188
src/modules/AbstractModule.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, Logger } from "octagonal-wheels/common/logger";
|
||||
import type { LOG_LEVEL } from "../lib/src/common/types";
|
||||
import type { LiveSyncCore } from "../main";
|
||||
import { unique } from "octagonal-wheels/collection";
|
||||
import type { IObsidianModule } from "./AbstractObsidianModule.ts";
|
||||
import type {
|
||||
ICoreModuleBase,
|
||||
AllInjectableProps,
|
||||
AllExecuteProps,
|
||||
EveryExecuteProps,
|
||||
AnyExecuteProps,
|
||||
ICoreModule,
|
||||
} from "./ModuleTypes";
|
||||
|
||||
function isOverridableKey(key: string): key is keyof ICoreModuleBase {
|
||||
return key.startsWith("$");
|
||||
}
|
||||
|
||||
function isInjectableKey(key: string): key is keyof AllInjectableProps {
|
||||
return key.startsWith("$$");
|
||||
}
|
||||
|
||||
function isAllExecuteKey(key: string): key is keyof AllExecuteProps {
|
||||
return key.startsWith("$all");
|
||||
}
|
||||
function isEveryExecuteKey(key: string): key is keyof EveryExecuteProps {
|
||||
return key.startsWith("$every");
|
||||
}
|
||||
function isAnyExecuteKey(key: string): key is keyof AnyExecuteProps {
|
||||
return key.startsWith("$any");
|
||||
}
|
||||
/**
|
||||
* All $prefixed functions are hooked by the modules. Be careful to call them directly.
|
||||
* Please refer to the module's source code to understand the function.
|
||||
* $$ : Completely overridden functions.
|
||||
* $all : Process all modules and return all results.
|
||||
* $every : Process all modules until the first failure.
|
||||
* $any : Process all modules until the first success.
|
||||
* $ : Other interceptive points. You should manually assign the module
|
||||
* All of above performed on injectModules function.
|
||||
*/
|
||||
export function injectModules<T extends ICoreModule>(target: T, modules: ICoreModule[]) {
|
||||
const allKeys = unique([
|
||||
...Object.keys(Object.getOwnPropertyDescriptors(target)),
|
||||
...Object.keys(Object.getOwnPropertyDescriptors(Object.getPrototypeOf(target))),
|
||||
]).filter((e) => e.startsWith("$")) as (keyof ICoreModule)[];
|
||||
const moduleMap = new Map<string, IObsidianModule[]>();
|
||||
for (const module of modules) {
|
||||
for (const key of allKeys) {
|
||||
if (isOverridableKey(key)) {
|
||||
if (key in module) {
|
||||
const list = moduleMap.get(key) || [];
|
||||
if (typeof module[key] === "function") {
|
||||
module[key] = module[key].bind(module) as any;
|
||||
}
|
||||
list.push(module);
|
||||
moduleMap.set(key, list);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Logger(`Injecting modules for ${target.constructor.name}`, LOG_LEVEL_VERBOSE);
|
||||
for (const key of allKeys) {
|
||||
const modules = moduleMap.get(key) || [];
|
||||
if (isInjectableKey(key)) {
|
||||
if (modules.length == 0) {
|
||||
throw new Error(`No module injected for ${key}. This is a fatal error.`);
|
||||
}
|
||||
target[key] = modules[0][key]! as any;
|
||||
Logger(`[${modules[0].constructor.name}]: Injected ${key} `, LOG_LEVEL_VERBOSE);
|
||||
} else if (isAllExecuteKey(key)) {
|
||||
const modules = moduleMap.get(key) || [];
|
||||
target[key] = async (...args: any) => {
|
||||
for (const module of modules) {
|
||||
try {
|
||||
//@ts-ignore
|
||||
await module[key]!(...args);
|
||||
} catch (ex) {
|
||||
Logger(`[${module.constructor.name}]: All handler for ${key} failed`, LOG_LEVEL_VERBOSE);
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
for (const module of modules) {
|
||||
Logger(`[${module.constructor.name}]: Injected (All) ${key} `, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
} else if (isEveryExecuteKey(key)) {
|
||||
target[key] = async (...args: any) => {
|
||||
for (const module of modules) {
|
||||
try {
|
||||
//@ts-ignore:2556
|
||||
const ret = await module[key]!(...args);
|
||||
if (ret !== undefined && !ret) {
|
||||
// Failed then return that falsy value.
|
||||
return ret;
|
||||
}
|
||||
} catch (ex) {
|
||||
Logger(`[${module.constructor.name}]: Every handler for ${key} failed`);
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
for (const module of modules) {
|
||||
Logger(`[${module.constructor.name}]: Injected (Every) ${key} `, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
} else if (isAnyExecuteKey(key)) {
|
||||
//@ts-ignore
|
||||
target[key] = async (...args: any[]) => {
|
||||
for (const module of modules) {
|
||||
try {
|
||||
//@ts-ignore:2556
|
||||
const ret = await module[key](...args);
|
||||
// If truly value returned, then return that value.
|
||||
if (ret) {
|
||||
return ret;
|
||||
}
|
||||
} catch (ex) {
|
||||
Logger(`[${module.constructor.name}]: Any handler for ${key} failed`);
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
for (const module of modules) {
|
||||
Logger(`[${module.constructor.name}]: Injected (Any) ${key} `, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
} else {
|
||||
Logger(`No injected handler for ${key} `, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
}
|
||||
Logger(`Injected modules for ${target.constructor.name}`, LOG_LEVEL_VERBOSE);
|
||||
return true;
|
||||
}
|
||||
|
||||
export abstract class AbstractModule {
|
||||
_log = (msg: any, level: LOG_LEVEL = LOG_LEVEL_INFO, key?: string) => {
|
||||
if (typeof msg === "string" && level !== LOG_LEVEL_NOTICE) {
|
||||
msg = `[${this.constructor.name}]\u{200A} ${msg}`;
|
||||
}
|
||||
// console.log(msg);
|
||||
Logger(msg, level, key);
|
||||
};
|
||||
|
||||
get localDatabase() {
|
||||
return this.core.localDatabase;
|
||||
}
|
||||
get settings() {
|
||||
return this.core.settings;
|
||||
}
|
||||
set settings(value) {
|
||||
this.core.settings = value;
|
||||
}
|
||||
|
||||
constructor(public core: LiveSyncCore) {
|
||||
Logger(`[${this.constructor.name}] Loaded`, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
saveSettings = this.core.saveSettings.bind(this.core);
|
||||
|
||||
// abstract $everyTest(): Promise<boolean>;
|
||||
addTestResult(key: string, value: boolean, summary?: string, message?: string) {
|
||||
this.core.$$addTestResult(`${this.constructor.name}`, key, value, summary, message);
|
||||
}
|
||||
testDone(result: boolean = true) {
|
||||
return Promise.resolve(result);
|
||||
}
|
||||
testFail(message: string) {
|
||||
this._log(message, LOG_LEVEL_NOTICE);
|
||||
return this.testDone(false);
|
||||
}
|
||||
|
||||
async _test(key: string, process: () => Promise<any>) {
|
||||
this._log(`Testing ${key}`, LOG_LEVEL_VERBOSE);
|
||||
try {
|
||||
const ret = await process();
|
||||
if (ret !== true) {
|
||||
this.addTestResult(key, false, ret.toString());
|
||||
return this.testFail(`${key} failed: ${ret}`);
|
||||
}
|
||||
this.addTestResult(key, true, "");
|
||||
} catch (ex: any) {
|
||||
this.addTestResult(key, false, "Failed by Exception", ex.toString());
|
||||
return this.testFail(`${key} failed: ${ex}`);
|
||||
}
|
||||
return this.testDone();
|
||||
}
|
||||
}
|
||||
54
src/modules/AbstractObsidianModule.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { type Prettify } from "../lib/src/common/types";
|
||||
import type { LiveSyncCore } from "../main";
|
||||
import type ObsidianLiveSyncPlugin from "../main";
|
||||
import { AbstractModule } from "./AbstractModule.ts";
|
||||
import type { ChainableExecuteFunction, OverridableFunctionsKeys } from "./ModuleTypes";
|
||||
|
||||
export type IObsidianModuleBase = OverridableFunctionsKeys<ObsidianLiveSyncPlugin>;
|
||||
export type IObsidianModule = Prettify<Partial<IObsidianModuleBase>>;
|
||||
export type ModuleKeys = keyof IObsidianModule;
|
||||
export type ChainableModuleProps = ChainableExecuteFunction<ObsidianLiveSyncPlugin>;
|
||||
|
||||
export abstract class AbstractObsidianModule extends AbstractModule {
|
||||
addCommand = this.plugin.addCommand.bind(this.plugin);
|
||||
registerView = this.plugin.registerView.bind(this.plugin);
|
||||
addRibbonIcon = this.plugin.addRibbonIcon.bind(this.plugin);
|
||||
registerObsidianProtocolHandler = this.plugin.registerObsidianProtocolHandler.bind(this.plugin);
|
||||
|
||||
get localDatabase() {
|
||||
return this.plugin.localDatabase;
|
||||
}
|
||||
get settings() {
|
||||
return this.plugin.settings;
|
||||
}
|
||||
set settings(value) {
|
||||
this.plugin.settings = value;
|
||||
}
|
||||
get app() {
|
||||
return this.plugin.app;
|
||||
}
|
||||
|
||||
constructor(
|
||||
public plugin: ObsidianLiveSyncPlugin,
|
||||
public core: LiveSyncCore
|
||||
) {
|
||||
super(core);
|
||||
}
|
||||
|
||||
saveSettings = this.plugin.saveSettings.bind(this.plugin);
|
||||
|
||||
_isMainReady() {
|
||||
return this.core.$$isReady();
|
||||
}
|
||||
_isMainSuspended() {
|
||||
return this.core.$$isSuspended();
|
||||
}
|
||||
_isDatabaseReady() {
|
||||
return this.core.$$isDatabaseReady();
|
||||
}
|
||||
|
||||
//should be overridden
|
||||
_isThisModuleEnabled() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
55
src/modules/ModuleTypes.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { Prettify } from "../lib/src/common/types";
|
||||
import type { LiveSyncCore } from "../main";
|
||||
|
||||
export type OverridableFunctionsKeys<T> = {
|
||||
[K in keyof T as K extends `$${string}` ? K : never]: T[K];
|
||||
};
|
||||
|
||||
export type ChainableExecuteFunction<T> = {
|
||||
[K in keyof T as K extends `$${string}`
|
||||
? T[K] extends (...args: any) => ChainableFunctionResult
|
||||
? K
|
||||
: never
|
||||
: never]: T[K];
|
||||
};
|
||||
|
||||
export type ICoreModuleBase = OverridableFunctionsKeys<LiveSyncCore>;
|
||||
export type ICoreModule = Prettify<Partial<ICoreModuleBase>>;
|
||||
export type CoreModuleKeys = keyof ICoreModule;
|
||||
|
||||
export type ChainableFunctionResult =
|
||||
| Promise<boolean | undefined | string>
|
||||
| Promise<boolean | undefined>
|
||||
| Promise<boolean>
|
||||
| Promise<void>;
|
||||
export type ChainableFunctionResultOrAll = Promise<boolean | undefined | string | void>;
|
||||
|
||||
type AllExecuteFunction<T> = {
|
||||
[K in keyof T as K extends `$all${string}`
|
||||
? T[K] extends (...args: any[]) => ChainableFunctionResultOrAll
|
||||
? K
|
||||
: never
|
||||
: never]: T[K];
|
||||
};
|
||||
type EveryExecuteFunction<T> = {
|
||||
[K in keyof T as K extends `$every${string}`
|
||||
? T[K] extends (...args: any[]) => ChainableFunctionResult
|
||||
? K
|
||||
: never
|
||||
: never]: T[K];
|
||||
};
|
||||
type AnyExecuteFunction<T> = {
|
||||
[K in keyof T as K extends `$any${string}`
|
||||
? T[K] extends (...args: any[]) => ChainableFunctionResult
|
||||
? K
|
||||
: never
|
||||
: never]: T[K];
|
||||
};
|
||||
type InjectableFunction<T> = {
|
||||
[K in keyof T as K extends `$$${string}` ? (T[K] extends (...args: any[]) => any ? K : never) : never]: T[K];
|
||||
};
|
||||
export type AllExecuteProps = AllExecuteFunction<LiveSyncCore>;
|
||||
export type EveryExecuteProps = EveryExecuteFunction<LiveSyncCore>;
|
||||
export type AnyExecuteProps = AnyExecuteFunction<LiveSyncCore>;
|
||||
|
||||
export type AllInjectableProps = InjectableFunction<LiveSyncCore>;
|
||||
325
src/modules/core/ModuleDatabaseFileAccess.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import { LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import { EVENT_FILE_SAVED, eventHub } from "../../common/events";
|
||||
import { getPathFromUXFileInfo, isInternalMetadata, markChangesAreSame } from "../../common/utils";
|
||||
import type {
|
||||
UXFileInfoStub,
|
||||
FilePathWithPrefix,
|
||||
UXFileInfo,
|
||||
MetaEntry,
|
||||
LoadedEntry,
|
||||
FilePath,
|
||||
SavingEntry,
|
||||
} from "../../lib/src/common/types";
|
||||
import type { DatabaseFileAccess } from "../interfaces/DatabaseFileAccess";
|
||||
import { type IObsidianModule } from "../AbstractObsidianModule.ts";
|
||||
import { isPlainText, shouldBeIgnored } from "../../lib/src/string_and_binary/path";
|
||||
import {
|
||||
createBlob,
|
||||
createTextBlob,
|
||||
delay,
|
||||
determineTypeFromBlob,
|
||||
isDocContentSame,
|
||||
readContent,
|
||||
} from "../../lib/src/common/utils";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
|
||||
export class ModuleDatabaseFileAccess extends AbstractModule implements IObsidianModule, DatabaseFileAccess {
|
||||
$everyOnload(): Promise<boolean> {
|
||||
this.core.databaseFileAccess = this;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
async $everyModuleTest(): Promise<boolean> {
|
||||
if (!this.settings.enableDebugTools) return Promise.resolve(true);
|
||||
const testString = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam nec purus nec nunc";
|
||||
// Before test, we need to delete completely.
|
||||
const conflicts = await this.getConflictedRevs("autoTest.md" as FilePathWithPrefix);
|
||||
for (const rev of conflicts) {
|
||||
await this.delete("autoTest.md" as FilePathWithPrefix, rev);
|
||||
}
|
||||
await this.delete("autoTest.md" as FilePathWithPrefix);
|
||||
// OK, begin!
|
||||
|
||||
await this._test(
|
||||
"storeContent",
|
||||
async () => await this.storeContent("autoTest.md" as FilePathWithPrefix, testString)
|
||||
);
|
||||
// For test, we need to clear the caches.
|
||||
await this.localDatabase.hashCaches.clear();
|
||||
await this._test("readContent", async () => {
|
||||
const content = await this.fetch("autoTest.md" as FilePathWithPrefix);
|
||||
if (!content) return "File not found";
|
||||
if (content.deleted) return "File is deleted";
|
||||
return (await content.body.text()) == testString
|
||||
? true
|
||||
: `Content is not same ${await content.body.text()}`;
|
||||
});
|
||||
await this._test("delete", async () => await this.delete("autoTest.md" as FilePathWithPrefix));
|
||||
await this._test("read deleted content", async () => {
|
||||
const content = await this.fetch("autoTest.md" as FilePathWithPrefix);
|
||||
if (!content) return true;
|
||||
if (content.deleted) return true;
|
||||
return `Still exist !:${await content.body.text()},${JSON.stringify(content, undefined, 2)}`;
|
||||
});
|
||||
await delay(100);
|
||||
return this.testDone();
|
||||
}
|
||||
|
||||
async checkIsTargetFile(file: UXFileInfoStub | FilePathWithPrefix): Promise<boolean> {
|
||||
const path = getPathFromUXFileInfo(file);
|
||||
if (!(await this.core.$$isTargetFile(path))) {
|
||||
this._log(`File is not target`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
if (shouldBeIgnored(path)) {
|
||||
this._log(`File should be ignored`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async delete(file: UXFileInfoStub | FilePathWithPrefix, rev?: string): Promise<boolean> {
|
||||
if (!(await this.checkIsTargetFile(file))) {
|
||||
return true;
|
||||
}
|
||||
const fullPath = getPathFromUXFileInfo(file);
|
||||
try {
|
||||
this._log(`deleteDB By path:${fullPath}`);
|
||||
return await this.deleteFromDBbyPath(fullPath, rev);
|
||||
} catch (ex) {
|
||||
this._log(`Failed to delete ${fullPath}`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async createChunks(file: UXFileInfo, force: boolean = false, skipCheck?: boolean): Promise<boolean> {
|
||||
return await this._store(file, force, skipCheck, true);
|
||||
}
|
||||
|
||||
async store(file: UXFileInfo, force: boolean = false, skipCheck?: boolean): Promise<boolean> {
|
||||
return await this._store(file, force, skipCheck, false);
|
||||
}
|
||||
async storeContent(path: FilePathWithPrefix, content: string): Promise<boolean> {
|
||||
const blob = createTextBlob(content);
|
||||
const bytes = (await blob.arrayBuffer()).byteLength;
|
||||
const dummyUXFileInfo: UXFileInfo = {
|
||||
name: path.split("/").pop() as string,
|
||||
path: path,
|
||||
stat: {
|
||||
size: bytes,
|
||||
ctime: Date.now(),
|
||||
mtime: Date.now(),
|
||||
type: "file",
|
||||
},
|
||||
body: blob,
|
||||
};
|
||||
return await this._store(dummyUXFileInfo, true, false, false);
|
||||
}
|
||||
|
||||
async _store(
|
||||
file: UXFileInfo,
|
||||
force: boolean = false,
|
||||
skipCheck?: boolean,
|
||||
onlyChunks?: boolean
|
||||
): Promise<boolean> {
|
||||
if (!skipCheck) {
|
||||
if (!(await this.checkIsTargetFile(file))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!file) {
|
||||
this._log("File seems bad", LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
const path = getPathFromUXFileInfo(file);
|
||||
|
||||
const isPlain = isPlainText(file.name);
|
||||
const possiblyLarge = !isPlain;
|
||||
const content = file.body;
|
||||
if (possiblyLarge) this._log(`Processing: ${path}`, LOG_LEVEL_VERBOSE);
|
||||
const datatype = determineTypeFromBlob(content);
|
||||
const fullPath = file.path;
|
||||
const id = await this.core.$$path2id(fullPath);
|
||||
const d: SavingEntry = {
|
||||
_id: id,
|
||||
path: file.path,
|
||||
data: content,
|
||||
ctime: file.stat.ctime,
|
||||
mtime: file.stat.mtime,
|
||||
size: file.stat.size,
|
||||
children: [],
|
||||
datatype: datatype,
|
||||
type: datatype,
|
||||
eden: {},
|
||||
};
|
||||
//upsert should locked
|
||||
const msg = `STORAGE -> DB (${datatype}) `;
|
||||
const isNotChanged = await serialized("file-" + fullPath, async () => {
|
||||
if (force) {
|
||||
this._log(msg + "Force writing " + fullPath, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
// Commented out temporarily: this checks that the file was made ourself.
|
||||
// if (this.core.storageAccess.recentlyTouched(file)) {
|
||||
// return true;
|
||||
// }
|
||||
try {
|
||||
const old = await this.localDatabase.getDBEntry(fullPath, undefined, false, true, false);
|
||||
if (old !== false) {
|
||||
const oldData = { data: old.data, deleted: old._deleted || old.deleted };
|
||||
const newData = { data: d.data, deleted: d._deleted || d.deleted };
|
||||
if (oldData.deleted != newData.deleted) return false;
|
||||
if (!(await isDocContentSame(old.data, newData.data))) return false;
|
||||
this._log(
|
||||
msg + "Skipped (not changed) " + fullPath + (d._deleted || d.deleted ? " (deleted)" : ""),
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
markChangesAreSame(old, d.mtime, old.mtime);
|
||||
return true;
|
||||
// d._rev = old._rev;
|
||||
}
|
||||
} catch (ex) {
|
||||
if (force) {
|
||||
this._log(
|
||||
msg +
|
||||
"Error, Could not check the diff for the old one." +
|
||||
(force ? "force writing." : "") +
|
||||
fullPath +
|
||||
(d._deleted || d.deleted ? " (deleted)" : ""),
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
} else {
|
||||
this._log(
|
||||
msg +
|
||||
"Error, Could not check the diff for the old one." +
|
||||
fullPath +
|
||||
(d._deleted || d.deleted ? " (deleted)" : ""),
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
}
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
return !force;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (isNotChanged) {
|
||||
this._log(msg + " Skip " + fullPath, LOG_LEVEL_VERBOSE);
|
||||
return true;
|
||||
}
|
||||
const ret = await this.localDatabase.putDBEntry(d, onlyChunks);
|
||||
if (ret !== false) {
|
||||
this._log(msg + fullPath);
|
||||
eventHub.emitEvent(EVENT_FILE_SAVED);
|
||||
}
|
||||
return ret != false;
|
||||
}
|
||||
|
||||
async getConflictedRevs(file: UXFileInfoStub | FilePathWithPrefix): Promise<string[]> {
|
||||
if (!(await this.checkIsTargetFile(file))) {
|
||||
return [];
|
||||
}
|
||||
const filename = getPathFromUXFileInfo(file);
|
||||
const doc = await this.localDatabase.getDBEntryMeta(filename, { conflicts: true }, true);
|
||||
if (doc === false) {
|
||||
return [];
|
||||
}
|
||||
return doc._conflicts || [];
|
||||
}
|
||||
|
||||
async fetch(
|
||||
file: UXFileInfoStub | FilePathWithPrefix,
|
||||
rev?: string,
|
||||
waitForReady?: boolean,
|
||||
skipCheck = false
|
||||
): Promise<UXFileInfo | false> {
|
||||
if (skipCheck && !(await this.checkIsTargetFile(file))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const entry = await this.fetchEntry(file, rev, waitForReady, true);
|
||||
if (entry === false) {
|
||||
return false;
|
||||
}
|
||||
const data = createBlob(readContent(entry));
|
||||
const fileInfo: UXFileInfo = {
|
||||
name: entry.path.split("/").pop() as string,
|
||||
path: entry.path,
|
||||
stat: {
|
||||
size: entry.size,
|
||||
ctime: entry.ctime,
|
||||
mtime: entry.mtime,
|
||||
type: "file",
|
||||
},
|
||||
body: data,
|
||||
deleted: entry.deleted || entry._deleted,
|
||||
};
|
||||
if (isInternalMetadata(entry.path)) {
|
||||
fileInfo.isInternal = true;
|
||||
}
|
||||
return fileInfo;
|
||||
}
|
||||
async fetchEntryMeta(
|
||||
file: UXFileInfoStub | FilePathWithPrefix,
|
||||
rev?: string,
|
||||
skipCheck = false
|
||||
): Promise<MetaEntry | false> {
|
||||
const filename = getPathFromUXFileInfo(file);
|
||||
if (skipCheck && !(await this.checkIsTargetFile(file))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const doc = await this.localDatabase.getDBEntryMeta(filename, rev ? { rev: rev } : undefined, true);
|
||||
if (doc === false) {
|
||||
return false;
|
||||
}
|
||||
return doc as MetaEntry;
|
||||
}
|
||||
async fetchEntryFromMeta(
|
||||
meta: MetaEntry,
|
||||
waitForReady: boolean = true,
|
||||
skipCheck = false
|
||||
): Promise<LoadedEntry | false> {
|
||||
if (skipCheck && !(await this.checkIsTargetFile(meta.path))) {
|
||||
return false;
|
||||
}
|
||||
const doc = await this.localDatabase.getDBEntryFromMeta(
|
||||
meta as LoadedEntry,
|
||||
undefined,
|
||||
false,
|
||||
waitForReady,
|
||||
true
|
||||
);
|
||||
if (doc === false) {
|
||||
return false;
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
async fetchEntry(
|
||||
file: UXFileInfoStub | FilePathWithPrefix,
|
||||
rev?: string,
|
||||
waitForReady: boolean = true,
|
||||
skipCheck = false
|
||||
): Promise<LoadedEntry | false> {
|
||||
if (skipCheck && !(await this.checkIsTargetFile(file))) {
|
||||
return false;
|
||||
}
|
||||
const entry = await this.fetchEntryMeta(file, rev, true);
|
||||
if (entry === false) {
|
||||
return false;
|
||||
}
|
||||
const doc = await this.fetchEntryFromMeta(entry, waitForReady, true);
|
||||
return doc;
|
||||
}
|
||||
async deleteFromDBbyPath(fullPath: FilePath | FilePathWithPrefix, rev?: string): Promise<boolean> {
|
||||
if (!(await this.checkIsTargetFile(fullPath))) {
|
||||
this._log(`storeFromStorage: File is not target: ${fullPath}`);
|
||||
return true;
|
||||
}
|
||||
const opt = rev ? { rev: rev } : undefined;
|
||||
const ret = await this.localDatabase.deleteDBEntry(fullPath, opt);
|
||||
eventHub.emitEvent(EVENT_FILE_SAVED);
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
415
src/modules/core/ModuleFileHandler.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import type { FileEventItem } from "../../common/types";
|
||||
import type {
|
||||
FilePath,
|
||||
FilePathWithPrefix,
|
||||
MetaEntry,
|
||||
UXFileInfo,
|
||||
UXFileInfoStub,
|
||||
UXInternalFileInfoStub,
|
||||
} from "../../lib/src/common/types";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import {
|
||||
compareFileFreshness,
|
||||
EVEN,
|
||||
getPath,
|
||||
getPathFromUXFileInfo,
|
||||
getPathWithoutPrefix,
|
||||
markChangesAreSame,
|
||||
} from "../../common/utils";
|
||||
import { getDocDataAsArray, isDocContentSame, readContent } from "../../lib/src/common/utils";
|
||||
import { shouldBeIgnored } from "../../lib/src/string_and_binary/path";
|
||||
import type { ICoreModule } from "../ModuleTypes";
|
||||
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
|
||||
|
||||
export class ModuleFileHandler extends AbstractModule implements ICoreModule {
|
||||
get db() {
|
||||
return this.core.databaseFileAccess;
|
||||
}
|
||||
get storage() {
|
||||
return this.core.storageAccess;
|
||||
}
|
||||
|
||||
$everyOnloadStart(): Promise<boolean> {
|
||||
this.core.fileHandler = this;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
async readFileFromStub(file: UXFileInfoStub | UXFileInfo) {
|
||||
if ("body" in file && file.body) {
|
||||
return file;
|
||||
}
|
||||
const readFile = await this.storage.readStubContent(file);
|
||||
if (!readFile) {
|
||||
throw new Error(`File ${file.path} is not exist on the storage`);
|
||||
}
|
||||
return readFile;
|
||||
}
|
||||
|
||||
async storeFileToDB(
|
||||
info: UXFileInfoStub | UXFileInfo | UXInternalFileInfoStub | FilePathWithPrefix,
|
||||
force: boolean = false,
|
||||
onlyChunks: boolean = false
|
||||
): Promise<boolean | undefined> {
|
||||
const file = typeof info === "string" ? this.storage.getFileStub(info) : info;
|
||||
if (file == null) {
|
||||
this._log(`File ${info} is not exist on the storage`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
// const file = item.args.file;
|
||||
if (file.isInternal) {
|
||||
this._log(
|
||||
`Internal file ${file.path} is not allowed to be processed on processFileEvent`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// First, check the file on the database
|
||||
const entry = await this.db.fetchEntry(file, undefined, true, true);
|
||||
|
||||
if (!entry || entry.deleted || entry._deleted) {
|
||||
// If the file is not exist on the database, then it should be created.
|
||||
const readFile = await this.readFileFromStub(file);
|
||||
if (!onlyChunks) {
|
||||
return await this.db.store(readFile);
|
||||
} else {
|
||||
return await this.db.createChunks(readFile, false, true);
|
||||
}
|
||||
}
|
||||
|
||||
// entry is exist on the database, check the difference between the file and the entry.
|
||||
|
||||
let shouldApplied = false;
|
||||
if (!force && !onlyChunks) {
|
||||
// 1. if the time stamp is far different, then it should be updated.
|
||||
// Note: This checks only the mtime with the resolution reduced to 2 seconds.
|
||||
// 2 seconds it for the ZIP file's mtime. If not, we cannot backup the vault as the ZIP file.
|
||||
// This is hardcoded on `compareMtime` of `src/common/utils.ts`.
|
||||
if (compareFileFreshness(file, entry) !== EVEN) {
|
||||
shouldApplied = true;
|
||||
}
|
||||
// 2. if not, the content should be checked.
|
||||
let readFile: UXFileInfo | undefined = undefined;
|
||||
if (!shouldApplied) {
|
||||
readFile = await this.readFileFromStub(file);
|
||||
if (await isDocContentSame(getDocDataAsArray(entry.data), readFile.body)) {
|
||||
if (shouldApplied) {
|
||||
// Timestamp is different but the content is same. therefore, two timestamps should be handled as same.
|
||||
// So, mark the changes are same.
|
||||
markChangesAreSame(file, file.stat.mtime, entry.mtime);
|
||||
}
|
||||
} else {
|
||||
shouldApplied = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldApplied) {
|
||||
this._log(`File ${file.path} is not changed`, LOG_LEVEL_VERBOSE);
|
||||
return true;
|
||||
}
|
||||
if (!readFile) readFile = await this.readFileFromStub(file);
|
||||
// If the file is changed, then the file should be stored.
|
||||
if (onlyChunks) {
|
||||
return await this.db.createChunks(readFile, false, true);
|
||||
} else {
|
||||
return await this.db.store(readFile, false, true);
|
||||
}
|
||||
} else {
|
||||
// If force is true, then it should be updated.
|
||||
const readFile = await this.readFileFromStub(file);
|
||||
if (onlyChunks) {
|
||||
return await this.db.createChunks(readFile, true, true);
|
||||
} else {
|
||||
return await this.db.store(readFile, true, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFileFromDB(info: UXFileInfoStub | UXInternalFileInfoStub | FilePath): Promise<boolean | undefined> {
|
||||
const file = typeof info === "string" ? this.storage.getFileStub(info) : info;
|
||||
if (file == null) {
|
||||
this._log(`File ${info} is not exist on the storage`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
// const file = item.args.file;
|
||||
if (file.isInternal) {
|
||||
this._log(
|
||||
`Internal file ${file.path} is not allowed to be processed on processFileEvent`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// First, check the file on the database
|
||||
const entry = await this.db.fetchEntry(file, undefined, true, true);
|
||||
if (!entry || entry.deleted || entry._deleted) {
|
||||
this._log(`File ${file.path} is not exist or already deleted on the database`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
// Check the file is already conflicted. if so, only the conflicted one should be deleted.
|
||||
const conflictedRevs = await this.db.getConflictedRevs(file);
|
||||
if (conflictedRevs.length > 0) {
|
||||
// If conflicted, then it should be deleted. entry._rev should be own file's rev.
|
||||
// TODO: I BELIEVED SO. BUT I NOTICED THAT I AN NOT SURE. I SHOULD CHECK THIS.
|
||||
// ANYWAY, I SHOULD DELETE THE FILE. ACTUALLY WE SIMPLY DELETED THE FILE UNTIL PREVIOUS VERSIONS.
|
||||
return await this.db.delete(file, entry._rev);
|
||||
}
|
||||
// Otherwise, the file should be deleted simply. This is the previous behaviour.
|
||||
return await this.db.delete(file);
|
||||
}
|
||||
|
||||
async deleteRevisionFromDB(
|
||||
info: UXFileInfoStub | FilePath | FilePathWithPrefix,
|
||||
rev: string
|
||||
): Promise<boolean | undefined> {
|
||||
//TODO: Possibly check the conflicting.
|
||||
return await this.db.delete(info, rev);
|
||||
}
|
||||
|
||||
async resolveConflictedByDeletingRevision(
|
||||
info: UXFileInfoStub | FilePath,
|
||||
rev: string
|
||||
): Promise<boolean | undefined> {
|
||||
if (!(await this.deleteRevisionFromDB(info, rev))) {
|
||||
this._log(
|
||||
`Failed to delete the conflicted revision ${rev} of ${getPathFromUXFileInfo(info)}`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (!(await this.dbToStorageWithSpecificRev(info, rev, true))) {
|
||||
this._log(
|
||||
`Failed to apply the resolved revision ${rev} of ${getPathFromUXFileInfo(info)} to the storage`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async dbToStorageWithSpecificRev(
|
||||
info: UXFileInfoStub | UXFileInfo | FilePath | null,
|
||||
rev: string,
|
||||
force?: boolean
|
||||
): Promise<boolean> {
|
||||
const file = typeof info === "string" ? this.storage.getFileStub(info) : info;
|
||||
if (file == null) {
|
||||
this._log(`File ${info} is not exist on the storage`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
const docEntry = await this.db.fetchEntryMeta(file, rev, true);
|
||||
if (!docEntry) {
|
||||
this._log(`File ${file.path} is not exist on the database`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
return await this.dbToStorage(docEntry, file, force);
|
||||
}
|
||||
|
||||
async dbToStorage(
|
||||
entryInfo: MetaEntry | FilePathWithPrefix,
|
||||
info: UXFileInfoStub | UXFileInfo | FilePath | null,
|
||||
force?: boolean
|
||||
): Promise<boolean> {
|
||||
const file = typeof info === "string" ? this.storage.getFileStub(info) : info;
|
||||
const mode = file == null ? "create" : "modify";
|
||||
|
||||
const docEntry =
|
||||
typeof entryInfo === "string"
|
||||
? await this.db.fetchEntryMeta(entryInfo, undefined, true)
|
||||
: await this.db.fetchEntryMeta(entryInfo.path, undefined, true);
|
||||
if (!docEntry) {
|
||||
this._log(`File ${entryInfo} is not exist on the database`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
const path = getPath(docEntry);
|
||||
|
||||
// 1. Check if it already conflicted.
|
||||
const revs = await this.db.getConflictedRevs(path);
|
||||
if (revs.length > 0) {
|
||||
// Some conflicts are exist.
|
||||
if (this.settings.writeDocumentsIfConflicted) {
|
||||
// If configured to write the document even if conflicted, then it should be written.
|
||||
// NO OP
|
||||
} else {
|
||||
// If not, then it should be checked. and will be processed later (i.e., after the conflict is resolved).
|
||||
await this.core.$$queueConflictCheckIfOpen(path);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check if the file is already exist on the storage.
|
||||
const existDoc = this.storage.getStub(path);
|
||||
if (existDoc && existDoc.isFolder) {
|
||||
this._log(`Folder ${path} is already exist on the storage as a folder`, LOG_LEVEL_VERBOSE);
|
||||
// We can do nothing, and other modules should also nothing to do.
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check existence of both file and docEntry.
|
||||
const existOnDB = !(docEntry._deleted || docEntry.deleted || false);
|
||||
const existOnStorage = existDoc != null;
|
||||
if (!existOnDB && !existOnStorage) {
|
||||
this._log(`File ${path} seems to be deleted, but already not on storage`, LOG_LEVEL_VERBOSE);
|
||||
return true;
|
||||
}
|
||||
if (!existOnDB && existOnStorage) {
|
||||
// Deletion has been Transferred. Storage files will be deleted.
|
||||
// Note: If the folder becomes empty, the folder will be deleted if not configured to keep it.
|
||||
// This behaviour is implemented on the `ModuleFileAccessObsidian`.
|
||||
// And it does not care actually deleted.
|
||||
await this.storage.deleteVaultItem(path);
|
||||
return true;
|
||||
}
|
||||
// Okay, the file is exist on the database. Let's check the file is exist on the storage.
|
||||
const docRead = await this.db.fetchEntryFromMeta(docEntry);
|
||||
if (!docRead) {
|
||||
this._log(`File ${path} is not exist on the database`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
const docData = readContent(docRead);
|
||||
|
||||
if (existOnStorage && !force) {
|
||||
// The file is exist on the storage. Let's check the difference between the file and the entry.
|
||||
// But, if force is true, then it should be updated.
|
||||
// Ok, we have to compare.
|
||||
let shouldApplied = false;
|
||||
// 1. if the time stamp is far different, then it should be updated.
|
||||
// Note: This checks only the mtime with the resolution reduced to 2 seconds.
|
||||
// 2 seconds it for the ZIP file's mtime. If not, we cannot backup the vault as the ZIP file.
|
||||
// This is hardcoded on `compareMtime` of `src/common/utils.ts`.
|
||||
if (compareFileFreshness(existDoc, docEntry) !== EVEN) {
|
||||
shouldApplied = true;
|
||||
}
|
||||
// 2. if not, the content should be checked.
|
||||
|
||||
if (shouldApplied) {
|
||||
const readFile = await this.readFileFromStub(existDoc);
|
||||
if (await isDocContentSame(docData, readFile.body)) {
|
||||
// The content is same. So, we do not need to update the file.
|
||||
shouldApplied = false;
|
||||
// Timestamp is different but the content is same. therefore, two timestamps should be handled as same.
|
||||
// So, mark the changes are same.
|
||||
markChangesAreSame(docRead, docRead.mtime, existDoc.stat.mtime);
|
||||
} else {
|
||||
shouldApplied = true;
|
||||
}
|
||||
}
|
||||
if (!shouldApplied) {
|
||||
this._log(`File ${docRead.path} is not changed`, LOG_LEVEL_VERBOSE);
|
||||
return true;
|
||||
}
|
||||
// Let's apply the changes.
|
||||
} else {
|
||||
this._log(
|
||||
`File ${docRead.path} ${existOnStorage ? "(new) " : ""} ${force ? " (forced)" : ""}`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
}
|
||||
await this.storage.ensureDir(path);
|
||||
const ret = await this.storage.writeFileAuto(path, docData, { ctime: docRead.ctime, mtime: docRead.mtime });
|
||||
this.storage.touched(path);
|
||||
this.storage.triggerFileEvent(mode, path);
|
||||
return ret;
|
||||
}
|
||||
|
||||
async $anyHandlerProcessesFileEvent(item: FileEventItem): Promise<boolean | undefined> {
|
||||
const eventItem = item.args;
|
||||
const type = item.type;
|
||||
const path = eventItem.file.path;
|
||||
if (!(await this.core.$$isTargetFile(path))) {
|
||||
this._log(`File ${path} is not the target file`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
if (shouldBeIgnored(path)) {
|
||||
this._log(`File ${path} should be ignored`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
const lockKey = `processFileEvent-${path}`;
|
||||
return await serialized(lockKey, async () => {
|
||||
switch (type) {
|
||||
case "CREATE":
|
||||
case "CHANGED":
|
||||
return await this.storeFileToDB(item.args.file);
|
||||
case "DELETE":
|
||||
return await this.deleteFileFromDB(item.args.file);
|
||||
case "INTERNAL":
|
||||
// this should be handled on the other module.
|
||||
return false;
|
||||
default:
|
||||
this._log(`Unsupported event type: ${type}`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async $anyProcessReplicatedDoc(entry: MetaEntry): Promise<boolean | undefined> {
|
||||
return await serialized(entry.path, async () => {
|
||||
if (!(await this.core.$$isTargetFile(entry.path))) {
|
||||
this._log(`File ${entry.path} is not the target file`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
if (shouldBeIgnored(entry.path)) {
|
||||
this._log(`File ${entry.path} should be ignored`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
const path = getPath(entry);
|
||||
|
||||
const targetFile = this.storage.getStub(getPathWithoutPrefix(entry));
|
||||
if (targetFile && targetFile.isFolder) {
|
||||
this._log(`${getPath(entry)} is already exist as the folder`);
|
||||
// Nothing to do and other modules should also nothing to do.
|
||||
return true;
|
||||
} else {
|
||||
this._log(
|
||||
`Processing ${path} (${entry._id.substring(0, 8)}: ${entry._rev?.substring(0, 5)}) :Started...`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
const ret = await this.dbToStorage(entry, targetFile);
|
||||
this._log(`Processing ${path} (${entry._id.substring(0, 8)} :${entry._rev?.substring(0, 5)}) : Done`);
|
||||
return ret;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async createAllChunks(showingNotice?: boolean): Promise<void> {
|
||||
this._log("Collecting local files on the storage", LOG_LEVEL_VERBOSE);
|
||||
const semaphore = Semaphore(10);
|
||||
|
||||
let processed = 0;
|
||||
const filesStorageSrc = this.storage.getFiles();
|
||||
const incProcessed = () => {
|
||||
processed++;
|
||||
if (processed % 25 == 0)
|
||||
this._log(
|
||||
`Creating missing chunks: ${processed} of ${total} files`,
|
||||
showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO,
|
||||
"chunkCreation"
|
||||
);
|
||||
};
|
||||
const total = filesStorageSrc.length;
|
||||
const procAllChunks = filesStorageSrc.map(async (file) => {
|
||||
if (!(await this.core.$$isTargetFile(file))) {
|
||||
incProcessed();
|
||||
return true;
|
||||
}
|
||||
if (shouldBeIgnored(file.path)) {
|
||||
incProcessed();
|
||||
return true;
|
||||
}
|
||||
const release = await semaphore.acquire();
|
||||
incProcessed();
|
||||
try {
|
||||
await this.storeFileToDB(file, false, true);
|
||||
} catch (ex) {
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
});
|
||||
await Promise.all(procAllChunks);
|
||||
this._log(
|
||||
`Creating chunks Done: ${processed} of ${total} files`,
|
||||
showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO,
|
||||
"chunkCreation"
|
||||
);
|
||||
}
|
||||
}
|
||||
25
src/modules/core/ModuleLocalDatabaseObsidian.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { $f } from "../../lib/src/common/i18n";
|
||||
import { LiveSyncLocalDB } from "../../lib/src/pouchdb/LiveSyncLocalDB.ts";
|
||||
import { initializeStores } from "../../common/stores.ts";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import type { ICoreModule } from "../ModuleTypes.ts";
|
||||
|
||||
export class ModuleLocalDatabaseObsidian extends AbstractModule implements ICoreModule {
|
||||
$everyOnloadStart(): Promise<boolean> {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
async $$openDatabase(): Promise<boolean> {
|
||||
if (this.localDatabase != null) {
|
||||
await this.localDatabase.close();
|
||||
}
|
||||
const vaultName = this.core.$$getVaultName();
|
||||
this._log($f`Waiting for ready...`);
|
||||
this.core.localDatabase = new LiveSyncLocalDB(vaultName, this.core);
|
||||
initializeStores(vaultName);
|
||||
return await this.localDatabase.initializeDatabase();
|
||||
}
|
||||
|
||||
$$isDatabaseReady(): boolean {
|
||||
return this.localDatabase != null && this.localDatabase.isReady;
|
||||
}
|
||||
}
|
||||
33
src/modules/core/ModulePeriodicProcess.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { PeriodicProcessor } from "../../common/utils";
|
||||
import { AbstractModule } from "../AbstractModule";
|
||||
import type { ICoreModule } from "../ModuleTypes";
|
||||
|
||||
export class ModulePeriodicProcess extends AbstractModule implements ICoreModule {
|
||||
periodicSyncProcessor = new PeriodicProcessor(this.core, async () => await this.core.$$replicate());
|
||||
|
||||
_disablePeriodic() {
|
||||
this.periodicSyncProcessor?.disable();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
_resumePeriodic() {
|
||||
this.periodicSyncProcessor.enable(
|
||||
this.settings.periodicReplication ? this.settings.periodicReplicationInterval * 1000 : 0
|
||||
);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
$allOnUnload() {
|
||||
return this._disablePeriodic();
|
||||
}
|
||||
$everyBeforeRealizeSetting(): Promise<boolean> {
|
||||
return this._disablePeriodic();
|
||||
}
|
||||
$everyBeforeSuspendProcess(): Promise<boolean> {
|
||||
return this._disablePeriodic();
|
||||
}
|
||||
$everyAfterResumeProcess(): Promise<boolean> {
|
||||
return this._resumePeriodic();
|
||||
}
|
||||
$everyAfterRealizeSetting(): Promise<boolean> {
|
||||
return this._resumePeriodic();
|
||||
}
|
||||
}
|
||||
19
src/modules/core/ModulePouchDB.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { AbstractModule } from "../AbstractModule";
|
||||
import type { ICoreModule } from "../ModuleTypes";
|
||||
import { PouchDB } from "../../lib/src/pouchdb/pouchdb-browser";
|
||||
|
||||
export class ModulePouchDB extends AbstractModule implements ICoreModule {
|
||||
$$createPouchDBInstance<T extends object>(
|
||||
name?: string,
|
||||
options?: PouchDB.Configuration.DatabaseConfiguration
|
||||
): PouchDB.Database<T> {
|
||||
const optionPass = options ?? {};
|
||||
if (this.settings.useIndexedDBAdapter) {
|
||||
optionPass.adapter = "indexeddb";
|
||||
//@ts-ignore :missing def
|
||||
optionPass.purged_infos_limit = 1;
|
||||
return new PouchDB(name + "-indexeddb", optionPass);
|
||||
}
|
||||
return new PouchDB(name, optionPass);
|
||||
}
|
||||
}
|
||||
254
src/modules/core/ModuleRebuilder.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { delay } from "octagonal-wheels/promises";
|
||||
import {
|
||||
FLAGMD_REDFLAG2_HR,
|
||||
FLAGMD_REDFLAG3_HR,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
} from "../../lib/src/common/types.ts";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import type { Rebuilder } from "../interfaces/DatabaseRebuilder.ts";
|
||||
import type { ICoreModule } from "../ModuleTypes.ts";
|
||||
import type { LiveSyncCouchDBReplicator } from "../../lib/src/replication/couchdb/LiveSyncReplicator.ts";
|
||||
import { fetchAllUsedChunks } from "../../lib/src/pouchdb/utils_couchdb.ts";
|
||||
|
||||
export class ModuleRebuilder extends AbstractModule implements ICoreModule, Rebuilder {
|
||||
$everyOnload(): Promise<boolean> {
|
||||
this.core.rebuilder = this;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
async $performRebuildDB(
|
||||
method: "localOnly" | "remoteOnly" | "rebuildBothByThisDevice" | "localOnlyWithChunks"
|
||||
): Promise<void> {
|
||||
if (method == "localOnly") {
|
||||
await this.$fetchLocal();
|
||||
}
|
||||
if (method == "localOnlyWithChunks") {
|
||||
await this.$fetchLocal(true);
|
||||
}
|
||||
if (method == "remoteOnly") {
|
||||
await this.$rebuildRemote();
|
||||
}
|
||||
if (method == "rebuildBothByThisDevice") {
|
||||
await this.$rebuildEverything();
|
||||
}
|
||||
}
|
||||
|
||||
async askUsingOptionalFeature(opt: { enableFetch?: boolean; enableOverwrite?: boolean }) {
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
"Do you want to enable extra features? If you are new to Self-hosted LiveSync, try the core feature first!",
|
||||
{ title: "Enable extra features", defaultOption: "No", timeout: 15 }
|
||||
)) == "yes"
|
||||
) {
|
||||
await this.core.$allAskUsingOptionalSyncFeature(opt);
|
||||
}
|
||||
}
|
||||
|
||||
async rebuildRemote() {
|
||||
await this.core.$allSuspendExtraSync();
|
||||
this.core.settings.isConfigured = true;
|
||||
|
||||
await this.core.$$realizeSettingSyncMode();
|
||||
await this.core.$$markRemoteLocked();
|
||||
await this.core.$$tryResetRemoteDatabase();
|
||||
await this.core.$$markRemoteLocked();
|
||||
await delay(500);
|
||||
await this.askUsingOptionalFeature({ enableOverwrite: true });
|
||||
await delay(1000);
|
||||
await this.core.$$replicateAllToServer(true);
|
||||
await delay(1000);
|
||||
await this.core.$$replicateAllToServer(true, true);
|
||||
}
|
||||
$rebuildRemote(): Promise<void> {
|
||||
return this.rebuildRemote();
|
||||
}
|
||||
|
||||
async rebuildEverything() {
|
||||
await this.core.$allSuspendExtraSync();
|
||||
await this.askUseNewAdapter();
|
||||
this.core.settings.isConfigured = true;
|
||||
await this.core.$$realizeSettingSyncMode();
|
||||
await this.resetLocalDatabase();
|
||||
await delay(1000);
|
||||
await this.core.$$initializeDatabase(true);
|
||||
await this.core.$$markRemoteLocked();
|
||||
await this.core.$$tryResetRemoteDatabase();
|
||||
await this.core.$$markRemoteLocked();
|
||||
await delay(500);
|
||||
await this.askUsingOptionalFeature({ enableOverwrite: true });
|
||||
await delay(1000);
|
||||
await this.core.$$replicateAllToServer(true);
|
||||
await delay(1000);
|
||||
await this.core.$$replicateAllToServer(true, true);
|
||||
}
|
||||
|
||||
$rebuildEverything(): Promise<void> {
|
||||
return this.rebuildEverything();
|
||||
}
|
||||
|
||||
$fetchLocal(makeLocalChunkBeforeSync?: boolean): Promise<void> {
|
||||
return this.fetchLocal(makeLocalChunkBeforeSync);
|
||||
}
|
||||
|
||||
async scheduleRebuild(): Promise<void> {
|
||||
try {
|
||||
await this.core.storageAccess.writeFileAuto(FLAGMD_REDFLAG2_HR, "");
|
||||
} catch (ex) {
|
||||
this._log("Could not create red_flag_rebuild.md", LOG_LEVEL_NOTICE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
this.core.$$performRestart();
|
||||
}
|
||||
async scheduleFetch(): Promise<void> {
|
||||
try {
|
||||
await this.core.storageAccess.writeFileAuto(FLAGMD_REDFLAG3_HR, "");
|
||||
} catch (ex) {
|
||||
this._log("Could not create red_flag_fetch.md", LOG_LEVEL_NOTICE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
this.core.$$performRestart();
|
||||
}
|
||||
|
||||
async $$tryResetRemoteDatabase(): Promise<void> {
|
||||
await this.core.replicator.tryResetRemoteDatabase(this.settings);
|
||||
}
|
||||
|
||||
async $$tryCreateRemoteDatabase(): Promise<void> {
|
||||
await this.core.replicator.tryCreateRemoteDatabase(this.settings);
|
||||
}
|
||||
|
||||
async $$resetLocalDatabase(): Promise<void> {
|
||||
this.core.storageAccess.clearTouched();
|
||||
await this.localDatabase.resetDatabase();
|
||||
}
|
||||
|
||||
async suspendAllSync() {
|
||||
this.core.settings.liveSync = false;
|
||||
this.core.settings.periodicReplication = false;
|
||||
this.core.settings.syncOnSave = false;
|
||||
this.core.settings.syncOnEditorSave = false;
|
||||
this.core.settings.syncOnStart = false;
|
||||
this.core.settings.syncOnFileOpen = false;
|
||||
this.core.settings.syncAfterMerge = false;
|
||||
await this.core.$allSuspendExtraSync();
|
||||
}
|
||||
async suspendReflectingDatabase() {
|
||||
if (this.core.settings.doNotSuspendOnFetching) return;
|
||||
if (this.core.settings.remoteType == REMOTE_MINIO) return;
|
||||
this._log(
|
||||
`Suspending reflection: Database and storage changes will not be reflected in each other until completely finished the fetching.`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
this.core.settings.suspendParseReplicationResult = true;
|
||||
this.core.settings.suspendFileWatching = true;
|
||||
await this.core.saveSettings();
|
||||
}
|
||||
async resumeReflectingDatabase() {
|
||||
if (this.core.settings.doNotSuspendOnFetching) return;
|
||||
if (this.core.settings.remoteType == REMOTE_MINIO) return;
|
||||
this._log(`Database and storage reflection has been resumed!`, LOG_LEVEL_NOTICE);
|
||||
this.core.settings.suspendParseReplicationResult = false;
|
||||
this.core.settings.suspendFileWatching = false;
|
||||
await this.core.$$performFullScan(true);
|
||||
await this.core.$everyBeforeReplicate(false); //TODO: Check actual need of this.
|
||||
await this.core.saveSettings();
|
||||
}
|
||||
async askUseNewAdapter() {
|
||||
if (!this.core.settings.useIndexedDBAdapter) {
|
||||
const message = `Now this core has been configured to use the old database adapter for keeping compatibility. Do you want to deactivate it?`;
|
||||
const CHOICE_YES = "Yes, disable and use latest";
|
||||
const CHOICE_NO = "No, keep compatibility";
|
||||
const choices = [CHOICE_YES, CHOICE_NO];
|
||||
|
||||
const ret = await this.core.confirm.confirmWithMessage(
|
||||
"Database adapter",
|
||||
message,
|
||||
choices,
|
||||
CHOICE_YES,
|
||||
10
|
||||
);
|
||||
if (ret == CHOICE_YES) {
|
||||
this.core.settings.useIndexedDBAdapter = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
async fetchLocal(makeLocalChunkBeforeSync?: boolean) {
|
||||
await this.core.$allSuspendExtraSync();
|
||||
await this.askUseNewAdapter();
|
||||
this.core.settings.isConfigured = true;
|
||||
await this.suspendReflectingDatabase();
|
||||
await this.core.$$realizeSettingSyncMode();
|
||||
await this.resetLocalDatabase();
|
||||
await delay(1000);
|
||||
await this.core.$$openDatabase();
|
||||
// this.core.isReady = true;
|
||||
this.core.$$markIsReady();
|
||||
if (makeLocalChunkBeforeSync) {
|
||||
await this.core.fileHandler.createAllChunks(true);
|
||||
}
|
||||
await this.core.$$markRemoteResolved();
|
||||
await delay(500);
|
||||
await this.core.$$replicateAllFromServer(true);
|
||||
await delay(1000);
|
||||
await this.core.$$replicateAllFromServer(true);
|
||||
await this.resumeReflectingDatabase();
|
||||
await this.askUsingOptionalFeature({ enableFetch: true });
|
||||
}
|
||||
async fetchLocalWithRebuild() {
|
||||
return await this.fetchLocal(true);
|
||||
}
|
||||
|
||||
async $allSuspendAllSync(): Promise<boolean> {
|
||||
await this.suspendAllSync();
|
||||
return true;
|
||||
}
|
||||
|
||||
async resetLocalDatabase() {
|
||||
if (this.core.settings.isConfigured && this.core.settings.additionalSuffixOfDatabaseName == "") {
|
||||
// Discard the non-suffixed database
|
||||
await this.core.$$resetLocalDatabase();
|
||||
}
|
||||
const suffix = (await this.core.$anyGetAppId()) || "";
|
||||
this.core.settings.additionalSuffixOfDatabaseName = suffix;
|
||||
await this.core.$$resetLocalDatabase();
|
||||
}
|
||||
async fetchRemoteChunks() {
|
||||
if (
|
||||
!this.core.settings.doNotSuspendOnFetching &&
|
||||
this.core.settings.readChunksOnline &&
|
||||
this.core.settings.remoteType == REMOTE_COUCHDB
|
||||
) {
|
||||
this._log(`Fetching chunks`, LOG_LEVEL_NOTICE);
|
||||
const replicator = this.core.$$getReplicator() as LiveSyncCouchDBReplicator;
|
||||
const remoteDB = await replicator.connectRemoteCouchDBWithSetting(
|
||||
this.settings,
|
||||
this.core.$$isMobile(),
|
||||
true
|
||||
);
|
||||
if (typeof remoteDB == "string") {
|
||||
this._log(remoteDB, LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
await fetchAllUsedChunks(this.localDatabase.localDatabase, remoteDB.db);
|
||||
}
|
||||
this._log(`Fetching chunks done`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
}
|
||||
async resolveAllConflictedFilesByNewerOnes() {
|
||||
this._log(`Resolving conflicts by newer ones`, LOG_LEVEL_NOTICE);
|
||||
const files = this.core.storageAccess.getFileNames();
|
||||
|
||||
let i = 0;
|
||||
for (const file of files) {
|
||||
if (i++ % 10)
|
||||
this._log(
|
||||
`Check and Processing ${i} / ${files.length}`,
|
||||
LOG_LEVEL_NOTICE,
|
||||
"resolveAllConflictedFilesByNewerOnes"
|
||||
);
|
||||
await this.core.$anyResolveConflictByNewest(file);
|
||||
}
|
||||
this._log(`Done!`, LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes");
|
||||
}
|
||||
}
|
||||
416
src/modules/core/ModuleReplicator.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { fireAndForget, yieldMicrotask } from "octagonal-wheels/promises";
|
||||
import type { LiveSyncLocalDB } from "../../lib/src/pouchdb/LiveSyncLocalDB";
|
||||
import { AbstractModule } from "../AbstractModule";
|
||||
import type { ICoreModule } from "../ModuleTypes";
|
||||
import { Logger, LOG_LEVEL_NOTICE, LOG_LEVEL_INFO, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import { isLockAcquired, shareRunningResult, skipIfDuplicated } from "octagonal-wheels/concurrency/lock";
|
||||
import { purgeUnreferencedChunks, balanceChunkPurgedDBs } from "../../lib/src/pouchdb/utils_couchdb";
|
||||
import { LiveSyncCouchDBReplicator } from "../../lib/src/replication/couchdb/LiveSyncReplicator";
|
||||
import { throttle } from "octagonal-wheels/function";
|
||||
import { arrayToChunkedArray } from "octagonal-wheels/collection";
|
||||
import {
|
||||
SYNCINFO_ID,
|
||||
VER,
|
||||
type EntryBody,
|
||||
type EntryDoc,
|
||||
type LoadedEntry,
|
||||
type MetaEntry,
|
||||
} from "../../lib/src/common/types";
|
||||
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
|
||||
import { getPath, isChunk, isValidPath, scheduleTask } from "../../common/utils";
|
||||
import { sendValue } from "octagonal-wheels/messagepassing/signal";
|
||||
import { isAnyNote } from "../../lib/src/common/utils";
|
||||
import { EVENT_FILE_SAVED, eventHub } from "../../common/events";
|
||||
import type { LiveSyncAbstractReplicator } from "../../lib/src/replication/LiveSyncAbstractReplicator";
|
||||
|
||||
export class ModuleReplicator extends AbstractModule implements ICoreModule {
|
||||
$everyOnloadAfterLoadSettings(): Promise<boolean> {
|
||||
eventHub.onEvent(EVENT_FILE_SAVED, () => {
|
||||
if (this.settings.syncOnSave && !this.core.$$isSuspended()) {
|
||||
scheduleTask("perform-replicate-after-save", 250, () => this.core.$$waitForReplicationOnce());
|
||||
}
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
async setReplicator() {
|
||||
const replicator = await this.core.$anyNewReplicator();
|
||||
if (!replicator) {
|
||||
this._log("No replicator is available, this is the fatal error.", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
this.core.replicator = replicator;
|
||||
await yieldMicrotask();
|
||||
return true;
|
||||
}
|
||||
|
||||
$$getReplicator(): LiveSyncAbstractReplicator {
|
||||
return this.core.replicator;
|
||||
}
|
||||
|
||||
$everyOnInitializeDatabase(db: LiveSyncLocalDB): Promise<boolean> {
|
||||
return this.setReplicator();
|
||||
}
|
||||
|
||||
$everyOnResetDatabase(db: LiveSyncLocalDB): Promise<boolean> {
|
||||
return this.setReplicator();
|
||||
}
|
||||
|
||||
async $everyBeforeReplicate(showMessage: boolean): Promise<boolean> {
|
||||
await this.loadQueuedFiles();
|
||||
return true;
|
||||
}
|
||||
async $$replicate(showMessage: boolean = false): Promise<boolean | void> {
|
||||
//--?
|
||||
if (!this.core.$$isReady()) return;
|
||||
if (isLockAcquired("cleanup")) {
|
||||
Logger("Database cleaning up is in process. replication has been cancelled", LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
if (this.settings.versionUpFlash != "") {
|
||||
Logger("Open settings and check message, please. replication has been cancelled.", LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
if (!(await this.core.$everyCommitPendingFileEvent())) {
|
||||
Logger("Some file events are pending. Replication has been cancelled.", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (!(await this.core.$everyBeforeReplicate(showMessage))) {
|
||||
Logger(`Replication has been cancelled by some module failure`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
|
||||
//<-- Here could be an module.
|
||||
const ret = await this.core.replicator.openReplication(this.settings, false, showMessage, false);
|
||||
if (!ret) {
|
||||
if (this.core.replicator.tweakSettingsMismatched) {
|
||||
await this.core.$$askResolvingMismatchedTweaks();
|
||||
} else {
|
||||
if (this.core.replicator?.remoteLockedAndDeviceNotAccepted) {
|
||||
if (this.core.replicator.remoteCleaned && this.settings.useIndexedDBAdapter) {
|
||||
Logger(
|
||||
`The remote database has been cleaned.`,
|
||||
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
||||
);
|
||||
await skipIfDuplicated("cleanup", async () => {
|
||||
const count = await purgeUnreferencedChunks(this.localDatabase.localDatabase, true);
|
||||
const message = `The remote database has been cleaned up.
|
||||
To synchronize, this device must be also cleaned up. ${count} chunk(s) will be erased from this device.
|
||||
However, If there are many chunks to be deleted, maybe fetching again is faster.
|
||||
We will lose the history of this device if we fetch the remote database again.
|
||||
Even if you choose to clean up, you will see this option again if you exit Obsidian and then synchronise again.`;
|
||||
const CHOICE_FETCH = "Fetch again";
|
||||
const CHOICE_CLEAN = "Cleanup";
|
||||
const CHOICE_DISMISS = "Dismiss";
|
||||
const ret = await this.core.confirm.confirmWithMessage(
|
||||
"Cleaned",
|
||||
message,
|
||||
[CHOICE_FETCH, CHOICE_CLEAN, CHOICE_DISMISS],
|
||||
CHOICE_DISMISS,
|
||||
30
|
||||
);
|
||||
if (ret == CHOICE_FETCH) {
|
||||
await this.core.rebuilder.$performRebuildDB("localOnly");
|
||||
}
|
||||
if (ret == CHOICE_CLEAN) {
|
||||
const replicator = this.core.$$getReplicator();
|
||||
if (!(replicator instanceof LiveSyncCouchDBReplicator)) return;
|
||||
const remoteDB = await replicator.connectRemoteCouchDBWithSetting(
|
||||
this.settings,
|
||||
this.core.$$isMobile(),
|
||||
true
|
||||
);
|
||||
if (typeof remoteDB == "string") {
|
||||
Logger(remoteDB, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
|
||||
await purgeUnreferencedChunks(this.localDatabase.localDatabase, false);
|
||||
this.localDatabase.hashCaches.clear();
|
||||
// Perform the synchronisation once.
|
||||
if (
|
||||
await this.core.replicator.openReplication(this.settings, false, showMessage, true)
|
||||
) {
|
||||
await balanceChunkPurgedDBs(this.localDatabase.localDatabase, remoteDB.db);
|
||||
await purgeUnreferencedChunks(this.localDatabase.localDatabase, false);
|
||||
this.localDatabase.hashCaches.clear();
|
||||
await this.core.$$getReplicator().markRemoteResolved(this.settings);
|
||||
Logger(
|
||||
"The local database has been cleaned up.",
|
||||
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
||||
);
|
||||
} else {
|
||||
Logger(
|
||||
"Replication has been cancelled. Please try it again.",
|
||||
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const message = `
|
||||
The remote database has been rebuilt.
|
||||
To synchronize, this device must fetch everything again once.
|
||||
Or if you are sure know what had been happened, we can unlock the database from the setting dialog.
|
||||
`;
|
||||
const CHOICE_FETCH = "Fetch again";
|
||||
const CHOICE_DISMISS = "Dismiss";
|
||||
const ret = await this.core.confirm.confirmWithMessage(
|
||||
"Locked",
|
||||
message,
|
||||
[CHOICE_FETCH, CHOICE_DISMISS],
|
||||
CHOICE_DISMISS,
|
||||
10
|
||||
);
|
||||
if (ret == CHOICE_FETCH) {
|
||||
const CHOICE_RESTART = "Restart";
|
||||
const CHOICE_WITHOUT_RESTART = "Without restart";
|
||||
if (
|
||||
(await this.core.confirm.askSelectStringDialogue(
|
||||
"Self-hosted LiveSync restarts Obsidian to fetch everything safely. However, you can do it without restarting. Please choose one.",
|
||||
[CHOICE_RESTART, CHOICE_WITHOUT_RESTART],
|
||||
{
|
||||
title: "Fetch again",
|
||||
defaultAction: CHOICE_RESTART,
|
||||
timeout: 30,
|
||||
}
|
||||
)) == CHOICE_RESTART
|
||||
) {
|
||||
await this.core.rebuilder.scheduleFetch();
|
||||
// await this.core.$$scheduleAppReload();
|
||||
return;
|
||||
} else {
|
||||
await this.core.rebuilder.$performRebuildDB("localOnly");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
$$parseReplicationResult(docs: Array<PouchDB.Core.ExistingDocument<EntryDoc>>): void {
|
||||
if (this.settings.suspendParseReplicationResult && !this.replicationResultProcessor.isSuspended) {
|
||||
this.replicationResultProcessor.suspend();
|
||||
}
|
||||
this.replicationResultProcessor.enqueueAll(docs);
|
||||
if (!this.settings.suspendParseReplicationResult && this.replicationResultProcessor.isSuspended) {
|
||||
this.replicationResultProcessor.resume();
|
||||
}
|
||||
}
|
||||
_saveQueuedFiles = throttle(() => {
|
||||
const saveData = this.replicationResultProcessor._queue
|
||||
.filter((e) => e !== undefined && e !== null)
|
||||
.map((e) => e?._id ?? ("" as string)) as string[];
|
||||
const kvDBKey = "queued-files";
|
||||
// localStorage.setItem(lsKey, saveData);
|
||||
fireAndForget(() => this.core.kvDB.set(kvDBKey, saveData));
|
||||
}, 100);
|
||||
saveQueuedFiles() {
|
||||
this._saveQueuedFiles();
|
||||
}
|
||||
async loadQueuedFiles() {
|
||||
if (this.settings.suspendParseReplicationResult) return;
|
||||
if (!this.settings.isConfigured) return;
|
||||
const kvDBKey = "queued-files";
|
||||
// const ids = [...new Set(JSON.parse(localStorage.getItem(lsKey) || "[]"))] as string[];
|
||||
const ids = [...new Set((await this.core.kvDB.get<string[]>(kvDBKey)) ?? [])];
|
||||
const batchSize = 100;
|
||||
const chunkedIds = arrayToChunkedArray(ids, batchSize);
|
||||
for await (const idsBatch of chunkedIds) {
|
||||
const ret = await this.localDatabase.allDocsRaw<EntryDoc>({
|
||||
keys: idsBatch,
|
||||
include_docs: true,
|
||||
limit: 100,
|
||||
});
|
||||
const docs = ret.rows.filter((e) => e.doc).map((e) => e.doc) as PouchDB.Core.ExistingDocument<EntryDoc>[];
|
||||
const errors = ret.rows.filter((e) => !e.doc && !e.value.deleted);
|
||||
if (errors.length > 0) {
|
||||
Logger("Some queued processes were not resurrected");
|
||||
Logger(JSON.stringify(errors), LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
this.replicationResultProcessor.enqueueAll(docs);
|
||||
await this.replicationResultProcessor.waitForAllProcessed();
|
||||
}
|
||||
}
|
||||
|
||||
replicationResultProcessor = new QueueProcessor(
|
||||
async (docs: PouchDB.Core.ExistingDocument<EntryDoc>[]) => {
|
||||
if (this.settings.suspendParseReplicationResult) return;
|
||||
const change = docs[0];
|
||||
if (!change) return;
|
||||
if (isChunk(change._id)) {
|
||||
// SendSignal?
|
||||
// this.parseIncomingChunk(change);
|
||||
sendValue(`leaf-${change._id}`, change);
|
||||
return;
|
||||
}
|
||||
if (await this.core.$anyModuleParsedReplicationResultItem(change)) return;
|
||||
// any addon needs this item?
|
||||
// for (const proc of this.core.addOns) {
|
||||
// if (await proc.parseReplicationResultItem(change)) {
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
if (change.type == "versioninfo") {
|
||||
if (change.version > VER) {
|
||||
this.core.replicator.closeReplication();
|
||||
Logger(
|
||||
`Remote database updated to incompatible version. update your Self-hosted LiveSync plugin.`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
change._id == SYNCINFO_ID || // Synchronisation information data
|
||||
change._id.startsWith("_design") //design document
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isAnyNote(change)) {
|
||||
const docPath = getPath(change);
|
||||
if (!(await this.core.$$isTargetFile(docPath))) {
|
||||
Logger(`Skipped: ${docPath}`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
if (this.databaseQueuedProcessor._isSuspended) {
|
||||
Logger(`Processing scheduled: ${docPath}`, LOG_LEVEL_INFO);
|
||||
}
|
||||
const size = change.size;
|
||||
if (this.core.$$isFileSizeExceeded(size)) {
|
||||
Logger(
|
||||
`Processing ${docPath} has been skipped due to file size exceeding the limit`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.databaseQueuedProcessor.enqueue(change);
|
||||
}
|
||||
return;
|
||||
},
|
||||
{
|
||||
batchSize: 1,
|
||||
suspended: true,
|
||||
concurrentLimit: 100,
|
||||
delay: 0,
|
||||
totalRemainingReactiveSource: this.core.replicationResultCount,
|
||||
}
|
||||
)
|
||||
.replaceEnqueueProcessor((queue, newItem) => {
|
||||
const q = queue.filter((e) => e._id != newItem._id);
|
||||
return [...q, newItem];
|
||||
})
|
||||
.startPipeline()
|
||||
.onUpdateProgress(() => {
|
||||
this.saveQueuedFiles();
|
||||
});
|
||||
|
||||
databaseQueuedProcessor = new QueueProcessor(
|
||||
async (docs: EntryBody[]) => {
|
||||
const dbDoc = docs[0] as LoadedEntry; // It has no `data`
|
||||
const path = getPath(dbDoc);
|
||||
|
||||
// If `Read chunks online` is disabled, chunks should be transferred before here.
|
||||
// However, in some cases, chunks are after that. So, if missing chunks exist, we have to wait for them.
|
||||
const doc = await this.localDatabase.getDBEntryFromMeta({ ...dbDoc }, {}, false, true, true);
|
||||
if (!doc) {
|
||||
Logger(
|
||||
`Something went wrong while gathering content of ${path} (${dbDoc._id.substring(0, 8)}, ${dbDoc._rev?.substring(0, 10)}) `,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await this.core.$anyProcessOptionalSyncFiles(dbDoc)) {
|
||||
// Already processed
|
||||
} else if (isValidPath(getPath(doc))) {
|
||||
this.storageApplyingProcessor.enqueue(doc as MetaEntry);
|
||||
} else {
|
||||
Logger(`Skipped: ${doc._id.substring(0, 8)}`, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
return;
|
||||
},
|
||||
{
|
||||
suspended: true,
|
||||
batchSize: 1,
|
||||
concurrentLimit: 10,
|
||||
yieldThreshold: 1,
|
||||
delay: 0,
|
||||
totalRemainingReactiveSource: this.core.databaseQueueCount,
|
||||
}
|
||||
)
|
||||
.replaceEnqueueProcessor((queue, newItem) => {
|
||||
const q = queue.filter((e) => e._id != newItem._id);
|
||||
return [...q, newItem];
|
||||
})
|
||||
.startPipeline();
|
||||
|
||||
storageApplyingProcessor = new QueueProcessor(
|
||||
async (docs: MetaEntry[]) => {
|
||||
const entry = docs[0];
|
||||
await this.core.$anyProcessReplicatedDoc(entry);
|
||||
return;
|
||||
},
|
||||
{
|
||||
suspended: true,
|
||||
batchSize: 1,
|
||||
concurrentLimit: 6,
|
||||
yieldThreshold: 1,
|
||||
delay: 0,
|
||||
totalRemainingReactiveSource: this.core.storageApplyingCount,
|
||||
}
|
||||
)
|
||||
.replaceEnqueueProcessor((queue, newItem) => {
|
||||
const q = queue.filter((e) => e._id != newItem._id);
|
||||
return [...q, newItem];
|
||||
})
|
||||
.startPipeline();
|
||||
|
||||
$everyBeforeSuspendProcess(): Promise<boolean> {
|
||||
this.core.replicator.closeReplication();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
async $$replicateAllToServer(
|
||||
showingNotice: boolean = false,
|
||||
sendChunksInBulkDisabled: boolean = false
|
||||
): Promise<boolean> {
|
||||
if (!this.core.$$isReady()) return false;
|
||||
if (!(await this.core.$everyBeforeReplicate(showingNotice))) {
|
||||
Logger(`Replication has been cancelled by some module failure`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (!sendChunksInBulkDisabled) {
|
||||
if (this.core.replicator instanceof LiveSyncCouchDBReplicator) {
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog("Do you want to send all chunks before replication?", {
|
||||
defaultOption: "No",
|
||||
timeout: 20,
|
||||
})) == "yes"
|
||||
) {
|
||||
await this.core.replicator.sendChunks(this.core.settings, undefined, true, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
const ret = await this.core.replicator.replicateAllToServer(this.settings, showingNotice);
|
||||
if (ret) return true;
|
||||
const checkResult = await this.core.$anyAfterConnectCheckFailed();
|
||||
if (checkResult == "CHECKAGAIN") return await this.core.$$replicateAllToServer(showingNotice);
|
||||
return !checkResult;
|
||||
}
|
||||
async $$replicateAllFromServer(showingNotice: boolean = false): Promise<boolean> {
|
||||
if (!this.core.$$isReady()) return false;
|
||||
const ret = await this.core.replicator.replicateAllFromServer(this.settings, showingNotice);
|
||||
if (ret) return true;
|
||||
const checkResult = await this.core.$anyAfterConnectCheckFailed();
|
||||
if (checkResult == "CHECKAGAIN") return await this.core.$$replicateAllFromServer(showingNotice);
|
||||
return !checkResult;
|
||||
}
|
||||
|
||||
async $$waitForReplicationOnce(): Promise<boolean | void> {
|
||||
return await shareRunningResult(`replication`, () => this.core.$$replicate());
|
||||
}
|
||||
}
|
||||
32
src/modules/core/ModuleReplicatorCouchDB.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { REMOTE_MINIO, type RemoteDBSettings } from "../../lib/src/common/types";
|
||||
import { LiveSyncCouchDBReplicator } from "../../lib/src/replication/couchdb/LiveSyncReplicator";
|
||||
import type { LiveSyncAbstractReplicator } from "../../lib/src/replication/LiveSyncAbstractReplicator";
|
||||
import { AbstractModule } from "../AbstractModule";
|
||||
import type { ICoreModule } from "../ModuleTypes";
|
||||
|
||||
export class ModuleReplicatorCouchDB extends AbstractModule implements ICoreModule {
|
||||
$anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator> {
|
||||
const settings = { ...this.settings, ...settingOverride };
|
||||
// If new remote types were added, add them here. Do not use `REMOTE_COUCHDB` directly for the safety valve.
|
||||
if (settings.remoteType == REMOTE_MINIO) {
|
||||
return undefined!;
|
||||
}
|
||||
return Promise.resolve(new LiveSyncCouchDBReplicator(this.core));
|
||||
}
|
||||
$everyAfterResumeProcess(): Promise<boolean> {
|
||||
if (this.settings.remoteType != REMOTE_MINIO) {
|
||||
// If LiveSync enabled, open replication
|
||||
if (this.settings.liveSync) {
|
||||
fireAndForget(() => this.core.replicator.openReplication(this.settings, true, false, false));
|
||||
}
|
||||
// If sync on start enabled, open replication
|
||||
if (!this.settings.liveSync && this.settings.syncOnStart) {
|
||||
// Possibly ok as if only share the result
|
||||
fireAndForget(() => this.core.replicator.openReplication(this.settings, false, false, false));
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
}
|
||||
15
src/modules/core/ModuleReplicatorMinIO.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { REMOTE_MINIO, type RemoteDBSettings } from "../../lib/src/common/types";
|
||||
import { LiveSyncJournalReplicator } from "../../lib/src/replication/journal/LiveSyncJournalReplicator";
|
||||
import type { LiveSyncAbstractReplicator } from "../../lib/src/replication/LiveSyncAbstractReplicator";
|
||||
import { AbstractModule } from "../AbstractModule";
|
||||
import type { ICoreModule } from "../ModuleTypes";
|
||||
|
||||
export class ModuleReplicatorMinIO extends AbstractModule implements ICoreModule {
|
||||
$anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator> {
|
||||
const settings = { ...this.settings, ...settingOverride };
|
||||
if (settings.remoteType == REMOTE_MINIO) {
|
||||
return Promise.resolve(new LiveSyncJournalReplicator(this.core));
|
||||
}
|
||||
return undefined!;
|
||||
}
|
||||
}
|
||||
171
src/modules/core/ModuleTargetFilter.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { LRUCache } from "octagonal-wheels/memory/LRUCache";
|
||||
import {
|
||||
getPathFromUXFileInfo,
|
||||
id2path,
|
||||
isInternalMetadata,
|
||||
path2id,
|
||||
stripInternalMetadataPrefix,
|
||||
useMemo,
|
||||
} from "../../common/utils";
|
||||
import {
|
||||
LOG_LEVEL_VERBOSE,
|
||||
type DocumentID,
|
||||
type EntryHasPath,
|
||||
type FilePath,
|
||||
type FilePathWithPrefix,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type UXFileInfoStub,
|
||||
} from "../../lib/src/common/types";
|
||||
import { addPrefix, isAcceptedAll, stripAllPrefixes } from "../../lib/src/string_and_binary/path";
|
||||
import { AbstractModule } from "../AbstractModule";
|
||||
import type { ICoreModule } from "../ModuleTypes";
|
||||
import { EVENT_REQUEST_RELOAD_SETTING_TAB, EVENT_SETTING_SAVED, eventHub } from "../../common/events";
|
||||
import { isDirty } from "../../lib/src/common/utils";
|
||||
export class ModuleTargetFilter extends AbstractModule implements ICoreModule {
|
||||
reloadIgnoreFiles() {
|
||||
this.ignoreFiles = this.settings.ignoreFiles.split(",").map((e) => e.trim());
|
||||
}
|
||||
$everyOnload(): Promise<boolean> {
|
||||
eventHub.onEvent(EVENT_SETTING_SAVED, (evt: ObsidianLiveSyncSettings) => {
|
||||
this.reloadIgnoreFiles();
|
||||
});
|
||||
eventHub.onEvent(EVENT_REQUEST_RELOAD_SETTING_TAB, () => {
|
||||
this.reloadIgnoreFiles();
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
$$id2path(id: DocumentID, entry?: EntryHasPath, stripPrefix?: boolean): FilePathWithPrefix {
|
||||
const tempId = id2path(id, entry);
|
||||
if (stripPrefix && isInternalMetadata(tempId)) {
|
||||
const out = stripInternalMetadataPrefix(tempId);
|
||||
return out;
|
||||
}
|
||||
return tempId;
|
||||
}
|
||||
async $$path2id(filename: FilePathWithPrefix | FilePath, prefix?: string): Promise<DocumentID> {
|
||||
const destPath = addPrefix(filename, prefix ?? "");
|
||||
return await path2id(
|
||||
destPath,
|
||||
this.settings.usePathObfuscation ? this.settings.passphrase : "",
|
||||
!this.settings.handleFilenameCaseSensitive
|
||||
);
|
||||
}
|
||||
|
||||
$$isFileSizeExceeded(size: number) {
|
||||
if (this.settings.syncMaxSizeInMB > 0 && size > 0) {
|
||||
if (this.settings.syncMaxSizeInMB * 1024 * 1024 < size) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
$$markFileListPossiblyChanged(): void {
|
||||
this.totalFileEventCount++;
|
||||
}
|
||||
totalFileEventCount = 0;
|
||||
get fileListPossiblyChanged() {
|
||||
if (isDirty("totalFileEventCount", this.totalFileEventCount)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async $$isTargetFile(file: string | UXFileInfoStub, keepFileCheckList = false) {
|
||||
const fileCount = useMemo<Record<string, number>>(
|
||||
{
|
||||
key: "fileCount", // forceUpdate: !keepFileCheckList,
|
||||
},
|
||||
(ctx, prev) => {
|
||||
if (keepFileCheckList && prev) return prev;
|
||||
if (!keepFileCheckList && prev && !this.fileListPossiblyChanged) {
|
||||
return prev;
|
||||
}
|
||||
const fileList = (ctx.get("fileList") ?? []) as FilePathWithPrefix[];
|
||||
// const fileNameList = (ctx.get("fileNameList") ?? []) as FilePath[];
|
||||
// const fileNames =
|
||||
const vaultFiles = this.core.storageAccess.getFileNames().sort();
|
||||
if (prev && vaultFiles.length == fileList.length) {
|
||||
const fl3 = new Set([...fileList, ...vaultFiles]);
|
||||
if (fileList.length == fl3.size && vaultFiles.length == fl3.size) {
|
||||
return prev;
|
||||
}
|
||||
}
|
||||
ctx.set("fileList", vaultFiles);
|
||||
|
||||
const fileCount: Record<string, number> = {};
|
||||
for (const file of vaultFiles) {
|
||||
const lc = file.toLowerCase();
|
||||
if (!fileCount[lc]) {
|
||||
fileCount[lc] = 1;
|
||||
} else {
|
||||
fileCount[lc]++;
|
||||
}
|
||||
}
|
||||
return fileCount;
|
||||
}
|
||||
);
|
||||
|
||||
const filepath = getPathFromUXFileInfo(file);
|
||||
const lc = filepath.toLowerCase();
|
||||
if (this.core.$$shouldCheckCaseInsensitive()) {
|
||||
if (lc in fileCount && fileCount[lc] > 1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const fileNameLC = getPathFromUXFileInfo(file).split("/").pop()?.toLowerCase();
|
||||
if (this.settings.useIgnoreFiles) {
|
||||
if (this.ignoreFiles.some((e) => e.toLowerCase() == fileNameLC)) {
|
||||
// We must reload ignore files due to the its change.
|
||||
await this.readIgnoreFile(filepath);
|
||||
}
|
||||
if (await this.core.$$isIgnoredByIgnoreFiles(file)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!this.localDatabase?.isTargetFile(filepath)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
ignoreFileCache = new LRUCache<string, string[] | false>(300, 250000, true);
|
||||
ignoreFiles = [] as string[];
|
||||
async readIgnoreFile(path: string) {
|
||||
try {
|
||||
const file = await this.core.storageAccess.readFileText(path);
|
||||
const gitignore = file.split(/\r?\n/g);
|
||||
this.ignoreFileCache.set(path, gitignore);
|
||||
return gitignore;
|
||||
} catch (ex) {
|
||||
this._log(`Failed to read ignore file ${path}`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
this.ignoreFileCache.set(path, false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async getIgnoreFile(path: string) {
|
||||
if (this.ignoreFileCache.has(path)) {
|
||||
return this.ignoreFileCache.get(path) ?? false;
|
||||
} else {
|
||||
return await this.readIgnoreFile(path);
|
||||
}
|
||||
}
|
||||
async $$isIgnoredByIgnoreFiles(file: string | UXFileInfoStub): Promise<boolean> {
|
||||
if (!this.settings.useIgnoreFiles) {
|
||||
return false;
|
||||
}
|
||||
const filepath = getPathFromUXFileInfo(file);
|
||||
if (this.ignoreFileCache.has(filepath)) {
|
||||
// Renew
|
||||
await this.readIgnoreFile(filepath);
|
||||
}
|
||||
if (
|
||||
!(await isAcceptedAll(stripAllPrefixes(filepath), this.ignoreFiles, (filename) =>
|
||||
this.getIgnoreFile(filename)
|
||||
))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
118
src/modules/coreFeatures/ModuleCheckRemoteSize.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import { sizeToHumanReadable } from "octagonal-wheels/number";
|
||||
import type { ICoreModule } from "../ModuleTypes.ts";
|
||||
|
||||
export class ModuleCheckRemoteSize extends AbstractModule implements ICoreModule {
|
||||
async $allScanStat(): Promise<boolean> {
|
||||
this._log(`Checking storage sizes`, LOG_LEVEL_VERBOSE);
|
||||
if (this.settings.notifyThresholdOfRemoteStorageSize < 0) {
|
||||
const message = `We can set a maximum database capacity warning, **to take action before running out of space on the remote storage**.
|
||||
Do you want to enable this?
|
||||
|
||||
> [!MORE]-
|
||||
> - 0: Do not warn about storage size.
|
||||
> This is recommended if you have enough space on the remote storage especially you have self-hosted. And you can check the storage size and rebuild manually.
|
||||
> - 800: Warn if the remote storage size exceeds 800MB.
|
||||
> This is recommended if you are using fly.io with 1GB limit or IBM Cloudant.
|
||||
> - 2000: Warn if the remote storage size exceeds 2GB.
|
||||
|
||||
If we have reached the limit, we will be asked to enlarge the limit step by step.
|
||||
`;
|
||||
const ANSWER_0 = "No, never warn please";
|
||||
const ANSWER_800 = "800MB (Cloudant, fly.io)";
|
||||
const ANSWER_2000 = "2GB (Standard)";
|
||||
const ASK_ME_NEXT_TIME = "Ask me later";
|
||||
|
||||
const ret = await this.core.confirm.askSelectStringDialogue(
|
||||
message,
|
||||
[ANSWER_0, ANSWER_800, ANSWER_2000, ASK_ME_NEXT_TIME],
|
||||
{
|
||||
defaultAction: ASK_ME_NEXT_TIME,
|
||||
title: "Setting up database size notification",
|
||||
timeout: 40,
|
||||
}
|
||||
);
|
||||
if (ret == ANSWER_0) {
|
||||
this.settings.notifyThresholdOfRemoteStorageSize = 0;
|
||||
await this.core.saveSettings();
|
||||
} else if (ret == ANSWER_800) {
|
||||
this.settings.notifyThresholdOfRemoteStorageSize = 800;
|
||||
await this.core.saveSettings();
|
||||
} else if (ret == ANSWER_2000) {
|
||||
this.settings.notifyThresholdOfRemoteStorageSize = 2000;
|
||||
await this.core.saveSettings();
|
||||
}
|
||||
}
|
||||
if (this.settings.notifyThresholdOfRemoteStorageSize > 0) {
|
||||
const remoteStat = await this.core.replicator?.getRemoteStatus(this.settings);
|
||||
if (remoteStat) {
|
||||
const estimatedSize = remoteStat.estimatedSize;
|
||||
if (estimatedSize) {
|
||||
const maxSize = this.settings.notifyThresholdOfRemoteStorageSize * 1024 * 1024;
|
||||
if (estimatedSize > maxSize) {
|
||||
const message = `**Your database is getting larger!** But do not worry, we can address it now. The time before running out of space on the remote storage.
|
||||
|
||||
| Measured size | Configured size |
|
||||
| --- | --- |
|
||||
| ${sizeToHumanReadable(estimatedSize)} | ${sizeToHumanReadable(maxSize)} |
|
||||
|
||||
> [!MORE]-
|
||||
> If you have been using it for many years, there may be unreferenced chunks - that is, garbage - accumulating in the database. Therefore, we recommend rebuilding everything. It will probably become much smaller.
|
||||
>
|
||||
> If the volume of your vault is simply increasing, it is better to rebuild everything after organizing the files. Self-hosted LiveSync does not delete the actual data even if you delete it to speed up the process. It is roughly [documented](https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/tech_info.md).
|
||||
>
|
||||
> If you don't mind the increase, you can increase the notification limit by 100MB. This is the case if you are running it on your own server. However, it is better to rebuild everything from time to time.
|
||||
>
|
||||
|
||||
> [!WARNING]
|
||||
> If you perform rebuild everything, make sure all devices are synchronised. The plug-in will merge as much as possible, though.
|
||||
|
||||
\n`;
|
||||
const newMax = ~~(estimatedSize / 1024 / 1024) + 100;
|
||||
const ANSWER_ENLARGE_LIMIT = `increase to ${newMax}MB`;
|
||||
const ANSWER_REBUILD = "Rebuild Everything Now";
|
||||
const ANSWER_IGNORE = "Dismiss";
|
||||
const ret = await this.core.confirm.askSelectStringDialogue(
|
||||
message,
|
||||
[ANSWER_ENLARGE_LIMIT, ANSWER_REBUILD, ANSWER_IGNORE],
|
||||
{
|
||||
defaultAction: ANSWER_IGNORE,
|
||||
title: "Remote storage size exceeded the limit",
|
||||
timeout: 60,
|
||||
}
|
||||
);
|
||||
if (ret == ANSWER_REBUILD) {
|
||||
const ret = await this.core.confirm.askYesNoDialog(
|
||||
"This may take a bit of a long time. Do you really want to rebuild everything now?",
|
||||
{ defaultOption: "No" }
|
||||
);
|
||||
if (ret == "yes") {
|
||||
this.core.settings.notifyThresholdOfRemoteStorageSize = -1;
|
||||
await this.saveSettings();
|
||||
await this.core.rebuilder.scheduleRebuild();
|
||||
}
|
||||
} else if (ret == ANSWER_ENLARGE_LIMIT) {
|
||||
this.settings.notifyThresholdOfRemoteStorageSize = ~~(estimatedSize / 1024 / 1024) + 100;
|
||||
this._log(
|
||||
`Threshold has been enlarged to ${this.settings.notifyThresholdOfRemoteStorageSize}MB`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
await this.core.saveSettings();
|
||||
} else {
|
||||
// Dismiss or Close the dialog
|
||||
}
|
||||
|
||||
this._log(
|
||||
`Remote storage size: ${sizeToHumanReadable(estimatedSize)} exceeded ${sizeToHumanReadable(this.settings.notifyThresholdOfRemoteStorageSize * 1024 * 1024)} `,
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
} else {
|
||||
this._log(`Remote storage size: ${sizeToHumanReadable(estimatedSize)}`, LOG_LEVEL_INFO);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
78
src/modules/coreFeatures/ModuleConflictChecker.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import { LOG_LEVEL_NOTICE, type FilePathWithPrefix } from "../../lib/src/common/types";
|
||||
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
|
||||
import { sendValue } from "octagonal-wheels/messagepassing/signal";
|
||||
import type { ICoreModule } from "../ModuleTypes.ts";
|
||||
|
||||
export class ModuleConflictChecker extends AbstractModule implements ICoreModule {
|
||||
async $$queueConflictCheckIfOpen(file: FilePathWithPrefix): Promise<void> {
|
||||
const path = file;
|
||||
if (this.settings.checkConflictOnlyOnOpen) {
|
||||
const af = this.core.$$getActiveFilePath();
|
||||
if (af && af != path) {
|
||||
this._log(`${file} is conflicted, merging process has been postponed.`, LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.core.$$queueConflictCheck(path);
|
||||
}
|
||||
|
||||
async $$queueConflictCheck(file: FilePathWithPrefix): Promise<void> {
|
||||
const optionalConflictResult = await this.core.$anyGetOptionalConflictCheckMethod(file);
|
||||
if (optionalConflictResult == true) {
|
||||
// The conflict has been resolved by another process.
|
||||
return;
|
||||
} else if (optionalConflictResult === "newer") {
|
||||
// The conflict should be resolved by the newer entry.
|
||||
await this.core.$anyResolveConflictByNewest(file);
|
||||
} else {
|
||||
this.conflictCheckQueue.enqueue(file);
|
||||
}
|
||||
}
|
||||
|
||||
$$waitForAllConflictProcessed(): Promise<boolean> {
|
||||
return this.conflictResolveQueue.waitForAllProcessed();
|
||||
}
|
||||
|
||||
// TODO-> Move to ModuleConflictResolver?
|
||||
conflictResolveQueue = new QueueProcessor(
|
||||
async (filenames: FilePathWithPrefix[]) => {
|
||||
await this.core.$$resolveConflict(filenames[0]);
|
||||
},
|
||||
{
|
||||
suspended: false,
|
||||
batchSize: 1,
|
||||
concurrentLimit: 1,
|
||||
delay: 10,
|
||||
keepResultUntilDownstreamConnected: false,
|
||||
}
|
||||
).replaceEnqueueProcessor((queue, newEntity) => {
|
||||
const filename = newEntity;
|
||||
sendValue("cancel-resolve-conflict:" + filename, true);
|
||||
const newQueue = [...queue].filter((e) => e != newEntity);
|
||||
return [...newQueue, newEntity];
|
||||
});
|
||||
|
||||
conflictCheckQueue = // First process - Check is the file actually need resolve -
|
||||
new QueueProcessor(
|
||||
(files: FilePathWithPrefix[]) => {
|
||||
const filename = files[0];
|
||||
// const file = await this.core.storageAccess.isExists(filename);
|
||||
// if (!file) return [];
|
||||
// if (!(file instanceof TFile)) return;
|
||||
// if ((file instanceof TFolder)) return [];
|
||||
// Check again?
|
||||
return Promise.resolve([filename]);
|
||||
// this.conflictResolveQueue.enqueueWithKey(filename, { filename, file });
|
||||
},
|
||||
{
|
||||
suspended: false,
|
||||
batchSize: 1,
|
||||
concurrentLimit: 5,
|
||||
delay: 10,
|
||||
keepResultUntilDownstreamConnected: true,
|
||||
pipeTo: this.conflictResolveQueue,
|
||||
totalRemainingReactiveSource: this.core.conflictProcessQueueCount,
|
||||
}
|
||||
);
|
||||
}
|
||||
172
src/modules/coreFeatures/ModuleConflictResolver.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import {
|
||||
AUTO_MERGED,
|
||||
CANCELLED,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
MISSING_OR_ERROR,
|
||||
NOT_CONFLICTED,
|
||||
type diff_check_result,
|
||||
type FilePathWithPrefix,
|
||||
} from "../../lib/src/common/types";
|
||||
import { compareMTime, displayRev, TARGET_IS_NEW } from "../../common/utils";
|
||||
import diff_match_patch from "diff-match-patch";
|
||||
import { stripAllPrefixes, isPlainText } from "../../lib/src/string_and_binary/path";
|
||||
import type { ICoreModule } from "../ModuleTypes.ts";
|
||||
|
||||
export class ModuleConflictResolver extends AbstractModule implements ICoreModule {
|
||||
async $$resolveConflictByDeletingRev(
|
||||
path: FilePathWithPrefix,
|
||||
deleteRevision: string,
|
||||
subTitle = ""
|
||||
): Promise<typeof MISSING_OR_ERROR | typeof AUTO_MERGED> {
|
||||
const title = `Resolving ${subTitle ? `[${subTitle}]` : ""}:`;
|
||||
if (!(await this.core.fileHandler.deleteRevisionFromDB(path, deleteRevision))) {
|
||||
this._log(
|
||||
`${title} Could not delete conflicted revision ${displayRev(deleteRevision)} of ${path}`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
this._log(`${title} Conflicted revision deleted ${displayRev(deleteRevision)} ${path}`, LOG_LEVEL_INFO);
|
||||
if ((await this.core.databaseFileAccess.getConflictedRevs(path)).length != 0) {
|
||||
this._log(`${title} some conflicts are left in ${path}`, LOG_LEVEL_INFO);
|
||||
return AUTO_MERGED;
|
||||
}
|
||||
// If no conflicts were found, write the resolved content to the storage.
|
||||
if (!(await this.core.fileHandler.dbToStorage(path, stripAllPrefixes(path), true))) {
|
||||
this._log(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
this._log(`${path} Has been merged automatically`, LOG_LEVEL_NOTICE);
|
||||
return AUTO_MERGED;
|
||||
}
|
||||
|
||||
async checkConflictAndPerformAutoMerge(path: FilePathWithPrefix): Promise<diff_check_result> {
|
||||
//
|
||||
const ret = await this.localDatabase.tryAutoMerge(path, !this.settings.disableMarkdownAutoMerge);
|
||||
if ("ok" in ret) {
|
||||
return ret.ok;
|
||||
}
|
||||
|
||||
if ("result" in ret) {
|
||||
const p = ret.result;
|
||||
// Merged content is coming.
|
||||
// 1. Store the merged content to the storage
|
||||
if (!(await this.core.databaseFileAccess.storeContent(path, p))) {
|
||||
this._log(`Merged content cannot be stored:${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
// 2. As usual, delete the conflicted revision and if there are no conflicts, write the resolved content to the storage.
|
||||
return await this.core.$$resolveConflictByDeletingRev(path, ret.conflictedRev, "Sensible");
|
||||
}
|
||||
|
||||
const { rightRev, leftLeaf, rightLeaf } = ret;
|
||||
|
||||
// should be one or more conflicts;
|
||||
if (leftLeaf == false) {
|
||||
// what's going on..
|
||||
this._log(`could not get current revisions:${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
if (rightLeaf == false) {
|
||||
// Conflicted item could not load, delete this.
|
||||
return await this.core.$$resolveConflictByDeletingRev(path, rightRev, "MISSING OLD REV");
|
||||
}
|
||||
|
||||
const isSame = leftLeaf.data == rightLeaf.data && leftLeaf.deleted == rightLeaf.deleted;
|
||||
const isBinary = !isPlainText(path);
|
||||
const alwaysNewer = this.settings.resolveConflictsByNewerFile;
|
||||
if (isSame || isBinary || alwaysNewer) {
|
||||
const result = compareMTime(leftLeaf.mtime, rightLeaf.mtime);
|
||||
let loser = leftLeaf;
|
||||
// if (lMtime > rMtime) {
|
||||
if (result != TARGET_IS_NEW) {
|
||||
loser = rightLeaf;
|
||||
}
|
||||
const subTitle = [
|
||||
`${isSame ? "same" : ""}`,
|
||||
`${isBinary ? "binary" : ""}`,
|
||||
`${alwaysNewer ? "alwaysNewer" : ""}`,
|
||||
].join(",");
|
||||
return await this.core.$$resolveConflictByDeletingRev(path, loser.rev, subTitle);
|
||||
}
|
||||
// make diff.
|
||||
const dmp = new diff_match_patch();
|
||||
const diff = dmp.diff_main(leftLeaf.data, rightLeaf.data);
|
||||
dmp.diff_cleanupSemantic(diff);
|
||||
this._log(`conflict(s) found:${path}`);
|
||||
return {
|
||||
left: leftLeaf,
|
||||
right: rightLeaf,
|
||||
diff: diff,
|
||||
};
|
||||
}
|
||||
|
||||
async $$resolveConflict(filename: FilePathWithPrefix): Promise<void> {
|
||||
// const filename = filenames[0];
|
||||
return await serialized(`conflict-resolve:${filename}`, async () => {
|
||||
const conflictCheckResult = await this.checkConflictAndPerformAutoMerge(filename);
|
||||
if (
|
||||
conflictCheckResult === MISSING_OR_ERROR ||
|
||||
conflictCheckResult === NOT_CONFLICTED ||
|
||||
conflictCheckResult === CANCELLED
|
||||
) {
|
||||
// nothing to do.
|
||||
this._log(`conflict:Nothing to do:${filename}`);
|
||||
return;
|
||||
}
|
||||
if (conflictCheckResult === AUTO_MERGED) {
|
||||
//auto resolved, but need check again;
|
||||
if (this.settings.syncAfterMerge && !this.core.$$isSuspended()) {
|
||||
//Wait for the running replication, if not running replication, run it once.
|
||||
await this.core.$$waitForReplicationOnce();
|
||||
}
|
||||
this._log("conflict:Automatically merged, but we have to check it again");
|
||||
await this.core.$$queueConflictCheck(filename);
|
||||
return;
|
||||
}
|
||||
if (this.settings.showMergeDialogOnlyOnActive) {
|
||||
const af = this.core.$$getActiveFilePath();
|
||||
if (af && af != filename) {
|
||||
this._log(
|
||||
`${filename} is conflicted. Merging process has been postponed to the file have got opened.`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this._log("conflict:Manual merge required!");
|
||||
await this.core.$anyResolveConflictByUI(filename, conflictCheckResult);
|
||||
});
|
||||
}
|
||||
|
||||
async $anyResolveConflictByNewest(filename: FilePathWithPrefix): Promise<boolean> {
|
||||
const revs = await this.core.databaseFileAccess.getConflictedRevs(filename);
|
||||
if (revs.length == 0) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
const mTimeAndRev = (
|
||||
await Promise.all(
|
||||
revs.map(async (rev) => {
|
||||
const leaf = await this.core.databaseFileAccess.fetchEntryMeta(filename, rev);
|
||||
if (leaf == false) {
|
||||
return [0, rev] as [number, string];
|
||||
}
|
||||
return [leaf.mtime, rev] as [number, string];
|
||||
})
|
||||
)
|
||||
).sort((a, b) => b[0] - a[0]);
|
||||
this._log(
|
||||
`Resolving conflict by newest: ${filename} (Newest: ${new Date(mTimeAndRev[0][0]).toLocaleString()}) (${mTimeAndRev.length} revisions exists)`
|
||||
);
|
||||
for (let i = 1; i < mTimeAndRev.length; i++) {
|
||||
this._log(
|
||||
`conflict: Deleting the older revision ${mTimeAndRev[i][1]} (${new Date(mTimeAndRev[i][0]).toLocaleString()}) of ${filename}`
|
||||
);
|
||||
await this.core.$$resolveConflictByDeletingRev(filename, mTimeAndRev[i][1], "NEWEST");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
152
src/modules/coreFeatures/ModuleRedFlag.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import { normalizePath } from "../../deps.ts";
|
||||
import {
|
||||
FLAGMD_REDFLAG,
|
||||
FLAGMD_REDFLAG2,
|
||||
FLAGMD_REDFLAG2_HR,
|
||||
FLAGMD_REDFLAG3,
|
||||
FLAGMD_REDFLAG3_HR,
|
||||
} from "../../lib/src/common/types.ts";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import type { ICoreModule } from "../ModuleTypes.ts";
|
||||
|
||||
export class ModuleRedFlag extends AbstractModule implements ICoreModule {
|
||||
async isFlagFileExist(path: string) {
|
||||
const redflag = await this.core.storageAccess.isExists(normalizePath(path));
|
||||
if (redflag) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async deleteFlagFile(path: string) {
|
||||
try {
|
||||
const isFlagged = await this.core.storageAccess.isExists(normalizePath(path));
|
||||
if (isFlagged) {
|
||||
await this.core.storageAccess.delete(path, true);
|
||||
}
|
||||
} catch (ex) {
|
||||
this._log(`Could not delete ${path}`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
}
|
||||
|
||||
isRedFlagRaised = async () => await this.isFlagFileExist(FLAGMD_REDFLAG);
|
||||
isRedFlag2Raised = async () =>
|
||||
(await this.isFlagFileExist(FLAGMD_REDFLAG2)) || (await this.isFlagFileExist(FLAGMD_REDFLAG2_HR));
|
||||
isRedFlag3Raised = async () =>
|
||||
(await this.isFlagFileExist(FLAGMD_REDFLAG3)) || (await this.isFlagFileExist(FLAGMD_REDFLAG3_HR));
|
||||
|
||||
async deleteRedFlag2() {
|
||||
await this.deleteFlagFile(FLAGMD_REDFLAG2);
|
||||
await this.deleteFlagFile(FLAGMD_REDFLAG2_HR);
|
||||
}
|
||||
|
||||
async deleteRedFlag3() {
|
||||
await this.deleteFlagFile(FLAGMD_REDFLAG3);
|
||||
await this.deleteFlagFile(FLAGMD_REDFLAG3_HR);
|
||||
}
|
||||
async $everyOnLayoutReady(): Promise<boolean> {
|
||||
try {
|
||||
const isRedFlagRaised = await this.isRedFlagRaised();
|
||||
const isRedFlag2Raised = await this.isRedFlag2Raised();
|
||||
const isRedFlag3Raised = await this.isRedFlag3Raised();
|
||||
|
||||
if (isRedFlagRaised || isRedFlag2Raised || isRedFlag3Raised) {
|
||||
if (isRedFlag2Raised) {
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
"Rebuild everything has been scheduled! Are you sure to rebuild everything?",
|
||||
{ defaultOption: "Yes", timeout: 0 }
|
||||
)) !== "yes"
|
||||
) {
|
||||
await this.deleteRedFlag2();
|
||||
await this.core.$$performRestart();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (isRedFlag3Raised) {
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog("Fetch again has been scheduled! Are you sure?", {
|
||||
defaultOption: "Yes",
|
||||
timeout: 0,
|
||||
})) !== "yes"
|
||||
) {
|
||||
await this.deleteRedFlag3();
|
||||
await this.core.$$performRestart();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this.settings.batchSave = false;
|
||||
await this.core.$allSuspendAllSync();
|
||||
await this.core.$allSuspendExtraSync();
|
||||
this.settings.suspendFileWatching = true;
|
||||
await this.saveSettings();
|
||||
if (isRedFlag2Raised) {
|
||||
this._log(
|
||||
`${FLAGMD_REDFLAG2} or ${FLAGMD_REDFLAG2_HR} has been detected! Self-hosted LiveSync suspends all sync and rebuild everything.`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
await this.core.rebuilder.$rebuildEverything();
|
||||
await this.deleteRedFlag2();
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
"Do you want to resume file and database processing, and restart obsidian now?",
|
||||
{ defaultOption: "Yes", timeout: 15 }
|
||||
)) == "yes"
|
||||
) {
|
||||
this.settings.suspendFileWatching = false;
|
||||
await this.saveSettings();
|
||||
this.core.$$performRestart();
|
||||
return false;
|
||||
}
|
||||
} else if (isRedFlag3Raised) {
|
||||
this._log(
|
||||
`${FLAGMD_REDFLAG3} or ${FLAGMD_REDFLAG3_HR} has been detected! Self-hosted LiveSync will discard the local database and fetch everything from the remote once again.`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
const makeLocalChunkBeforeSync =
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
`Do you want to create local chunks before fetching?
|
||||
> [!MORE]-
|
||||
> If creating local chunks before fetching, only the difference between the local and remote will be fetched.
|
||||
|
||||
`,
|
||||
{ defaultOption: "Yes", title: "Trick to transfer efficiently" }
|
||||
)) == "yes";
|
||||
await this.core.rebuilder.$fetchLocal(makeLocalChunkBeforeSync);
|
||||
await this.deleteRedFlag3();
|
||||
if (this.settings.suspendFileWatching) {
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
"Do you want to resume file and database processing, and restart obsidian now?",
|
||||
{ defaultOption: "Yes", timeout: 15 }
|
||||
)) == "yes"
|
||||
) {
|
||||
this.settings.suspendFileWatching = false;
|
||||
await this.saveSettings();
|
||||
this.core.$$performRestart();
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
this._log(
|
||||
"Your content of files will be synchronised gradually. Please wait for the completion.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Case of FLAGMD_REDFLAG.
|
||||
this.settings.writeLogToTheFile = true;
|
||||
// await this.plugin.openDatabase();
|
||||
const warningMessage =
|
||||
"The red flag is raised! The whole initialize steps are skipped, and any file changes are not captured.";
|
||||
this._log(warningMessage, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
this._log("Something went wrong on FlagFile Handling", LOG_LEVEL_NOTICE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
16
src/modules/coreFeatures/ModuleRemoteGovernor.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import type { ICoreModule } from "../ModuleTypes.ts";
|
||||
|
||||
export class ModuleRemoteGovernor extends AbstractModule implements ICoreModule {
|
||||
async $$markRemoteLocked(lockByClean: boolean = false): Promise<void> {
|
||||
return await this.core.replicator.markRemoteLocked(this.settings, true, lockByClean);
|
||||
}
|
||||
|
||||
async $$markRemoteUnlocked(): Promise<void> {
|
||||
return await this.core.replicator.markRemoteLocked(this.settings, false, false);
|
||||
}
|
||||
|
||||
async $$markRemoteResolved(): Promise<void> {
|
||||
return await this.core.replicator.markRemoteResolved(this.settings);
|
||||
}
|
||||
}
|
||||
196
src/modules/coreFeatures/ModuleResolveMismatchedTweaks.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { Logger, LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
|
||||
import { extractObject } from "octagonal-wheels/object";
|
||||
import {
|
||||
TweakValuesShouldMatchedTemplate,
|
||||
CompatibilityBreakingTweakValues,
|
||||
confName,
|
||||
type TweakValues,
|
||||
type RemoteDBSettings,
|
||||
} from "../../lib/src/common/types.ts";
|
||||
import { escapeMarkdownValue } from "../../lib/src/common/utils.ts";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import type { ICoreModule } from "../ModuleTypes.ts";
|
||||
|
||||
export class ModuleResolvingMismatchedTweaks extends AbstractModule implements ICoreModule {
|
||||
async $anyAfterConnectCheckFailed(): Promise<boolean | "CHECKAGAIN" | undefined> {
|
||||
if (!this.core.replicator.tweakSettingsMismatched) return false;
|
||||
const ret = await this.core.$$askResolvingMismatchedTweaks();
|
||||
if (ret == "OK") return false;
|
||||
if (ret == "CHECKAGAIN") return "CHECKAGAIN";
|
||||
if (ret == "IGNORE") return true;
|
||||
}
|
||||
|
||||
async $$askResolvingMismatchedTweaks(): Promise<"OK" | "CHECKAGAIN" | "IGNORE"> {
|
||||
if (!this.core.replicator.tweakSettingsMismatched) {
|
||||
return "OK";
|
||||
}
|
||||
const preferred = extractObject(TweakValuesShouldMatchedTemplate, this.core.replicator.preferredTweakValue!);
|
||||
const mine = extractObject(TweakValuesShouldMatchedTemplate, this.settings);
|
||||
const items = Object.entries(TweakValuesShouldMatchedTemplate);
|
||||
let rebuildRequired = false;
|
||||
|
||||
// Making tables:
|
||||
let table = `| Value name | This device | Configured | \n` + `|: --- |: --- :|: ---- :| \n`;
|
||||
|
||||
// const items = [mine,preferred]
|
||||
for (const v of items) {
|
||||
const key = v[0] as keyof typeof TweakValuesShouldMatchedTemplate;
|
||||
const valueMine = escapeMarkdownValue(mine[key]);
|
||||
const valuePreferred = escapeMarkdownValue(preferred[key]);
|
||||
if (valueMine == valuePreferred) continue;
|
||||
if (CompatibilityBreakingTweakValues.indexOf(key) !== -1) {
|
||||
rebuildRequired = true;
|
||||
}
|
||||
table += `| ${confName(key)} | ${valueMine} | ${valuePreferred} | \n`;
|
||||
}
|
||||
|
||||
const additionalMessage = rebuildRequired
|
||||
? `
|
||||
|
||||
**Note**: We have detected that some of the values are different to make incompatible the local database with the remote database.
|
||||
If you choose to use the configured values, the local database will be rebuilt, and if you choose to use the values of this device, the remote database will be rebuilt.
|
||||
Both of them takes a few minutes. Please choose after considering the situation.`
|
||||
: "";
|
||||
|
||||
const message = `
|
||||
Your configuration has not been matched with the one on the remote server.
|
||||
(Which you had decided once before, or set by initially synchronised device).
|
||||
|
||||
Configured values:
|
||||
|
||||
${table}
|
||||
|
||||
Please select which one you want to use.
|
||||
|
||||
- Use configured: Update settings of this device by configured one on the remote server.
|
||||
You should select this if you have changed the settings on ** another device **.
|
||||
- Update with mine: Update settings on the remote server by the settings of this device.
|
||||
You should select this if you have changed the settings on ** this device **.
|
||||
- Dismiss: Ignore this message and keep the current settings.
|
||||
You cannot synchronise until you resolve this issue without enabling \`Do not check configuration mismatch before replication\`.${additionalMessage}`;
|
||||
|
||||
const CHOICE_USE_REMOTE = "Use configured";
|
||||
const CHOICE_USR_MINE = "Update with mine";
|
||||
const CHOICE_DISMISS = "Dismiss";
|
||||
const CHOICE_AND_VALUES = [
|
||||
[CHOICE_USE_REMOTE, preferred],
|
||||
[CHOICE_USR_MINE, true],
|
||||
[CHOICE_DISMISS, false],
|
||||
];
|
||||
const CHOICES = Object.fromEntries(CHOICE_AND_VALUES) as Record<string, TweakValues | boolean>;
|
||||
const retKey = await this.core.confirm.confirmWithMessage(
|
||||
"Tweaks Mismatched or Changed",
|
||||
message,
|
||||
Object.keys(CHOICES),
|
||||
CHOICE_DISMISS,
|
||||
60
|
||||
);
|
||||
if (!retKey) return "IGNORE";
|
||||
const conf = CHOICES[retKey];
|
||||
|
||||
if (conf === true) {
|
||||
await this.core.replicator.setPreferredRemoteTweakSettings(this.settings);
|
||||
if (rebuildRequired) {
|
||||
await this.core.rebuilder.$rebuildRemote();
|
||||
}
|
||||
Logger(
|
||||
`Tweak values on the remote server have been updated. Your other device will see this message.`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return "CHECKAGAIN";
|
||||
}
|
||||
if (conf) {
|
||||
this.settings = { ...this.settings, ...conf };
|
||||
await this.core.replicator.setPreferredRemoteTweakSettings(this.settings);
|
||||
await this.core.$$saveSettingData();
|
||||
if (rebuildRequired) {
|
||||
await this.core.rebuilder.$fetchLocal();
|
||||
}
|
||||
Logger(`Configuration has been updated as configured by the other device.`, LOG_LEVEL_NOTICE);
|
||||
return "CHECKAGAIN";
|
||||
}
|
||||
return "IGNORE";
|
||||
}
|
||||
|
||||
async $$checkAndAskUseRemoteConfiguration(
|
||||
trialSetting: RemoteDBSettings
|
||||
): Promise<{ result: false | TweakValues; requireFetch: boolean }> {
|
||||
const replicator = await this.core.$anyNewReplicator(trialSetting);
|
||||
if (await replicator.tryConnectRemote(trialSetting)) {
|
||||
const preferred = await replicator.getRemotePreferredTweakValues(trialSetting);
|
||||
if (preferred) {
|
||||
const items = Object.entries(TweakValuesShouldMatchedTemplate);
|
||||
let rebuildRequired = false;
|
||||
// Making tables:
|
||||
let table = `| Value name | This device | Stored | \n` + `|: --- |: ---- :|: ---- :| \n`;
|
||||
let differenceCount = 0;
|
||||
// const items = [mine,preferred]
|
||||
for (const v of items) {
|
||||
const key = v[0] as keyof typeof TweakValuesShouldMatchedTemplate;
|
||||
const valuePreferred = escapeMarkdownValue(preferred[key]);
|
||||
const currentDisp = `${escapeMarkdownValue((trialSetting as TweakValues)?.[key])} |`;
|
||||
if ((trialSetting as TweakValues)?.[key] !== preferred[key]) {
|
||||
if (CompatibilityBreakingTweakValues.indexOf(key) !== -1) {
|
||||
rebuildRequired = true;
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
table += `| ${confName(key)} | ${currentDisp} ${valuePreferred} | \n`;
|
||||
differenceCount++;
|
||||
}
|
||||
|
||||
if (differenceCount === 0) {
|
||||
this._log(
|
||||
"The settings in the remote database are the same as the local database.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return { result: false, requireFetch: false };
|
||||
}
|
||||
const additionalMessage =
|
||||
rebuildRequired && this.core.settings.isConfigured
|
||||
? `
|
||||
|
||||
>[!WARNING]
|
||||
> Some remote configurations are not compatible with the local database of this device. Rebuilding the local database will be required.
|
||||
***Please ensure that you have time and are connected to a stable network to apply!***`
|
||||
: "";
|
||||
|
||||
const message = `
|
||||
The settings in the remote database are as follows.
|
||||
If you want to use these settings, please select "Use configured".
|
||||
If you want to keep the settings of this device, please select "Dismiss".
|
||||
|
||||
${table}
|
||||
|
||||
>[!TIP]
|
||||
> If you want to synchronise all settings, please use \`Sync settings via markdown\` after applying minimal configuration with this feature.
|
||||
|
||||
${additionalMessage}`;
|
||||
|
||||
const CHOICE_USE_REMOTE = "Use configured";
|
||||
const CHOICE_DISMISS = "Dismiss";
|
||||
// const CHOICE_AND_VALUES = [
|
||||
// [CHOICE_USE_REMOTE, preferred],
|
||||
// [CHOICE_DISMISS, false]]
|
||||
const CHOICES = [CHOICE_USE_REMOTE, CHOICE_DISMISS];
|
||||
const retKey = await this.core.confirm.askSelectStringDialogue(message, CHOICES, {
|
||||
title: "Use Remote Configuration",
|
||||
timeout: 0,
|
||||
defaultAction: CHOICE_DISMISS,
|
||||
});
|
||||
if (!retKey) return { result: false, requireFetch: false };
|
||||
if (retKey === CHOICE_DISMISS) return { result: false, requireFetch: false };
|
||||
if (retKey === CHOICE_USE_REMOTE) {
|
||||
return { result: { ...trialSetting, ...preferred }, requireFetch: rebuildRequired };
|
||||
}
|
||||
} else {
|
||||
this._log("Failed to get the preferred tweak values from the remote server.", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
return { result: false, requireFetch: false };
|
||||
} else {
|
||||
this._log("Failed to connect to the remote server.", LOG_LEVEL_NOTICE);
|
||||
return { result: false, requireFetch: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
341
src/modules/coreObsidian/ModuleFileAccessObsidian.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { normalizePath, TFile, TFolder, type ListedFiles } from "obsidian";
|
||||
import { SerializedFileAccess } from "./storageLib/SerializedFileAccess";
|
||||
import { AbstractObsidianModule, type IObsidianModule } from "../AbstractObsidianModule.ts";
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import type {
|
||||
FilePath,
|
||||
FilePathWithPrefix,
|
||||
UXDataWriteOptions,
|
||||
UXFileInfo,
|
||||
UXFileInfoStub,
|
||||
UXFolderInfo,
|
||||
UXStat,
|
||||
} from "../../lib/src/common/types";
|
||||
import { TFileToUXFileInfoStub, TFolderToUXFileInfoStub } from "./storageLib/utilObsidian.ts";
|
||||
import { StorageEventManagerObsidian, type StorageEventManager } from "./storageLib/StorageEventManager";
|
||||
import type { StorageAccess } from "../interfaces/StorageAccess";
|
||||
import { createBlob } from "../../lib/src/common/utils";
|
||||
|
||||
export class ModuleFileAccessObsidian extends AbstractObsidianModule implements IObsidianModule, StorageAccess {
|
||||
vaultAccess!: SerializedFileAccess;
|
||||
vaultManager: StorageEventManager = new StorageEventManagerObsidian(this.plugin, this.core);
|
||||
$everyOnload(): Promise<boolean> {
|
||||
this.core.storageAccess = this;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
$everyOnFirstInitialize(): Promise<boolean> {
|
||||
this.vaultManager.beginWatch();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
$allOnUnload(): Promise<boolean> {
|
||||
// this.vaultManager.
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
// $$flushFileEventQueue(): void {
|
||||
// this.vaultManager.flushQueue();
|
||||
// }
|
||||
|
||||
$everyCommitPendingFileEvent(): Promise<boolean> {
|
||||
this.vaultManager.flushQueue();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
$everyOnloadStart(): Promise<boolean> {
|
||||
this.vaultAccess = new SerializedFileAccess(this.app, this.plugin);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
$$isStorageInsensitive(): boolean {
|
||||
return this.vaultAccess.isStorageInsensitive();
|
||||
}
|
||||
|
||||
$$shouldCheckCaseInsensitive(): boolean {
|
||||
if (this.$$isStorageInsensitive()) return false;
|
||||
return !this.settings.handleFilenameCaseSensitive;
|
||||
}
|
||||
|
||||
async writeFileAuto(path: string, data: string | ArrayBuffer, opt?: UXDataWriteOptions): Promise<boolean> {
|
||||
const file = this.vaultAccess.getAbstractFileByPath(path);
|
||||
if (file instanceof TFile) {
|
||||
return this.vaultAccess.vaultModify(file, data, opt);
|
||||
} else if (file === null) {
|
||||
return (await this.vaultAccess.vaultCreate(path, data, opt)) instanceof TFile;
|
||||
} else {
|
||||
this._log(`Could not write file (Possibly already exists as a folder): ${path}`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
readFileAuto(path: string): Promise<string | ArrayBuffer> {
|
||||
const file = this.vaultAccess.getAbstractFileByPath(path);
|
||||
if (file instanceof TFile) {
|
||||
return this.vaultAccess.vaultRead(file);
|
||||
} else {
|
||||
throw new Error(`Could not read file (Possibly does not exist): ${path}`);
|
||||
}
|
||||
}
|
||||
readFileText(path: string): Promise<string> {
|
||||
const file = this.vaultAccess.getAbstractFileByPath(path);
|
||||
if (file instanceof TFile) {
|
||||
return this.vaultAccess.vaultRead(file);
|
||||
} else {
|
||||
throw new Error(`Could not read file (Possibly does not exist): ${path}`);
|
||||
}
|
||||
}
|
||||
isExists(path: string): Promise<boolean> {
|
||||
return Promise.resolve(this.vaultAccess.getAbstractFileByPath(path) instanceof TFile);
|
||||
}
|
||||
async writeHiddenFileAuto(path: string, data: string | ArrayBuffer, opt?: UXDataWriteOptions): Promise<boolean> {
|
||||
try {
|
||||
await this.vaultAccess.adapterWrite(path, data, opt);
|
||||
return true;
|
||||
} catch (e) {
|
||||
this._log(`Could not write hidden file: ${path}`, LOG_LEVEL_VERBOSE);
|
||||
this._log(e, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async appendHiddenFile(path: string, data: string, opt?: UXDataWriteOptions): Promise<boolean> {
|
||||
try {
|
||||
await this.vaultAccess.adapterAppend(path, data, opt);
|
||||
return true;
|
||||
} catch (e) {
|
||||
this._log(`Could not append hidden file: ${path}`, LOG_LEVEL_VERBOSE);
|
||||
this._log(e, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
stat(path: string): Promise<UXStat | null> {
|
||||
const file = this.vaultAccess.getAbstractFileByPath(path);
|
||||
if (file === null) return Promise.resolve(null);
|
||||
if (file instanceof TFile) {
|
||||
return Promise.resolve({
|
||||
ctime: file.stat.ctime,
|
||||
mtime: file.stat.mtime,
|
||||
size: file.stat.size,
|
||||
type: "file",
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Could not stat file (Possibly does not exist): ${path}`);
|
||||
}
|
||||
}
|
||||
statHidden(path: string): Promise<UXStat | null> {
|
||||
return this.vaultAccess.adapterStat(path);
|
||||
}
|
||||
async removeHidden(path: string): Promise<boolean> {
|
||||
try {
|
||||
await this.vaultAccess.adapterRemove(path);
|
||||
if (this.vaultAccess.adapterStat(path) !== null) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
this._log(`Could not remove hidden file: ${path}`, LOG_LEVEL_VERBOSE);
|
||||
this._log(e, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async readHiddenFileAuto(path: string): Promise<string | ArrayBuffer> {
|
||||
return await this.vaultAccess.adapterReadAuto(path);
|
||||
}
|
||||
async readHiddenFileText(path: string): Promise<string> {
|
||||
return await this.vaultAccess.adapterRead(path);
|
||||
}
|
||||
async readHiddenFileBinary(path: string): Promise<ArrayBuffer> {
|
||||
return await this.vaultAccess.adapterReadBinary(path);
|
||||
}
|
||||
async isExistsIncludeHidden(path: string): Promise<boolean> {
|
||||
return (await this.vaultAccess.adapterStat(path)) !== null;
|
||||
}
|
||||
async ensureDir(path: string): Promise<boolean> {
|
||||
try {
|
||||
await this.vaultAccess.ensureDirectory(path);
|
||||
return true;
|
||||
} catch (e) {
|
||||
this._log(`Could not ensure directory: ${path}`, LOG_LEVEL_VERBOSE);
|
||||
this._log(e, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
triggerFileEvent(event: string, path: string): void {
|
||||
// this.app.vault.trigger("file-change", path);
|
||||
this.vaultAccess.trigger(event, this.vaultAccess.getAbstractFileByPath(normalizePath(path)));
|
||||
}
|
||||
async triggerHiddenFile(path: string): Promise<void> {
|
||||
//@ts-ignore internal function
|
||||
await this.app.vault.adapter.reconcileInternalFile(path);
|
||||
}
|
||||
// getFileStub(file: TFile): UXFileInfoStub {
|
||||
// return TFileToUXFileInfoStub(file);
|
||||
// }
|
||||
getFileStub(path: string): UXFileInfoStub | null {
|
||||
const file = this.vaultAccess.getAbstractFileByPath(path);
|
||||
if (file instanceof TFile) {
|
||||
return TFileToUXFileInfoStub(file);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async readStubContent(stub: UXFileInfoStub): Promise<UXFileInfo | false> {
|
||||
const file = this.vaultAccess.getAbstractFileByPath(stub.path);
|
||||
if (!(file instanceof TFile)) {
|
||||
this._log(`Could not read file (Possibly does not exist or a folder): ${stub.path}`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
const data = await this.vaultAccess.vaultReadAuto(file);
|
||||
return {
|
||||
...stub,
|
||||
body: createBlob(data),
|
||||
};
|
||||
}
|
||||
getStub(path: string): UXFileInfoStub | UXFolderInfo | null {
|
||||
const file = this.vaultAccess.getAbstractFileByPath(path);
|
||||
if (file instanceof TFile) {
|
||||
return TFileToUXFileInfoStub(file);
|
||||
} else if (file instanceof TFolder) {
|
||||
return TFolderToUXFileInfoStub(file);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
getFiles(): UXFileInfoStub[] {
|
||||
return this.vaultAccess.getFiles().map((f) => TFileToUXFileInfoStub(f));
|
||||
}
|
||||
getFileNames(): FilePath[] {
|
||||
return this.vaultAccess.getFiles().map((f) => f.path as FilePath);
|
||||
}
|
||||
|
||||
async getFilesIncludeHidden(
|
||||
basePath: string,
|
||||
includeFilter?: RegExp[],
|
||||
excludeFilter?: RegExp[],
|
||||
skipFolder: string[] = [".git", ".trash", "node_modules"]
|
||||
): Promise<FilePath[]> {
|
||||
let w: ListedFiles;
|
||||
try {
|
||||
w = await this.app.vault.adapter.list(basePath);
|
||||
} catch (ex) {
|
||||
this._log(`Could not traverse(getFilesIncludeHidden):${basePath}`, LOG_LEVEL_INFO);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
return [];
|
||||
}
|
||||
skipFolder = skipFolder.map((e) => e.toLowerCase());
|
||||
|
||||
let files = [] as string[];
|
||||
for (const file of w.files) {
|
||||
if (excludeFilter && excludeFilter.some((ee) => file.match(ee))) {
|
||||
// If excludeFilter and includeFilter are both set, the file will be included in the list.
|
||||
if (includeFilter) {
|
||||
if (!includeFilter.some((e) => file.match(e))) continue;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (includeFilter) {
|
||||
if (!includeFilter.some((e) => file.match(e))) continue;
|
||||
}
|
||||
if (await this.plugin.$$isIgnoredByIgnoreFiles(file)) continue;
|
||||
files.push(file);
|
||||
}
|
||||
|
||||
for (const v of w.folders) {
|
||||
const folderName = (v.split("/").pop() ?? "").toLowerCase();
|
||||
if (skipFolder.some((e) => folderName === e)) {
|
||||
continue;
|
||||
}
|
||||
if (excludeFilter && excludeFilter.some((e) => v.match(e))) {
|
||||
if (includeFilter) {
|
||||
if (!includeFilter.some((e) => v.match(e))) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (includeFilter) {
|
||||
if (!includeFilter.some((e) => v.match(e))) continue;
|
||||
}
|
||||
// OK, deep dive!
|
||||
files = files.concat(await this.getFilesIncludeHidden(v, includeFilter, excludeFilter, skipFolder));
|
||||
}
|
||||
return files as FilePath[];
|
||||
}
|
||||
touched(file: UXFileInfoStub | FilePathWithPrefix): void {
|
||||
const path = typeof file === "string" ? file : file.path;
|
||||
this.vaultAccess.touch(path as FilePath);
|
||||
}
|
||||
recentlyTouched(file: UXFileInfoStub | FilePathWithPrefix): boolean {
|
||||
const xFile = typeof file === "string" ? (this.vaultAccess.getAbstractFileByPath(file) as TFile) : file;
|
||||
if (xFile === null) return false;
|
||||
if (xFile instanceof TFolder) return false;
|
||||
return this.vaultAccess.recentlyTouched(xFile);
|
||||
}
|
||||
clearTouched(): void {
|
||||
this.vaultAccess.clearTouched();
|
||||
}
|
||||
|
||||
delete(file: FilePathWithPrefix | UXFileInfoStub | string, force: boolean): Promise<void> {
|
||||
const xPath = typeof file === "string" ? file : file.path;
|
||||
const xFile = this.vaultAccess.getAbstractFileByPath(xPath);
|
||||
if (xFile === null) return Promise.resolve();
|
||||
if (!(xFile instanceof TFile) && !(xFile instanceof TFolder)) return Promise.resolve();
|
||||
return this.vaultAccess.delete(xFile, force);
|
||||
}
|
||||
trash(file: FilePathWithPrefix | UXFileInfoStub | string, system: boolean): Promise<void> {
|
||||
const xPath = typeof file === "string" ? file : file.path;
|
||||
const xFile = this.vaultAccess.getAbstractFileByPath(xPath);
|
||||
if (xFile === null) return Promise.resolve();
|
||||
if (!(xFile instanceof TFile) && !(xFile instanceof TFolder)) return Promise.resolve();
|
||||
return this.vaultAccess.trash(xFile, system);
|
||||
}
|
||||
// $readFileBinary(path: string): Promise<ArrayBuffer> {
|
||||
// const file = this.vaultAccess.getAbstractFileByPath(path);
|
||||
// if (file instanceof TFile) {
|
||||
// return this.vaultAccess.vaultReadBinary(file);
|
||||
// } else {
|
||||
// throw new Error(`Could not read file (Possibly does not exist): ${path}`);
|
||||
// }
|
||||
// }
|
||||
// async $appendFileAuto(path: string, data: string | ArrayBuffer, opt?: DataWriteOptions): Promise<boolean> {
|
||||
// const file = this.vaultAccess.getAbstractFileByPath(path);
|
||||
// if (file instanceof TFile) {
|
||||
// return this.vaultAccess.a(file, data, opt);
|
||||
// } else if (file !== null) {
|
||||
// return await this.vaultAccess.vaultCreate(path, data, opt) instanceof TFile;
|
||||
// } else {
|
||||
// this._log(`Could not append file (Possibly already exists as a folder): ${path}`, LOG_LEVEL_VERBOSE);
|
||||
// return false;
|
||||
// }
|
||||
// }
|
||||
|
||||
async _deleteVaultItem(file: TFile | TFolder) {
|
||||
if (file instanceof TFile) {
|
||||
if (!(await this.core.$$isTargetFile(file.path))) return;
|
||||
}
|
||||
const dir = file.parent;
|
||||
if (this.settings.trashInsteadDelete) {
|
||||
await this.vaultAccess.trash(file, false);
|
||||
} else {
|
||||
await this.vaultAccess.delete(file, true);
|
||||
}
|
||||
this._log(`xxx <- STORAGE (deleted) ${file.path}`);
|
||||
if (dir) {
|
||||
this._log(`files: ${dir.children.length}`);
|
||||
if (dir.children.length == 0) {
|
||||
if (!this.settings.doNotDeleteFolder) {
|
||||
this._log(
|
||||
`All files under the parent directory (${dir.path}) have been deleted, so delete this one.`
|
||||
);
|
||||
await this._deleteVaultItem(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deleteVaultItem(fileSrc: FilePathWithPrefix | UXFileInfoStub | UXFolderInfo): Promise<void> {
|
||||
const path = typeof fileSrc === "string" ? fileSrc : fileSrc.path;
|
||||
const file = this.vaultAccess.getAbstractFileByPath(path);
|
||||
if (file === null) return;
|
||||
if (file instanceof TFile || file instanceof TFolder) {
|
||||
return await this._deleteVaultItem(file);
|
||||
}
|
||||
}
|
||||
}
|
||||