Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d8463a3ed | ||
|
|
9fe1b1d5e6 | ||
|
|
df7b6e1848 | ||
|
|
a9126e5947 | ||
|
|
3bb7355db3 | ||
|
|
f33fa26c03 | ||
|
|
5ce350ba41 | ||
|
|
1d32f1242d | ||
|
|
d8ab054da1 | ||
|
|
b877228415 | ||
|
|
28b99557a8 | ||
|
|
ae28cf9183 | ||
|
|
8f68ac4ded | ||
|
|
220fd07a8b | ||
|
|
fe572b41c9 | ||
|
|
ca5ca5f576 | ||
|
|
0e6182b887 | ||
|
|
c5a7085fda | ||
|
|
640f1e56f1 | ||
|
|
cb6f7e572c | ||
|
|
83e0073134 | ||
|
|
cfe316f690 | ||
|
|
c472654060 | ||
|
|
46cbfcc3aa | ||
|
|
ce94a6d79c | ||
|
|
0fc0dcad64 | ||
|
|
577cdde21f | ||
|
|
583b365e72 | ||
|
|
3d213b2be8 | ||
|
|
ee224bf4f2 | ||
|
|
f1bd0b1ce6 | ||
|
|
300ed213af | ||
|
|
4be67ce491 | ||
|
|
98107ba4ea | ||
|
|
b674dd0f10 | ||
|
|
7930509e2a | ||
|
|
1c918d82da | ||
|
|
593fd7471b | ||
|
|
222f386586 | ||
|
|
dcd02457cb | ||
|
|
791c0e8df6 | ||
|
|
c37bf6aedd | ||
|
|
bed28d9f0b | ||
|
|
02e03681f7 | ||
|
|
62452341a3 | ||
|
|
bff2f6a642 | ||
|
|
833fdee69e | ||
|
|
936fce76a1 | ||
|
|
e2e8265d43 | ||
|
|
e228250613 | ||
|
|
dde4327249 | ||
|
|
e283efc8f7 | ||
|
|
6825241071 | ||
|
|
98380b6c92 | ||
|
|
d3eb2166aa | ||
|
|
7ecce29940 | ||
|
|
581c6237cc | ||
|
|
257c995090 | ||
|
|
fd095a9093 | ||
|
|
acc9233611 | ||
|
|
4456db11cf | ||
|
|
54449dd207 | ||
|
|
822941f1c3 | ||
|
|
62b176d709 | ||
|
|
9553bff44a | ||
|
|
ede1f4e1bc | ||
|
|
97f4c0c427 | ||
|
|
94a56dabdd | ||
|
|
4947eb91a2 | ||
|
|
2d12c9977d | ||
|
|
f2794ac539 | ||
|
|
519fd27dd5 | ||
|
|
dcbd36e33a | ||
|
|
e40a6adf63 | ||
|
|
06bf59bf2a | ||
|
|
d51659d3d1 | ||
|
|
7ec0102c7a | ||
|
|
c828a341ba | ||
|
|
d7ff793715 | ||
|
|
996533328e | ||
|
|
aff5a8b088 | ||
|
|
9fc46553e1 | ||
|
|
447e0c0aa4 | ||
|
|
768d6636d0 | ||
|
|
5f5b5de505 | ||
|
|
5df06bbed5 | ||
|
|
1f4737bfb8 | ||
|
|
40020c3e44 | ||
|
|
c512330d84 | ||
|
|
e34b120a63 | ||
|
|
725b12832c | ||
|
|
e341c4f780 | ||
|
|
10d22cb912 | ||
|
|
06b15b2146 | ||
|
|
2fbd87eed4 | ||
|
|
f6ea9938d1 | ||
|
|
38b189e2a7 | ||
|
|
6d1b451f4c | ||
|
|
69537f7002 | ||
|
|
17de78240f | ||
|
|
8cbcdf5323 | ||
|
|
6ba5abc0a6 | ||
|
|
d820285d1e | ||
|
|
a6002ba340 | ||
|
|
1f31303906 | ||
|
|
0fcedf397f | ||
|
|
085d8448d2 | ||
|
|
0d9da32b00 | ||
|
|
22880d6cf5 | ||
|
|
a084ec7728 | ||
|
|
eeeb4893c7 | ||
|
|
ed1ac66ac0 | ||
|
|
c33a9ecfa4 | ||
|
|
cabca54d80 | ||
|
|
8dde6c59d2 | ||
|
|
480cf224c6 | ||
|
|
37a801332f | ||
|
|
1e33f8b7d2 | ||
|
|
071b10387c | ||
|
|
b99053ef10 | ||
|
|
ff237ad8cb | ||
|
|
b89a163a66 | ||
|
|
991376a7a3 | ||
|
|
2da39bdfec | ||
|
|
f38c3d36be | ||
|
|
da0f183fad | ||
|
|
eb2ecca825 | ||
|
|
2494a2d96e | ||
|
|
8bb095c4aa | ||
|
|
c5e3147805 | ||
|
|
942e54961e | ||
|
|
29a3c76b4e | ||
|
|
897e4fc4e5 | ||
|
|
0124a49d7e | ||
|
|
3e245af50c | ||
|
|
695c74588f | ||
|
|
e3bd41a05a | ||
|
|
a6b73bde6a | ||
|
|
87d6e1f75f | ||
|
|
347c051d0c | ||
|
|
7422956621 | ||
|
|
7b67ba5b26 | ||
|
|
43cf95c417 | ||
|
|
53a1c32b43 | ||
|
|
71e25be28e | ||
|
|
afc5670313 | ||
|
|
81eb45d32c |
@@ -31,13 +31,30 @@ body:
|
|||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: service
|
id: service
|
||||||
attributes:
|
attributes:
|
||||||
label: What remote cloud services are you using?
|
label: What remote cloud services are you using? (Please choose the specified one if it's in the list)
|
||||||
multiple: true
|
multiple: true
|
||||||
options:
|
options:
|
||||||
- S3
|
- S3
|
||||||
|
- S3 (Cloudflare R2)
|
||||||
|
- S3 (BackBlaze B2)
|
||||||
|
- S3 (腾讯云 COS Tencent Cloud COS)
|
||||||
|
- S3 (阿里云 OSS Alibaba Cloud OSS)
|
||||||
|
- S3 (MinIO)
|
||||||
|
- S3 (Wasabi)
|
||||||
|
- S3 (Storj)
|
||||||
- OneDrive for personal
|
- OneDrive for personal
|
||||||
|
- OneDrive for business
|
||||||
- Dropbox
|
- Dropbox
|
||||||
- webdav
|
- webdav
|
||||||
|
- webdav (ownCloud)
|
||||||
|
- webdav (InfiniCloud (formally TeraCLOUD))
|
||||||
|
- webdav (AList)
|
||||||
|
- webdav (Cloudreve)
|
||||||
|
- webdav (坚果云 JianGuoYun/NutStore)
|
||||||
|
- webdav (NextCloud)
|
||||||
|
- webdav (FastMail)
|
||||||
|
- webdav (rclone webdav)
|
||||||
|
- webdav (nginx)
|
||||||
- others
|
- others
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -21,9 +21,26 @@ body:
|
|||||||
multiple: true
|
multiple: true
|
||||||
options:
|
options:
|
||||||
- S3
|
- S3
|
||||||
|
- S3 (Cloudflare R2)
|
||||||
|
- S3 (BackBlaze B2)
|
||||||
|
- S3 (腾讯云 COS Tencent Cloud COS)
|
||||||
|
- S3 (阿里云 OSS Alibaba Cloud OSS)
|
||||||
|
- S3 (MinIO)
|
||||||
|
- S3 (Wasabi)
|
||||||
|
- S3 (Storj)
|
||||||
- OneDrive for personal
|
- OneDrive for personal
|
||||||
|
- OneDrive for business
|
||||||
- Dropbox
|
- Dropbox
|
||||||
- webdav
|
- webdav
|
||||||
|
- webdav (ownCloud)
|
||||||
|
- webdav (InfiniCloud (formally TeraCLOUD))
|
||||||
|
- webdav (AList)
|
||||||
|
- webdav (Cloudreve)
|
||||||
|
- webdav (坚果云 JianGuoYun/NutStore)
|
||||||
|
- webdav (NextCloud)
|
||||||
|
- webdav (FastMail)
|
||||||
|
- webdav (rclone webdav)
|
||||||
|
- webdav (nginx)
|
||||||
- others
|
- others
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
|
|||||||
@@ -17,23 +17,22 @@ This is yet another unofficial sync plugin for Obsidian. If you like it or find
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Supports:
|
- Supports:
|
||||||
- Amazon S3 or S3-compatible
|
- Amazon S3 or S3-compatible (Cloudflare R2 / BackBlaze B2 / MinIO / ...)
|
||||||
- Dropbox
|
- Dropbox
|
||||||
- OneDrive for personal
|
- OneDrive for personal
|
||||||
- Webdav
|
- Webdav
|
||||||
- [Here](./docs/services_connectable_or_not.md) shows more connectable (or not-connectable) services in details.
|
- [Here](./docs/services_connectable_or_not.md) shows more connectable (or not-connectable) services in details.
|
||||||
- **Obsidian Mobile supported.** Vaults can be synced across mobile and desktop devices with the cloud service as the "broker".
|
- **Obsidian Mobile supported.** Vaults can be synced across mobile and desktop devices with the cloud service as the "broker".
|
||||||
- **[End-to-end encryption](./docs/encryption.md) supported.** Files would be encrypted using openssl format before being sent to the cloud **if** user specify a password.
|
- **[End-to-end encryption](./docs/encryption/README.md) supported.** Files would be encrypted using openssl format before being sent to the cloud **if** user specify a password.
|
||||||
- **Scheduled auto sync supported.** You can also manually trigger the sync using sidebar ribbon, or using the command from the command palette (or even bind the hot key combination to the command then press the hot key combination).
|
- **Scheduled auto sync supported.** You can also manually trigger the sync using sidebar ribbon, or using the command from the command palette (or even bind the hot key combination to the command then press the hot key combination).
|
||||||
- **[Minimal Intrusive](./docs/minimal_intrusive_design.md).**
|
- **[Minimal Intrusive](./docs/minimal_intrusive_design.md).**
|
||||||
- \*\*Skip Large files and skip paths by custom regex conditions!
|
- **Skip Large files** and **skip paths** by custom regex conditions!
|
||||||
- **Fully open source under [Apache-2.0 License](./LICENSE).**
|
- **Fully open source under [Apache-2.0 License](./LICENSE).**
|
||||||
- **[Sync Algorithm open](./docs/sync_algorithm_v2.md) for discussion.**
|
- **[Sync Algorithm open](./docs/sync_algorithm/v3/intro.md) for discussion.**
|
||||||
|
- **[Basic Conflict Detection And Handling](./docs/sync_algorithm/v3/intro.md)** now, more to come!
|
||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
- **To support deletions sync, extra metadata will also be uploaded.** See [Minimal Intrusive](./docs/minimal_intrusive_design.md).
|
|
||||||
- **No Conflict resolution. No content-diff-and-patch algorithm.** All files and folders are compared using their local and remote "last modified time" and those with later "last modified time" wins.
|
|
||||||
- **Cloud services cost you money.** Always be aware of the costs and pricing. Specifically, all the operations, including but not limited to downloading, uploading, listing all files, calling any api, storage sizes, may or may not cost you money.
|
- **Cloud services cost you money.** Always be aware of the costs and pricing. Specifically, all the operations, including but not limited to downloading, uploading, listing all files, calling any api, storage sizes, may or may not cost you money.
|
||||||
- **Some limitations from the browser environment.** More technical details are [in the doc](./docs/browser_env.md).
|
- **Some limitations from the browser environment.** More technical details are [in the doc](./docs/browser_env.md).
|
||||||
- **You should protect your `data.json` file.** The file contains sensitive information.
|
- **You should protect your `data.json` file.** The file contains sensitive information.
|
||||||
@@ -63,7 +62,11 @@ Additionally, the plugin author may occasionally visit Obsidian official forum a
|
|||||||
|
|
||||||
- Tutorials / Examples:
|
- Tutorials / Examples:
|
||||||
- [Cloudflare R2](./docs/remote_services/s3_cloudflare_r2/README.md)
|
- [Cloudflare R2](./docs/remote_services/s3_cloudflare_r2/README.md)
|
||||||
|
- [BackBlaze B2](./docs/remote_services/s3_backblaze_b2/README.md)
|
||||||
|
- [Storj](./docs/remote_services/s3_storj_io/README.md)
|
||||||
|
- [腾讯云 COS](./docs/remote_services/s3_tencent_cloud_cos/README.zh-cn.md) | [Tencent Cloud COS](./docs/remote_services/s3_tencent_cloud_cos/README.md)
|
||||||
- [MinIO](./docs/remote_services/s3_minio/README.md)
|
- [MinIO](./docs/remote_services/s3_minio/README.md)
|
||||||
|
- [又拍云](./docs/remote_services/s3_upyun/README.zh-cn.md)
|
||||||
- Prepare your S3 (-compatible) service information: [endpoint, region](https://docs.aws.amazon.com/general/latest/gr/s3.html), [access key id, secret access key](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/getting-your-credentials.html), bucket name. The bucket should be empty and solely for syncing a vault.
|
- Prepare your S3 (-compatible) service information: [endpoint, region](https://docs.aws.amazon.com/general/latest/gr/s3.html), [access key id, secret access key](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/getting-your-credentials.html), bucket name. The bucket should be empty and solely for syncing a vault.
|
||||||
- If you are using AWS S3, create [policy and user](./docs/remote_services/s3_general/s3_user_policy.md).
|
- If you are using AWS S3, create [policy and user](./docs/remote_services/s3_general/s3_user_policy.md).
|
||||||
- Very old version of Obsidian needs [configuring CORS](./docs/remote_services/s3_general/s3_cors_configure.md).
|
- Very old version of Obsidian needs [configuring CORS](./docs/remote_services/s3_general/s3_cors_configure.md).
|
||||||
@@ -72,6 +75,7 @@ Additionally, the plugin author may occasionally visit Obsidian official forum a
|
|||||||
- If you want to enable end-to-end encryption, also set a password in settings. If you do not specify a password, the files and folders are synced in plain, original content to the cloud.
|
- If you want to enable end-to-end encryption, also set a password in settings. If you do not specify a password, the files and folders are synced in plain, original content to the cloud.
|
||||||
- Click the new "circle arrow" icon on the ribbon (the left sidebar), **every time** you want to sync your vault between local and remote. (Or, you could configure auto sync in the settings panel (See next chapter).) While syncing, the icon becomes "two half-circle arrows". Besides clicking the icon on the sidebar ribbon, you can also activate the corresponding command in the command palette.
|
- Click the new "circle arrow" icon on the ribbon (the left sidebar), **every time** you want to sync your vault between local and remote. (Or, you could configure auto sync in the settings panel (See next chapter).) While syncing, the icon becomes "two half-circle arrows". Besides clicking the icon on the sidebar ribbon, you can also activate the corresponding command in the command palette.
|
||||||
- **Be patient while syncing.** Especially in the first-time sync.
|
- **Be patient while syncing.** Especially in the first-time sync.
|
||||||
|
- If you want to sync the files across multiple devices, **your vault name should be the same** while using default settings.
|
||||||
|
|
||||||
### Dropbox
|
### Dropbox
|
||||||
|
|
||||||
@@ -79,6 +83,7 @@ Additionally, the plugin author may occasionally visit Obsidian official forum a
|
|||||||
- After the authorization, the plugin can read your name and email (which cannot be unselected on Dropbox api), and read and write files in your Dropbox's `/Apps/remotely-save` folder.
|
- After the authorization, the plugin can read your name and email (which cannot be unselected on Dropbox api), and read and write files in your Dropbox's `/Apps/remotely-save` folder.
|
||||||
- If you decide to authorize this plugin to connect to Dropbox, please go to plugin's settings, and choose Dropbox then follow the instructions. [More with screenshot is here](./docs/dropbox_review_material/README.md).
|
- If you decide to authorize this plugin to connect to Dropbox, please go to plugin's settings, and choose Dropbox then follow the instructions. [More with screenshot is here](./docs/dropbox_review_material/README.md).
|
||||||
- Password-based end-to-end encryption is also supported. But please be aware that **the vault name itself is not encrypted**.
|
- Password-based end-to-end encryption is also supported. But please be aware that **the vault name itself is not encrypted**.
|
||||||
|
- If you want to sync the files across multiple devices, **your vault name should be the same** while using default settings.
|
||||||
|
|
||||||
### OneDrive for personal
|
### OneDrive for personal
|
||||||
|
|
||||||
@@ -87,16 +92,21 @@ Additionally, the plugin author may occasionally visit Obsidian official forum a
|
|||||||
- After the authorization, the plugin can read your name and email, and read and write files in your OneDrive's `/Apps/remotely-save` folder.
|
- After the authorization, the plugin can read your name and email, and read and write files in your OneDrive's `/Apps/remotely-save` folder.
|
||||||
- If you decide to authorize this plugin to connect to OneDrive, please go to plugin's settings, and choose OneDrive then follow the instructions.
|
- If you decide to authorize this plugin to connect to OneDrive, please go to plugin's settings, and choose OneDrive then follow the instructions.
|
||||||
- Password-based end-to-end encryption is also supported. But please be aware that **the vault name itself is not encrypted**.
|
- Password-based end-to-end encryption is also supported. But please be aware that **the vault name itself is not encrypted**.
|
||||||
|
- If you want to sync the files across multiple devices, **your vault name should be the same** while using default settings.
|
||||||
|
- You might also want to checkout [faq for OneDrive](./docs/remote_services/onedrive/README.md).
|
||||||
|
|
||||||
### webdav
|
### webdav
|
||||||
|
|
||||||
- Tutorials / Examples:
|
- Tutorials / Examples:
|
||||||
- [ownCloud](./docs/remote_services/webdav_owncloud/README.md)
|
- [ownCloud](./docs/remote_services/webdav_owncloud/README.md)
|
||||||
- [InfiniCloud](./docs/remote_services/webdav_infinicloud_teracloud/README.md)
|
- [InfiniCloud](./docs/remote_services/webdav_infinicloud_teracloud/README.md)
|
||||||
- [坚果云 JianGuoYun/NutStore](./docs/remote_services/webdav_jianguoyun/README.md)
|
- [Synology webdav server](./docs/remote_services/webdav_synology_webdav_server/README.md) | [群晖 webdav server](./docs/remote_services/webdav_synology_webdav_server/README.zh-cn.md)
|
||||||
|
- [AList(中文)](./docs/remote_services/webdav_alist/README.zh-cn.md) | [AList (English)](./docs/remote_services/webdav_alist/README.md)
|
||||||
|
- [坚果云](./docs/remote_services/webdav_jianguoyun/README.zh-cn.md) | [JianGuoYun/NutStore](./docs/remote_services/webdav_jianguoyun/README.md)
|
||||||
- Very old version of Obsidian needs [configuring CORS](./docs/remote_services/webdav_general/webav_cors.md).
|
- Very old version of Obsidian needs [configuring CORS](./docs/remote_services/webdav_general/webav_cors.md).
|
||||||
- Your data would be synced to a `${vaultName}` sub folder on your webdav server.
|
- Your data would be synced to a `${vaultName}` sub folder on your webdav server.
|
||||||
- Password-based end-to-end encryption is also supported. But please be aware that **the vault name itself is not encrypted**.
|
- Password-based end-to-end encryption is also supported. But please be aware that **the vault name itself is not encrypted**.
|
||||||
|
- If you want to sync the files across multiple devices, **your vault name should be the same** while using default settings.
|
||||||
|
|
||||||
## Scheduled Auto Sync
|
## Scheduled Auto Sync
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Encryption
|
||||||
|
|
||||||
|
Currently (March 2024), Remotely Save supports two end to end encryption format:
|
||||||
|
|
||||||
|
1. [RClone Crypt](./rclone.md) format, which is the recommend way now.
|
||||||
|
2. [OpenSSL enc](./openssl.md) format
|
||||||
|
|
||||||
|
Here is also the [comparation](./comparation.md).
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Comparation Between Encryption Formats
|
||||||
|
|
||||||
|
## Warning
|
||||||
|
|
||||||
|
**ALWAYS BACKUP YOUR VAULT MANUALLY!!!**
|
||||||
|
|
||||||
|
If you switch between RClone Crypt format and OpenSSL enc format, you have to delete the cloud vault files **manually** and **fully**, so that the plugin can re-sync (i.e. re-upload) the newly encrypted versions to the cloud.
|
||||||
|
|
||||||
|
## The feature table
|
||||||
|
|
||||||
|
| | RClone Crypt | OpenSSL enc | comments |
|
||||||
|
| ------------------------ | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| key generation | scrypt with fixed salt | PBKDF2 with dynamic salt | scrypt is better than PBKDF2 from the algorithm aspect. But RClone uses fixed salt by default. Also the parameters might affect the result. |
|
||||||
|
| content encryption | XSalsa20Poly1305 on chunks | AES-256-CBC | XSalsa20Poly1305 is way better than AES-256-CBC. And encryption by chunks should require less resources. |
|
||||||
|
| file name encryption | EME on each segment of the path | AES-256-CBC on the whole path | RClone has the benefit as well as pitfall that the path structure is preserved. Maybe it's more of a design decision difference? No comment on EME and AES-256-CBC. |
|
||||||
|
| viewing decrypted result | RClone has command that can mount the encrypted vault as if the encryption is transparent. | No convenient way except writing some scripts we are aware of. | RClone is way more convenient. |
|
||||||
|
|
||||||
|
## Some notes
|
||||||
|
|
||||||
|
1. Anyway, security is a hard problem. The author of Remotely Save doesn't have sufficient knowledge to "judge" which one is the better format. **Use them at your own risk.**
|
||||||
|
2. Currently the RClone Crypt format is recommended by default in Remotely Save. Just because of the taste from the Remotely Save author, who likes RClone.
|
||||||
|
3. **Always use a long password.**
|
||||||
|
4. Both algorithms are selected deliberately to **be compatible with some well-known third-party tools** (instead of some home-made methods) and **have many tests to ensure the correctness**.
|
||||||
@@ -1,10 +1,22 @@
|
|||||||
# Encryption
|
# OpenSSL enc format
|
||||||
|
|
||||||
If a password is set, the files are encrypted before being sent to the cloud.
|
If a password is set, the files are encrypted before being sent to the cloud.
|
||||||
|
|
||||||
The encryption algorithm is delibrately designed to be aligned with openssl format.
|
## Warning
|
||||||
|
|
||||||
1. The encryption algorithm is implemented using web-crypto.
|
**ALWAYS BACKUP YOUR VAULT MANUALLY!!!**
|
||||||
|
|
||||||
|
If you switch between RClone Crypt format and OpenSSL enc format, you have to delete the cloud vault files **manually** and **fully**, so that the plugin can re-sync (i.e. re-upload) the newly encrypted versions to the cloud.
|
||||||
|
|
||||||
|
## Comparation between encryption formats
|
||||||
|
|
||||||
|
See the doc [Comparation](./comparation.md).
|
||||||
|
|
||||||
|
## Interoperability with official OpenSSL
|
||||||
|
|
||||||
|
This encryption algorithm is delibrately designed to be aligned with openssl format.
|
||||||
|
|
||||||
|
1. The encryption algorithm is implemented using web-crypto. Using AES-256-CBC.
|
||||||
2. The file content is encrypted using openssl format. Assuming a file named `sometext.txt`, a password `somepassword`, then the encryption is equivalent to the following command:
|
2. The file content is encrypted using openssl format. Assuming a file named `sometext.txt`, a password `somepassword`, then the encryption is equivalent to the following command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# RClone Crypt format
|
||||||
|
|
||||||
|
The encryption is compatible with RClone Crypt with **base64** name encryption format.
|
||||||
|
|
||||||
|
It's developed based on another js project by the same author of Remotely Save: [`@fyears/rclone-crypt`](https://github.com/fyears/rclone-crypt), which is NOT an official library from RClone, and is NOT affiliated with RClone.
|
||||||
|
|
||||||
|
Reasonable tests are also ported from official RClone code, to ensure the compatibility and correctness of the encryption.
|
||||||
|
|
||||||
|
## Warning
|
||||||
|
|
||||||
|
**ALWAYS BACKUP YOUR VAULT MANUALLY!!!**
|
||||||
|
|
||||||
|
If you switch between RClone Crypt format and OpenSSL enc format, you have to delete the cloud vault files **manually** and **fully**, so that the plugin can re-sync (i.e. re-upload) the newly encrypted versions to the cloud.
|
||||||
|
|
||||||
|
## Comparation between encryption formats
|
||||||
|
|
||||||
|
See the doc [Comparation](./comparation.md).
|
||||||
|
|
||||||
|
## Interoperability with official RClone
|
||||||
|
|
||||||
|
Please pay attention that the plugin uses **base64** of encrypted file names, while official RClone by default uses **base32** file names. The intention is purely for potentially support longer file names.
|
||||||
|
|
||||||
|
You could set up the RClone profile by calling `rclone config`. You need to create two profiles, one for your original connection and the other for RClone Crypt.
|
||||||
|
|
||||||
|
Finally, a working config file should like this:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[webdav1]
|
||||||
|
type = webdav
|
||||||
|
url = https://example.com/sharefolder1/subfolder1 # the same as the web address in Remotely Save settings.
|
||||||
|
vendor = other
|
||||||
|
user = <some webdav username>
|
||||||
|
pass = <some webdav password, obfuscated>
|
||||||
|
|
||||||
|
[webdav1crypt]
|
||||||
|
type = crypt
|
||||||
|
remote = nas1test:vaultname # the same as your "Remote Base Directory" (usually the vault name) in Remotely Save settings
|
||||||
|
password = <some encryption password, obfuscated>
|
||||||
|
filename_encoding = base64 # don't forget this!!!
|
||||||
|
```
|
||||||
|
|
||||||
|
You can use the `mount` command to view and see the files in file explorer! On Windows, the command should like this (the remote vault is mounted to drive `X:`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rclone mount webdav1crypt: X: --network-mode
|
||||||
|
```
|
||||||
@@ -12,8 +12,8 @@ See [here](./export_sync_plans.md).
|
|||||||
|
|
||||||
See [here](./check_console_output.md).
|
See [here](./check_console_output.md).
|
||||||
|
|
||||||
## Advanced: Save Console Output Then Read Them Later
|
## Advanced: Use `Logstravaganza` to export logs
|
||||||
|
|
||||||
This method works for desktop and mobile devices (iOS, Android).
|
This method works for desktop and mobile devices (iOS, Android), especially useful for iOS.
|
||||||
|
|
||||||
See [here](./save_console_output_and_export.md).
|
See [here](./use_logstravaganza.md).
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
# Save Console Output And Read Them Later
|
|
||||||
|
|
||||||
## Disable Auto Sync Firstly
|
|
||||||
|
|
||||||
You should disable auto sync to avoid any unexpected running.
|
|
||||||
|
|
||||||
## Set The Output Level To Debug
|
|
||||||
|
|
||||||
Go to the plugin settings, scroll down to the section "Debug" -> "alter console log level", and change it from "info" to "debug".
|
|
||||||
|
|
||||||
## Enable Saving The Output To DB
|
|
||||||
|
|
||||||
Go to the plugin settings, scroll down to the section "Debug" -> "Save Console Logs Into DB", and change it from "disable" to "enable". **This setting has some performance cost, so do NOT always turn this on when not necessary!**
|
|
||||||
|
|
||||||
## Run The Sync
|
|
||||||
|
|
||||||
Trigger the sync manually (by clicking the icon on the ribbon sidebar). Something (hopefully) helpful should show up in the console. The the console logs are also saved into DB now.
|
|
||||||
|
|
||||||
## Export The Output And Read The Logs
|
|
||||||
|
|
||||||
Go to the plugin settings, scroll down to the section "Debug" -> "Export Console Logs From DB", and click the button. A new file `log_hist_exported_on_....md` should be created inside the special folder `_debug_remotely_save/`. You could read it and hopefully find something useful.
|
|
||||||
|
|
||||||
## Disable Saving The Output To DB
|
|
||||||
|
|
||||||
After debugging, go to the plugin settings, scroll down to the section "Debug" -> "Save Console Logs Into DB", and change it from "enable" to "disable".
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Use `Logstravaganza`
|
||||||
|
|
||||||
|
On iOS, it's quite hard to directly check the console logs.
|
||||||
|
|
||||||
|
Luckily, there is a third-party plugin: [`Logstravaganza`](https://obsidian.md/plugins?search=Logstravaganza#), by Carlo Zottmann, that can redirect the output to a note.
|
||||||
|
|
||||||
|
You can just:
|
||||||
|
|
||||||
|
1. Install it.
|
||||||
|
2. Enable it.
|
||||||
|
3. Do something, to trigger some console logs.
|
||||||
|
4. Checkout `LOGGING-NOTE (device name).md` in the root of your vault.
|
||||||
|
|
||||||
|
See more on its site: <https://github.com/czottmann/obsidian-logstravaganza>.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# How to receive `obsidian://` in Linux
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
For example, when we are authorizing OneDrive, we have to jump back to Obsidian automatically using `obsidian://`.
|
||||||
|
|
||||||
|
## Short Desc From Official Obsidian Doc
|
||||||
|
|
||||||
|
Official doc has some explanation:
|
||||||
|
|
||||||
|
<https://help.obsidian.md/Extending+Obsidian/Obsidian+URI#Register+Obsidian+URI>
|
||||||
|
|
||||||
|
# Long Desc
|
||||||
|
|
||||||
|
Assuming the username is `somebody`, and the `.AppImage` file is downloaded to `~/Desktop`.
|
||||||
|
|
||||||
|
1. Download and **extract** the app image file in terminal
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/somebody/Desktop
|
||||||
|
chmod +x Obsidian-x.y.z.AppImage
|
||||||
|
./Obsidian-x.y.z.AppImage --appimage-extract
|
||||||
|
|
||||||
|
# you should have the folder squashfs-root
|
||||||
|
# we want to rename it
|
||||||
|
mv squashfs-root Obsidian
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create a `.desktop` file
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# copy and paste the follow MULTI LINE command
|
||||||
|
# you might need to input your password because it requires root privilege
|
||||||
|
# remember to adjust the path
|
||||||
|
cat > ~/Desktop/obsidian.desktop <<EOF
|
||||||
|
[Desktop Entry]
|
||||||
|
Name=Obsidian
|
||||||
|
Comment=obsidian
|
||||||
|
Exec=/home/somebody/Desktop/Obsidian/obsidian %u
|
||||||
|
Keywords=obsidian
|
||||||
|
StartupNotify=true
|
||||||
|
Terminal=false
|
||||||
|
Type=Application
|
||||||
|
Icon=/home/somebody/Desktop/Obsidian/obsidian.png
|
||||||
|
MimeType=x-scheme-handler/obsidian;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# yeah we can check out the output
|
||||||
|
cat ~/Desktop/obsidian.desktop
|
||||||
|
## [Desktop Entry]
|
||||||
|
## ...
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Right click the `obsidian.desktop` file on the Desktop, and click "Allow launching"
|
||||||
|
|
||||||
|
4. Double click the `obsidian.desktop` file.
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
# Minimal Intrusive Design
|
# Minimal Intrusive Design
|
||||||
|
|
||||||
Before version 0.3.0, the plugin did not upload additional meta data to the remote.
|
~~Before version 0.3.0, the plugin did not upload additional meta data to the remote.~~
|
||||||
|
|
||||||
From and after version 0.3.0, the plugin just upload minimal extra necessary meta data to the remote.
|
~~From version 0.3.0 ~ 0.3.40, the plugin just upload minimal extra necessary meta data to the remote.~~
|
||||||
|
|
||||||
|
From version 0.4.1 and above, the plugin doesn't need uploading meta data due to the sync algorithm upgrade.
|
||||||
|
|
||||||
## Benefits
|
## Benefits
|
||||||
|
|
||||||
@@ -12,10 +14,14 @@ For example, it's possbile for a uses to manually upload a file to s3, and next
|
|||||||
|
|
||||||
And it's also possible to combine another "sync-to-s3" solution (like, another software) on desktops, and this plugin on mobile devices, together.
|
And it's also possible to combine another "sync-to-s3" solution (like, another software) on desktops, and this plugin on mobile devices, together.
|
||||||
|
|
||||||
## Necessarity Of Uploading Extra Metadata
|
## ~~Necessarity Of Uploading Extra Metadata from 0.3.0 ~ 0.3.40~~
|
||||||
|
|
||||||
The main issue comes from deletions (and renamings which is actually interpreted as "deletion-then-creation").
|
~~The main issue comes from deletions (and renamings which is actually interpreted as "deletion-then-creation").~~
|
||||||
|
|
||||||
If we don't upload any extra info to the remote, there's usually no way for the second device to know what files / folders have been deleted on the first device.
|
~~If we don't upload any extra info to the remote, there's usually no way for the second device to know what files / folders have been deleted on the first device.~~
|
||||||
|
|
||||||
To overcome this issue, from and after version 0.3.0, the plugin uploads extra metadata files `_remotely-save-metadata-on-remote.{json,bin}` to users' configured cloud services. Those files contain some info about what has been deleted on the first device, so that the second device can read the list to apply the deletions to itself. Some other necessary meta info would also be written into the extra files.
|
~~To overcome this issue, from and after version 0.3.0, the plugin uploads extra metadata files `_remotely-save-metadata-on-remote.{json,bin}` to users' configured cloud services. Those files contain some info about what has been deleted on the first device, so that the second device can read the list to apply the deletions to itself. Some other necessary meta info would also be written into the extra files.~~
|
||||||
|
|
||||||
|
## No uploading extra metadata from 0.4.1
|
||||||
|
|
||||||
|
Some information, including previous successful sync status of each file, is kept locally.
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# OneDrive
|
||||||
|
|
||||||
|
- **This plugin is NOT an official Microsoft / OneDrive product.** The plugin just uses Microsoft's [OneDrive's public API](https://docs.microsoft.com/en-us/onedrive/developer/rest-api).
|
||||||
|
- After the authorization, the plugin can read your name and email, and read and write files in your OneDrive's `/Apps/remotely-save` folder.
|
||||||
|
- If you decide to authorize this plugin to connect to OneDrive, please go to plugin's settings, and choose OneDrive then follow the instructions.
|
||||||
|
- Password-based end-to-end encryption is also supported. But please be aware that **the vault name itself is not encrypted**.
|
||||||
|
- If you want to sync the files across multiple devices, **your vault name should be the same** while using default settings.
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
### How about OneDrive for Business?
|
||||||
|
|
||||||
|
This plugin only works for "OneDrive for personal", and not works for "OneDrive for Business" (yet). See [#11](https://github.com/fyears/remotely-save/issues/11) to further details.
|
||||||
|
|
||||||
|
### I cannot find `/Apps/remotely-save` folder
|
||||||
|
|
||||||
|
Mystically some users report that their OneDrive generate `/Application/Graph` instead of `/Apps/remotely-save`. See [#517](https://github.com/remotely-save/remotely-save/issues/517).
|
||||||
|
|
||||||
|
The solution is simple:
|
||||||
|
|
||||||
|
1. Backup your vault manually.
|
||||||
|
2. Go to onedrive website (<https://onedrive.live.com/>), and rename `/Application/Graph` to `/Application/remotely-save` (right click on the folder and you will see rename option)
|
||||||
|
3. Come back to Obsidian and try to sync!
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Backblaze B2
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
https://www.backblaze.com/cloud-storage
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Create a Backblaze account [on this page](https://www.backblaze.com/cloud-storage). Credit card info _is not_ required. Backblaze B2 offers 10 GB of free storage.
|
||||||
|
|
||||||
|
2. Please be aware that, though B2 provides some free quota, **it may still cost you money if the usage of storage or api requests exceed a certain value!!!** Especially pay attention to the api requests!!!
|
||||||
|
|
||||||
|
3. Create a **bucket**, you can leave the default settings, or you can enable the encryption (which is different from what you can set in Remotely Save):
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
4. Copy `Endpoint`, eg. `s3.us-east-005.backblazeb2.com` — it'll be used later.
|
||||||
|
|
||||||
|
5. Copy `bucketname` near the 🪣 icon (the "bucket icon") — it'll be used later.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
6. Go to **Application Keys**:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
7. **Add a new key**:
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
8. Save `keyID` and `applicationKey` — they will be used later.
|
||||||
|
|
||||||
|
9. Go to Remotely Save settings in Obsidian and:
|
||||||
|
|
||||||
|
- Choose `S3 or compatibile` in **Remote Service**:
|
||||||
|
- Copy `Endpoint` from Backblaze (see 3. above) to `Endpoint` in Remotely Save
|
||||||
|
- From `endpoint` take `region` (eg. `us-east-005`) and paste it in `endpoint` in Remotely Save
|
||||||
|
- Copy `keyID` (see 7. above) to `Access Key ID` in Remotely Save
|
||||||
|
- Copy `applicationKey` (see 7. above) to `Secret Access Key` in Remotely Save
|
||||||
|
- Copy `bucketname` (see 4. above) to `Bucket Name` in Remotely Save
|
||||||
|

|
||||||
|
|
||||||
|
10. **Enable Bypass CORS**:
|
||||||
|

|
||||||
|
|
||||||
|
11. Click **Check** in _Check Connectivity_ to see if you can connect to B2 bucket:
|
||||||
|

|
||||||
|
|
||||||
|
12. Sync!
|
||||||
|
|
||||||
|

|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:fae18a84bb218461aca01c7de47ddadfd2b451d865ec889c098cd6918f6e8773
|
||||||
|
size 59679
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9389f9c4462740371edb548f7e05191ede5277a5d470e3254c60fbcc4a75e552
|
||||||
|
size 4558
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:46f35968938ebf3bbafae64ee49a4927844fd0abf79e4190522be91776d9fed0
|
||||||
|
size 107623
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:7fb639c7b4e595516594ee9d07c864e7cd15b608a97c81386f4cf72d499d0e5f
|
||||||
|
size 80121
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:299bfca82b77de86f60d8f481b5f74069dc977dece19fa025ee629d1e6f783d4
|
||||||
|
size 6499
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:5985e918b521c91cc185f60c58cdd334ee76695b897a02e02eaa17c1bad71609
|
||||||
|
size 10214
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:5796791bcf89c99c1e1e844c0083739fd46b1a458dc274f3c3fa3f65b87b71b3
|
||||||
|
size 70728
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:3633379fcb12f1a5e0a4d1d68c193ddc7bf0be7edd92f2f6006351817035a94f
|
||||||
|
size 27164
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9e130be182ae7d3270e5de3fec442ac5e81efd61822e844cd61ed24c7e485d39
|
||||||
|
size 16802
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:29a8971fea065fd86223850198640f4072ee3fda2112820829cda1348f122c59
|
||||||
|
size 11103
|
||||||
@@ -28,19 +28,12 @@ Using the principle of least privilege is crucial for security when allowing a t
|
|||||||
{
|
{
|
||||||
"Version": "2012-10-17",
|
"Version": "2012-10-17",
|
||||||
"Statement": [
|
"Statement": [
|
||||||
{
|
|
||||||
"Sid": "ObsidianBucket",
|
|
||||||
"Effect": "Allow",
|
|
||||||
"Action": [
|
|
||||||
"s3:HeadBucket"
|
|
||||||
],
|
|
||||||
"Resource": "arn:aws:s3:::my-bucket"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"Sid": "ObsidianObjects",
|
"Sid": "ObsidianObjects",
|
||||||
"Effect": "Allow",
|
"Effect": "Allow",
|
||||||
"Action": [
|
"Action": [
|
||||||
"s3:HeadObject",
|
"s3:HeadObject",
|
||||||
|
"s3:ListBucket",
|
||||||
"s3:PutObject",
|
"s3:PutObject",
|
||||||
"s3:CopyObject",
|
"s3:CopyObject",
|
||||||
"s3:UploadPart",
|
"s3:UploadPart",
|
||||||
@@ -56,7 +49,10 @@ Using the principle of least privilege is crucial for security when allowing a t
|
|||||||
"s3:DeleteObject",
|
"s3:DeleteObject",
|
||||||
"s3:DeleteObjects"
|
"s3:DeleteObjects"
|
||||||
],
|
],
|
||||||
"Resource": "arn:aws:s3:::my-bucket/*"
|
"Resource": [
|
||||||
|
"arn:aws:s3:::my-bucket",
|
||||||
|
"arn:aws:s3:::my-bucket/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Storj
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
<https://www.storj.io/>
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Register an account. Login.
|
||||||
|
2. Create a bucket.
|
||||||
|
3. Create S3 Credentials in Access Management. Allow all permissions for the bucket. Remember the access key and secret key and the end point. The end point is likely to be [`https://gateway.storjshare.io`](https://docs.storj.io/dcs/api/s3/s3-compatible-gateway).
|
||||||
|

|
||||||
|

|
||||||
|
4. Input your credentials into remotely-save settings. Region [should be `global`](https://docs.storj.io/dcs/api/s3/s3-compatibility).
|
||||||
|

|
||||||
|
5. Check connectivity.
|
||||||
|
6. Sync!
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:a3484f74f45fbdae6110de285edeff10c196c13160cb97f079da77843ae92e80
|
||||||
|
size 386188
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:42593d98e1b154f4174a932088e72c25db9a976b5613f4d6ae21df1445b4559c
|
||||||
|
size 278219
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:7843c31153054eb4762dbaf3027c1042b0d7cb4dd774bf584788cd7a97ba4044
|
||||||
|
size 571188
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Tencent Cloud COS
|
||||||
|
|
||||||
|
English | [中文](./README.zh-cn.md)
|
||||||
|
|
||||||
|
## Link
|
||||||
|
|
||||||
|
- international <https://console.tencentcloud.com/cos>
|
||||||
|
|
||||||
|
## 步骤 Steps
|
||||||
|
|
||||||
|
The example shows the steps of China version. International version should be similar.
|
||||||
|
|
||||||
|
1. Create a bucket with **private read-write permissions** and recommendly enable server-side-encryption.
|
||||||
|
2. In bucket list page, enter the bucket overview page of the bucket you just created. You should see the bucket name (your texts with the number of your account id), region, and access address.
|
||||||
|

|
||||||
|
3. In CAM page, create api key, and note down the SecretID and SecretKey.
|
||||||
|

|
||||||
|
4. **Remove the bucket name from your access address to obtain your endpoint address! If your access address on the website is `https://<bucket-name-with-number>.cos.<region>.myqcloud.com`, then the endpoint address you are going to use is `https://cos.<region>.myqcloud.com`.**
|
||||||
|
5. In remotely-save settings page, enter your endpoint adress, SecretID, SecretKey,and bucket name.
|
||||||
|

|
||||||
|
6. Check Connectivity.
|
||||||
|

|
||||||
|
7. Sync!
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 腾讯云 COS
|
||||||
|
|
||||||
|
[English](./README.md) | 中文
|
||||||
|
|
||||||
|
## 链接
|
||||||
|
|
||||||
|
- 中国区 <https://console.cloud.tencent.com/cos>
|
||||||
|
|
||||||
|
## 步骤
|
||||||
|
|
||||||
|
注意这里用中国区示例,国际区配置应该类似。
|
||||||
|
|
||||||
|
1. 在“存储桶列表”页,[“创建存储桶”](https://console.cloud.tencent.com/cos/bucket?action=create)。注意创建**私有读写**,建议打开服务端加密。
|
||||||
|
2. 在桶列表页,点击刚刚存储的桶,进入概览页。可以见到桶名称(一般来说是之前指定的英文加账号数字),地域,访问域名。记录下来。
|
||||||
|

|
||||||
|
3. 在[“访问管理页”](https://console.cloud.tencent.com/cam/capi) ,“API 密钥管理”,“创建密钥”,要记录 SecretID 和 SecretKey。
|
||||||
|

|
||||||
|
4. **把桶名称从访问域名移除,才是你即将输入的服务地址!假如你在腾讯云网站看到访问域名是 `https://<bucket-name-with-number>.cos.<region>.myqcloud.com`,那么“服务地址”是 `https://cos.<region>.myqcloud.com`.**
|
||||||
|
5. 在 remotely-save 设置,输入服务地址,SecretID,SecretKey,和 桶名称。
|
||||||
|

|
||||||
|
6. 检查连接。
|
||||||
|

|
||||||
|
7. 可以同步了!
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:08b47288686c8ef8c6426776f7df52411efe431966e8f7f2f9a3a8836ee4d6c6
|
||||||
|
size 323127
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:94c0ec5db7a75c54c36224f067659682e271aea2e61b1ee50d046cd493ba72ac
|
||||||
|
size 58410
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:f51f542756def80a77404e47f9793d1cd6917df5ab6e09d5a0ee1f7191855dc6
|
||||||
|
size 425021
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:55064233031b44066fb7b77e17ba1eb1d0ae8398235a343cc7e137b07aaf7a39
|
||||||
|
size 668954
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 又拍云
|
||||||
|
|
||||||
|
## 链接
|
||||||
|
|
||||||
|
* 官网 <https://www.upyun.com/>
|
||||||
|
* 官网的 S3 文档 <https://help.upyun.com/knowledge-base/aws-s3%e5%85%bc%e5%ae%b9/>
|
||||||
|
|
||||||
|
## 步骤
|
||||||
|
|
||||||
|
1. 注册,新建对象存储。
|
||||||
|
2. 参考官网文档 <https://help.upyun.com/knowledge-base/aws-s3%e5%85%bc%e5%ae%b9/>,创建操作员然后创建 S3 访问凭证。
|
||||||
|
3. 在 Remotely Save 设置以下:
|
||||||
|
* 服务地址(Endpoint):`s3.api.upyun.com` **一定是这个域名**
|
||||||
|
* 区域(Region):`us-east-1`
|
||||||
|
* Acccess Key ID:您获取到的访问凭证的 AccessKey
|
||||||
|
* Secret Access Key:您获取到的访问凭证的 SecretAccessKey
|
||||||
|
* 存储桶(Bucket)的名字:您创建的“服务名”
|
||||||
|
* 是否生成文件夹 Object:不生成(默认) **一定要选择不生成**
|
||||||
|
4. 同步。
|
||||||
@@ -1,22 +1,23 @@
|
|||||||
# AList
|
# AList
|
||||||
|
|
||||||
## 链接 Links
|
English | [中文](./README.zh-cn.md)
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
- English official website: <https://alist.nn.ci/> and <https://alist.nn.ci/guide/webdav.html>
|
- English official website: <https://alist.nn.ci/> and <https://alist.nn.ci/guide/webdav.html>
|
||||||
- 中文官网:<https://alist.nn.ci/zh/> 和 <https://alist.nn.ci/zh/guide/webdav.html>
|
|
||||||
|
|
||||||
## 步骤 Steps
|
## Steps
|
||||||
|
|
||||||
1. 安装和使用 AList。获取账号名和密码。在网页上登录。Install and run AList. Get the account and password. Login using the web page.
|
1. Install and run AList. Get the account and password. Login using the web page.
|
||||||
2. 新建挂载,检查挂载路径。如图所示是 `/alisttest davpath`。Add new storage. Pay attention to the mount path. The screenshot shows the mount path as `/alisttest davpath`.
|
2. Add new storage. Pay attention to the mount path. The screenshot shows the mount path as `/alisttest davpath`.
|
||||||

|

|
||||||

|

|
||||||
3. 从而构建 webdav 网址如下,**http(s)://域名** + **端口** + **`/dav`** + **挂载路径**,其中挂载路径中假如有空格,换成 `%20`:Construct the webdav address as: **http(s)://domain** + **port** + **`/dav`** + **mount path**, and the space inside the mount path should be replaced with `%20`:
|
3. Construct the webdav address as: **http(s)://domain** + **port** + **`/dav`** + **mount path**, and the space inside the mount path should be replaced with `%20`:
|
||||||
```
|
```
|
||||||
http[s]://domain:port/dav/[mountpath url encoded]
|
http[s]://domain:port/dav/[mountpath url encoded]
|
||||||
http://127.0.0.1:5244/dav/alisttest%20davpath
|
http://127.0.0.1:5244/dav/alisttest%20davpath
|
||||||
```
|
```
|
||||||
4. 在 remotely-save 设置,输入**带域名端口`/dav`和挂载路径的网址**、账号、密码。In remotely-save setting page, select webdav type, then input the **full address with mount path**/account/password.
|
4. In remotely-save setting page, select webdav type, then input the **full address with mount path**/account/password.
|
||||||

|

|
||||||
5. 在 remotely-save 设置,检查连接。In remotely-save setting page, click "Check Connectivity".
|
5. In remotely-save setting page, click "Check Connectivity".
|
||||||
6. 同步文件!Sync!
|
6. Sync!
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# AList
|
||||||
|
|
||||||
|
[English](./README.md) | 中文
|
||||||
|
|
||||||
|
## 链接
|
||||||
|
|
||||||
|
- 中文官网:<https://alist.nn.ci/zh/> 和 <https://alist.nn.ci/zh/guide/webdav.html>
|
||||||
|
|
||||||
|
## 步骤
|
||||||
|
|
||||||
|
1. 安装和使用 AList。获取账号名和密码。在网页上登录。
|
||||||
|
2. 新建挂载,检查挂载路径。如图所示是 `/alisttest davpath`。
|
||||||
|

|
||||||
|

|
||||||
|
3. 从而构建 webdav 网址如下,**http(s)://域名** + **端口** + **`/dav`** + **挂载路径**,其中挂载路径中假如有空格,换成 `%20`:
|
||||||
|
```
|
||||||
|
http[s]://domain:port/dav/[mountpath url encoded]
|
||||||
|
http://127.0.0.1:5244/dav/alisttest%20davpath
|
||||||
|
```
|
||||||
|
4. 在 remotely-save 设置,输入**带域名端口`/dav`和挂载路径的网址**、账号、密码。
|
||||||
|

|
||||||
|
5. 在 remotely-save 设置,检查连接。
|
||||||
|
6. 同步文件!
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
# 坚果云 JianGuoYun/NutStore
|
# JianGuoYun/NutStore
|
||||||
|
|
||||||
## 链接 Link
|
English | [中文](./README.zh-cn.md)
|
||||||
|
|
||||||
|
## Link
|
||||||
|
|
||||||
<https://www.jianguoyun.com/>
|
<https://www.jianguoyun.com/>
|
||||||
|
|
||||||
## 注意!!!Attentions!!!
|
## Attentions!!!
|
||||||
|
|
||||||
坚果云有限制 api 数量等设定。本插件会产生若干查询,如果文件较多很容易触发 api 上限,从而工作不正常。这不是插件 bug,也没有办法解决。
|
|
||||||
|
|
||||||
JianGuoYun/NutStore has api limits. The plugin may generate many queries, and it's possible to reach the api limits if there are many files, then do not work properly. It's not a bug and there's no way to fix this situation.
|
JianGuoYun/NutStore has api limits. The plugin may generate many queries, and it's possible to reach the api limits if there are many files, then do not work properly. It's not a bug and there's no way to fix this situation.
|
||||||
|
|
||||||
## 步骤 Steps
|
## Steps
|
||||||
|
|
||||||
1. **知悉坚果云有 api 限制,本插件可能因此工作不正常。Be aware that JianGuoYun/NutStore has api limits, and the plugin may not work properly because of this.**
|
1. **Be aware that JianGuoYun/NutStore has api limits, and the plugin may not work properly because of this.**
|
||||||
2. 注册账号,登录。Register an account.
|
2. Register an account.
|
||||||
3. 去“个人信息”->“安全”,“添加应用”,从而获取了 webDAV 账号(应该是 email)和 WebDAV 密码(一串特殊的字符,不等于网站密码)。Go to "settings"->"Security", click "Add Application", then obtain the WebDAV account (email), and WebDAV password (a string different from web site password).
|
3. Go to "settings"->"Security", click "Add Application", then obtain the WebDAV account (email), and WebDAV password (a string different from web site password).
|
||||||

|

|
||||||
4. 在 remotely-save 设置,输入网址、账号、密码、**“发送到服务器的 Depth Header”设置为“只支持 depth='1'”**。Input the WebDAV address, account, password, **Depth Header Sent To Servers="only supports depth='1'"** in remotely-save settings.
|
4. Input the WebDAV address, account, password, **Depth Header Sent To Servers="only supports depth='1'"** in remotely-save settings.
|
||||||

|

|
||||||
5. 在 remotely-save 设置,检查连接。In remotely-save setting page, click "Check Connectivity".
|
5. In remotely-save setting page, click "Check Connectivity".
|
||||||
6. 同步文件!Sync!
|
6. Sync!
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# 坚果云
|
||||||
|
|
||||||
|
[English](./README.md) | 中文
|
||||||
|
|
||||||
|
## 链接
|
||||||
|
|
||||||
|
<https://www.jianguoyun.com/>
|
||||||
|
|
||||||
|
## 注意!!!
|
||||||
|
|
||||||
|
坚果云有限制 api 数量等设定。本插件会产生若干查询,如果文件较多很容易触发 api 上限,从而工作不正常。这不是插件 bug,也没有办法解决。
|
||||||
|
|
||||||
|
## 步骤
|
||||||
|
|
||||||
|
1. **知悉坚果云有 api 限制,本插件可能因此工作不正常。**
|
||||||
|
2. 注册账号,登录。
|
||||||
|
3. 去“个人信息”->“安全”,“添加应用”,从而获取了 webDAV 账号(应该是 email)和 WebDAV 密码(一串特殊的字符,不等于网站密码)。
|
||||||
|

|
||||||
|
4. 在 remotely-save 设置,输入网址、账号、密码、**“发送到服务器的 Depth Header”设置为“只支持 depth='1'”**。
|
||||||
|

|
||||||
|
5. 在 remotely-save 设置,检查连接。
|
||||||
|
6. 同步文件!
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Synology Webdav Server
|
||||||
|
|
||||||
|
English | [中文](./README.zh-cn.md)
|
||||||
|
|
||||||
|
## Link
|
||||||
|
|
||||||
|
<https://kb.synology.com/en-global/DSM/tutorial/How_to_access_files_on_Synology_NAS_with_WebDAV>
|
||||||
|
|
||||||
|
## Attention
|
||||||
|
|
||||||
|
The tutorial author (the author of Remotely Save) is NOT an expert of NAS / Synology. Please read the doc carefully and change it to accommodate your needs by yourself.
|
||||||
|
|
||||||
|
**It's dangerous to expose your NAS into public Internet if you don't know how to set up firewalls and other protections.**
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
Synology DSM 7 is used in this tutorial.
|
||||||
|
|
||||||
|
1. Create a new shared folder if you don't have one. For this tutorial, a new shared folder `share2` is created. You should assing a proper user to read / write the shared folder.
|
||||||
|

|
||||||
|
|
||||||
|
2. Assuming you want to sync your vault into sub folder `哈哈哈/sub folder`, please create the sub folder(s) correspondingly inide the shared folder `share2`.
|
||||||
|
|
||||||
|
3. Install webdav server from package center.
|
||||||
|

|
||||||
|
|
||||||
|
4. Enter the webdav server settings.
|
||||||
|
|
||||||
|
5. If you know how to configure https certificates correctly, you are strongly recommend to enable https.
|
||||||
|
|
||||||
|
For the demonstration purpose, this tutorial enable http server for the later steps.
|
||||||
|
|
||||||
|
Also "Enable DavDepthInfinity", which could speed up the plugin runnings greatly.
|
||||||
|
|
||||||
|
"Apply".
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
6. In Remotely Save settings, you should input your address as:
|
||||||
|
|
||||||
|
`http(s)://<your synology ip or domain>:<port>/<shared folder>/<sub folders>`
|
||||||
|
|
||||||
|
For example, in the tutorial, the proper url should be:
|
||||||
|
|
||||||
|
`http://<ip>:5000/share2/哈哈哈/sub folder`
|
||||||
|
|
||||||
|
Username and password should be the user you configured before with read / write permissions to `share2`.
|
||||||
|
|
||||||
|
Depth header should be "supports depth="infinity"".
|
||||||
|
|
||||||
|
Check connectivity!
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
7. Sync!
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# 群晖 Webdav Server
|
||||||
|
|
||||||
|
[English](./README.md) | 中文
|
||||||
|
|
||||||
|
## 链接
|
||||||
|
|
||||||
|
<https://kb.synology.cn/zh-cn/DSM/tutorial/How_to_access_files_on_Synology_NAS_with_WebDAV>
|
||||||
|
|
||||||
|
## 注意
|
||||||
|
|
||||||
|
教程作者(Remotely Save 作者)**不是** NAS、群晖专家。请仔细阅读文档,并自行改动以适应您自身需求。
|
||||||
|
|
||||||
|
**没有设置防火墙和其他保护措施的话,将 NAS 暴露到公网上非常危险。**
|
||||||
|
|
||||||
|
## 步骤
|
||||||
|
|
||||||
|
本教程有用到群晖 DSM 7。
|
||||||
|
|
||||||
|
1. 创建共享文件夹。本教程示例创建了 `share2`。你需要允许某个账号对此的读写权限。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
2. 假设之后你想同步你的库到子文件夹,`哈哈哈/sub folder`,请先在共享文件夹 `share2` 底下创建好。
|
||||||
|
|
||||||
|
3. 从套件中心安装 webdav server 。
|
||||||
|

|
||||||
|
|
||||||
|
4. 进入 webdav server 设置。
|
||||||
|
|
||||||
|
5. 如果你知道如何正确配置 https 证书的话,强烈建议开启 https。
|
||||||
|
|
||||||
|
本教程简化示例,开启了 http。
|
||||||
|
|
||||||
|
也设置“Enable DavDepthInfinity”,这可以加速插件连接速度。
|
||||||
|
|
||||||
|
“Apply”。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
6. 在 Remotely Save 设置页,你的地址应如下格式输入:
|
||||||
|
|
||||||
|
`http(s)://<your synology ip or domain>:<port>/<shared folder>/<sub folders>`
|
||||||
|
|
||||||
|
比如说,本教程里,正确的地址类似于:
|
||||||
|
|
||||||
|
`http://<ip>:5000/share2/哈哈哈/sub folder`
|
||||||
|
|
||||||
|
用户名和密码是你之前配置了允许读写 `share2` 的那个账号。
|
||||||
|
|
||||||
|
Depth 设置应为“supports depth="infinity"”。
|
||||||
|
|
||||||
|
检查连接!
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
7. 同步!
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:71e61c25db793c00d4406ea0f46745941e16e268a41b7328b03a183ab0706ec8
|
||||||
|
size 59700
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0f0725099989b5496048c0e3cb284b4f6537d66866b4618c8e73ee758cb2b642
|
||||||
|
size 174986
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:7bc6d03a9deb0e8babd2ed102e7b85ee1062797abf1146ae71f80237a852ff10
|
||||||
|
size 522084
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:ba3781f24b5d65ee6915d496f7542c569116141cdced24ca505f4373d9efa9df
|
||||||
|
size 536163
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Sync Algorithm
|
||||||
|
|
||||||
|
- [v1](./v1/README.md)
|
||||||
|
- [v2](./v2/README.md)
|
||||||
|
- v3
|
||||||
|
- [intro doc for end users](./v3/intro.md)
|
||||||
|
- [design doc](./v3/design.md)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Sync Algorithm V3
|
||||||
|
|
||||||
|
- [intro doc for end users](./intro.md)
|
||||||
|
- [design doc](./design.md)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Sync Algorithm V3
|
||||||
|
|
||||||
|
Drafted on 20240117.
|
||||||
|
|
||||||
|
An absolutely better sync algorithm. Better for tracking deletions and better for subbranching.
|
||||||
|
|
||||||
|
## Huge Thanks
|
||||||
|
|
||||||
|
Basically a combination of algorithm v2 + [synclone](https://github.com/Jwink3101/syncrclone/blob/master/docs/algorithm.md) + [rsinc](https://github.com/ConorWilliams/rsinc) + (some of rclone [bisync](https://rclone.org/bisync/)). All of the later three are released under MIT License so no worries about the licenses.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
Must have
|
||||||
|
|
||||||
|
1. true deletion detection
|
||||||
|
2. deletion protection (blocking) with a setting
|
||||||
|
3. transaction from the old algorithm
|
||||||
|
4. user warning show up, **new algorithm needs all clients to be updated!** (deliberately corrput the metadata file??)
|
||||||
|
5. filters
|
||||||
|
6. conflict warning
|
||||||
|
7. partial sync
|
||||||
|
|
||||||
|
Nice to have
|
||||||
|
|
||||||
|
1. true time and hash
|
||||||
|
2. conflict rename
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
We have _five_ input sources:
|
||||||
|
|
||||||
|
1. local all files
|
||||||
|
2. remote all files
|
||||||
|
3. _local previous succeeded sync history_
|
||||||
|
4. local deletions
|
||||||
|
5. remote deletions.
|
||||||
|
|
||||||
|
Init run, consuming remote deletions :
|
||||||
|
|
||||||
|
Change history data into _local previous succeeded sync history_.
|
||||||
|
|
||||||
|
Later runs, use the first, second, third sources **only**.
|
||||||
|
|
||||||
|
Bidirectional table is modified based on synclone and rsinc. Incremental push / pull only tables is further modified based on the bidirectional table. The number inside the table cell is the decision branch in the code.
|
||||||
|
|
||||||
|
Bidirectional:
|
||||||
|
|
||||||
|
| local\remote | remote unchanged | remote modified | remote deleted | remote created |
|
||||||
|
| --------------- | ------------------ | ------------------------- | ------------------ | ------------------------- |
|
||||||
|
| local unchanged | (02/21) do nothing | (09) pull | (07) delete local | (??) conflict |
|
||||||
|
| local modified | (10) push | (16/17/18/19/20) conflict | (08) push | (??) conflict |
|
||||||
|
| local deleted | (04) delete remote | (05) pull | (01) clean history | (03) pull |
|
||||||
|
| local created | (??) conflict | (??) conflict | (06) push | (11/12/13/14/15) conflict |
|
||||||
|
|
||||||
|
Incremental push only:
|
||||||
|
|
||||||
|
| local\remote | remote unchanged | remote modified | remote deleted | remote created |
|
||||||
|
| --------------- | ---------------------------- | ---------------------------- | ---------------------- | ---------------------------- |
|
||||||
|
| local unchanged | (02/21) do nothing | **(26) conflict push** | **(32) conflict push** | (??) conflict |
|
||||||
|
| local modified | (10) push | **(25) conflict push** | (08) push | (??) conflict |
|
||||||
|
| local deleted | **(29) conflict do nothing** | **(30) conflict do nothing** | (01) clean history | **(28) conflict do nothing** |
|
||||||
|
| local created | (??) conflict | (??) conflict | (06) push | **(23) conflict push** |
|
||||||
|
|
||||||
|
Incremental pull only:
|
||||||
|
|
||||||
|
| local\remote | remote unchanged | remote modified | remote deleted | remote created |
|
||||||
|
| --------------- | ---------------------- | ---------------------- | ---------------------------- | ---------------------- |
|
||||||
|
| local unchanged | (02/21) do nothing | (09) pull | **(33) conflict do nothing** | (??) conflict |
|
||||||
|
| local modified | **(27) conflict pull** | **(24) conflict pull** | **(34) conflict do nothing** | (??) conflict |
|
||||||
|
| local deleted | **(35) conflict pull** | (05) pull | (01) clean history | (03) pull |
|
||||||
|
| local created | (??) conflict | (??) conflict | **(31) conflict do nothing** | **(22) conflict pull** |
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Introduction To Sync Algorithm V3
|
||||||
|
|
||||||
|
- [x] sync conflict: keep newer
|
||||||
|
- [x] sync conflict: keep larger
|
||||||
|
- [ ] sync conflict: keep both and rename
|
||||||
|
- [ ] sync conflict: show warning
|
||||||
|
- [x] deletion: true deletion status computation
|
||||||
|
- [x] meta data: no remote meta data any more
|
||||||
|
- [x] migration: old data auto transfer to new db (hopefully)
|
||||||
|
- [x] sync direction: incremental push only
|
||||||
|
- [x] sync direction: incremental pull only
|
||||||
|
- [x] sync protection: warning based on the threshold
|
||||||
|
- [ ] partial sync: better sync on save
|
||||||
|
- [x] encrpytion: new encryption method, see [this](../../encryption/)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import dotenv from "dotenv/config";
|
import dotenv from "dotenv/config";
|
||||||
import esbuild from "esbuild";
|
import esbuild from "esbuild";
|
||||||
import process from "process";
|
import process from "process";
|
||||||
|
import inlineWorkerPlugin from "esbuild-plugin-inline-worker";
|
||||||
// import builtins from 'builtin-modules'
|
// import builtins from 'builtin-modules'
|
||||||
|
|
||||||
const banner = `/*
|
const banner = `/*
|
||||||
@@ -35,6 +36,7 @@ esbuild
|
|||||||
"net",
|
"net",
|
||||||
"http",
|
"http",
|
||||||
"https",
|
"https",
|
||||||
|
"vm",
|
||||||
// ...builtins
|
// ...builtins
|
||||||
],
|
],
|
||||||
inject: ["./esbuild.injecthelper.mjs"],
|
inject: ["./esbuild.injecthelper.mjs"],
|
||||||
@@ -54,6 +56,7 @@ esbuild
|
|||||||
"process.env.NODE_DEBUG": `undefined`, // ugly fix
|
"process.env.NODE_DEBUG": `undefined`, // ugly fix
|
||||||
"process.env.DEBUG": `undefined`, // ugly fix
|
"process.env.DEBUG": `undefined`, // ugly fix
|
||||||
},
|
},
|
||||||
|
plugins: [inlineWorkerPlugin()],
|
||||||
})
|
})
|
||||||
.then((context) => {
|
.then((context) => {
|
||||||
if (process.argv.includes("--watch")) {
|
if (process.argv.includes("--watch")) {
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "remotely-save",
|
"id": "remotely-save",
|
||||||
"name": "Remotely Save",
|
"name": "Remotely Save",
|
||||||
"version": "0.3.34",
|
"version": "0.4.16",
|
||||||
"minAppVersion": "0.13.21",
|
"minAppVersion": "0.13.21",
|
||||||
"description": "Yet another unofficial plugin allowing users to synchronize notes between local device and the cloud service.",
|
"description": "Yet another unofficial plugin allowing users to synchronize notes between local device and the cloud service.",
|
||||||
"author": "fyears",
|
"author": "fyears",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "remotely-save",
|
"id": "remotely-save",
|
||||||
"name": "Remotely Save",
|
"name": "Remotely Save",
|
||||||
"version": "0.3.34",
|
"version": "0.4.16",
|
||||||
"minAppVersion": "0.13.21",
|
"minAppVersion": "0.13.21",
|
||||||
"description": "Yet another unofficial plugin allowing users to synchronize notes between local device and the cloud service.",
|
"description": "Yet another unofficial plugin allowing users to synchronize notes between local device and the cloud service.",
|
||||||
"author": "fyears",
|
"author": "fyears",
|
||||||
|
|||||||
+34
-33
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "remotely-save",
|
"name": "remotely-save",
|
||||||
"version": "0.3.25",
|
"version": "0.4.16",
|
||||||
"description": "This is yet another sync plugin for Obsidian app.",
|
"description": "This is yet another sync plugin for Obsidian app.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev2": "node esbuild.config.mjs --watch",
|
"dev2": "node esbuild.config.mjs --watch",
|
||||||
@@ -16,7 +16,9 @@
|
|||||||
"process": "process/browser",
|
"process": "process/browser",
|
||||||
"stream": "stream-browserify",
|
"stream": "stream-browserify",
|
||||||
"crypto": "crypto-browserify",
|
"crypto": "crypto-browserify",
|
||||||
"url": "url/"
|
"url": "url/",
|
||||||
|
"fs": false,
|
||||||
|
"vm": false
|
||||||
},
|
},
|
||||||
"source": "main.ts",
|
"source": "main.ts",
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
@@ -24,62 +26,62 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@microsoft/microsoft-graph-types": "^2.40.0",
|
"@microsoft/microsoft-graph-types": "^2.40.0",
|
||||||
"@types/chai": "^4.3.11",
|
"@types/chai": "^4.3.14",
|
||||||
"@types/chai-as-promised": "^7.1.8",
|
"@types/chai-as-promised": "^7.1.8",
|
||||||
"@types/jsdom": "^21.1.6",
|
"@types/jsdom": "^21.1.6",
|
||||||
"@types/lodash": "^4.14.202",
|
"@types/lodash": "^4.17.0",
|
||||||
"@types/mime-types": "^2.1.4",
|
"@types/mime-types": "^2.1.4",
|
||||||
"@types/mocha": "^10.0.6",
|
"@types/mocha": "^10.0.6",
|
||||||
"@types/mustache": "^4.2.5",
|
"@types/mustache": "^4.2.5",
|
||||||
"@types/node": "^20.10.4",
|
"@types/node": "^20.12.7",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"builtin-modules": "^3.3.0",
|
"builtin-modules": "^3.3.0",
|
||||||
"chai": "^4.3.10",
|
|
||||||
"chai-as-promised": "^7.1.1",
|
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.4.5",
|
||||||
"esbuild": "^0.19.9",
|
"esbuild": "^0.20.2",
|
||||||
"jsdom": "^23.0.1",
|
"esbuild-plugin-inline-worker": "^0.1.1",
|
||||||
"mocha": "^10.2.0",
|
"jsdom": "^24.0.0",
|
||||||
"npm-check-updates": "^16.14.12",
|
"mocha": "^10.4.0",
|
||||||
"obsidian": "^1.4.11",
|
"npm-check-updates": "^16.14.20",
|
||||||
"prettier": "^3.1.1",
|
"obsidian": "^1.5.7",
|
||||||
|
"prettier": "^3.2.5",
|
||||||
"ts-loader": "^9.5.1",
|
"ts-loader": "^9.5.1",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.6.2",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.4.5",
|
||||||
"webdav-server": "^2.6.2",
|
"webdav-server": "^2.6.2",
|
||||||
"webpack": "^5.89.0",
|
"webpack": "^5.91.0",
|
||||||
"webpack-cli": "^5.1.4"
|
"webpack-cli": "^5.1.4",
|
||||||
|
"worker-loader": "^3.0.8"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.474.0",
|
"@aws-sdk/client-s3": "^3.563.0",
|
||||||
"@aws-sdk/lib-storage": "^3.474.0",
|
"@aws-sdk/lib-storage": "^3.563.0",
|
||||||
"@aws-sdk/signature-v4-crt": "^3.474.0",
|
"@aws-sdk/signature-v4-crt": "^3.556.0",
|
||||||
"@aws-sdk/types": "^3.468.0",
|
"@aws-sdk/types": "^3.535.0",
|
||||||
"@azure/msal-node": "^2.6.0",
|
"@azure/msal-node": "^2.7.0",
|
||||||
|
"@fyears/rclone-crypt": "^0.0.7",
|
||||||
"@fyears/tsqueue": "^1.0.1",
|
"@fyears/tsqueue": "^1.0.1",
|
||||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||||
"@smithy/fetch-http-handler": "^2.3.1",
|
"@smithy/fetch-http-handler": "^2.5.0",
|
||||||
"@smithy/protocol-http": "^3.0.11",
|
"@smithy/protocol-http": "^3.3.0",
|
||||||
"@smithy/querystring-builder": "^2.0.15",
|
"@smithy/querystring-builder": "^2.2.0",
|
||||||
"acorn": "^8.11.2",
|
"acorn": "^8.11.3",
|
||||||
"aggregate-error": "^5.0.0",
|
"aggregate-error": "^5.0.0",
|
||||||
"assert": "^2.1.0",
|
"assert": "^2.1.0",
|
||||||
"aws-crt": "^1.20.0",
|
"aws-crt": "^1.21.2",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"crypto-browserify": "^3.12.0",
|
"crypto-browserify": "^3.12.0",
|
||||||
"delay": "^6.0.0",
|
|
||||||
"dropbox": "^10.34.0",
|
"dropbox": "^10.34.0",
|
||||||
"emoji-regex": "^10.3.0",
|
"emoji-regex": "^10.3.0",
|
||||||
"http-status-codes": "^2.3.0",
|
"http-status-codes": "^2.3.0",
|
||||||
"localforage": "^1.10.0",
|
"localforage": "^1.10.0",
|
||||||
|
"localforage-getitems": "^1.4.2",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"loglevel": "^1.8.1",
|
"lucide": "^0.376.1",
|
||||||
"lucide": "^0.298.0",
|
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
"nanoid": "^5.0.4",
|
"nanoid": "^5.0.7",
|
||||||
"p-queue": "^8.0.1",
|
"p-queue": "^8.0.1",
|
||||||
"path-browserify": "^1.0.1",
|
"path-browserify": "^1.0.1",
|
||||||
"process": "^0.11.10",
|
"process": "^0.11.10",
|
||||||
@@ -89,8 +91,7 @@
|
|||||||
"stream-browserify": "^3.0.0",
|
"stream-browserify": "^3.0.0",
|
||||||
"url": "^0.11.3",
|
"url": "^0.11.3",
|
||||||
"util": "^0.12.5",
|
"util": "^0.12.5",
|
||||||
"webdav": "^5.3.1",
|
"webdav": "^5.6.0",
|
||||||
"webdav-fs": "^4.0.1",
|
|
||||||
"xregexp": "^5.1.1"
|
"xregexp": "^5.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+131
-39
@@ -21,10 +21,20 @@ export interface S3Config {
|
|||||||
s3AccessKeyID: string;
|
s3AccessKeyID: string;
|
||||||
s3SecretAccessKey: string;
|
s3SecretAccessKey: string;
|
||||||
s3BucketName: string;
|
s3BucketName: string;
|
||||||
bypassCorsLocally?: boolean;
|
|
||||||
partsConcurrency?: number;
|
partsConcurrency?: number;
|
||||||
forcePathStyle?: boolean;
|
forcePathStyle?: boolean;
|
||||||
remotePrefix?: string;
|
remotePrefix?: string;
|
||||||
|
|
||||||
|
useAccurateMTime?: boolean;
|
||||||
|
reverseProxyNoSignUrl?: string;
|
||||||
|
|
||||||
|
generateFolderObject?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
bypassCorsLocally?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DropboxConfig {
|
export interface DropboxConfig {
|
||||||
@@ -41,9 +51,10 @@ export interface DropboxConfig {
|
|||||||
|
|
||||||
export type WebdavAuthType = "digest" | "basic";
|
export type WebdavAuthType = "digest" | "basic";
|
||||||
export type WebdavDepthType =
|
export type WebdavDepthType =
|
||||||
| "auto_unknown"
|
| "auto" // deprecated on 20240116
|
||||||
| "auto_1"
|
| "auto_unknown" // deprecated on 20240116
|
||||||
| "auto_infinity"
|
| "auto_1" // deprecated on 20240116
|
||||||
|
| "auto_infinity" // deprecated on 20240116
|
||||||
| "manual_1"
|
| "manual_1"
|
||||||
| "manual_infinity";
|
| "manual_infinity";
|
||||||
|
|
||||||
@@ -52,9 +63,14 @@ export interface WebdavConfig {
|
|||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
authType: WebdavAuthType;
|
authType: WebdavAuthType;
|
||||||
manualRecursive: boolean; // deprecated in 0.3.6, use depth
|
|
||||||
depth?: WebdavDepthType;
|
depth?: WebdavDepthType;
|
||||||
remoteBaseDir?: string;
|
remoteBaseDir?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
manualRecursive: boolean; // deprecated in 0.3.6, use depth
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OnedriveConfig {
|
export interface OnedriveConfig {
|
||||||
@@ -70,6 +86,15 @@ export interface OnedriveConfig {
|
|||||||
remoteBaseDir?: string;
|
remoteBaseDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SyncDirectionType =
|
||||||
|
| "bidirectional"
|
||||||
|
| "incremental_pull_only"
|
||||||
|
| "incremental_push_only";
|
||||||
|
|
||||||
|
export type CipherMethodType = "rclone-base64" | "openssl-base64" | "unknown";
|
||||||
|
|
||||||
|
export type QRExportType = "all_but_oauth2" | "dropbox" | "onedrive";
|
||||||
|
|
||||||
export interface RemotelySavePluginSettings {
|
export interface RemotelySavePluginSettings {
|
||||||
s3: S3Config;
|
s3: S3Config;
|
||||||
webdav: WebdavConfig;
|
webdav: WebdavConfig;
|
||||||
@@ -81,29 +106,42 @@ export interface RemotelySavePluginSettings {
|
|||||||
autoRunEveryMilliseconds?: number;
|
autoRunEveryMilliseconds?: number;
|
||||||
initRunAfterMilliseconds?: number;
|
initRunAfterMilliseconds?: number;
|
||||||
syncOnSaveAfterMilliseconds?: number;
|
syncOnSaveAfterMilliseconds?: number;
|
||||||
agreeToUploadExtraMetadata?: boolean;
|
|
||||||
concurrency?: number;
|
concurrency?: number;
|
||||||
syncConfigDir?: boolean;
|
syncConfigDir?: boolean;
|
||||||
syncUnderscoreItems?: boolean;
|
syncUnderscoreItems?: boolean;
|
||||||
lang?: LangTypeAndAuto;
|
lang?: LangTypeAndAuto;
|
||||||
logToDB?: boolean;
|
agreeToUseSyncV3?: boolean;
|
||||||
skipSizeLargerThan?: number;
|
skipSizeLargerThan?: number;
|
||||||
ignorePaths?: string[];
|
ignorePaths?: string[];
|
||||||
enableStatusBarInfo?: boolean;
|
enableStatusBarInfo?: boolean;
|
||||||
deleteToWhere?: "system" | "obsidian";
|
deleteToWhere?: "system" | "obsidian";
|
||||||
|
conflictAction?: ConflictActionType;
|
||||||
|
howToCleanEmptyFolder?: EmptyFolderCleanType;
|
||||||
|
|
||||||
|
protectModifyPercentage?: number;
|
||||||
|
syncDirection?: SyncDirectionType;
|
||||||
|
|
||||||
|
obfuscateSettingFile?: boolean;
|
||||||
|
|
||||||
|
enableMobileStatusBar?: boolean;
|
||||||
|
|
||||||
|
encryptionMethod?: CipherMethodType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
agreeToUploadExtraMetadata?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @deprecated
|
* @deprecated
|
||||||
*/
|
*/
|
||||||
vaultRandomID?: string;
|
vaultRandomID?: string;
|
||||||
}
|
|
||||||
|
|
||||||
export interface RemoteItem {
|
/**
|
||||||
key: string;
|
* @deprecated
|
||||||
lastModified: number;
|
*/
|
||||||
size: number;
|
logToDB?: boolean;
|
||||||
remoteType: SUPPORTED_SERVICES_TYPE;
|
|
||||||
etag?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const COMMAND_URI = "remotely-save";
|
export const COMMAND_URI = "remotely-save";
|
||||||
@@ -121,32 +159,83 @@ export interface UriParams {
|
|||||||
// 80 days
|
// 80 days
|
||||||
export const OAUTH2_FORCE_EXPIRE_MILLISECONDS = 1000 * 60 * 60 * 24 * 80;
|
export const OAUTH2_FORCE_EXPIRE_MILLISECONDS = 1000 * 60 * 60 * 24 * 80;
|
||||||
|
|
||||||
type DecisionTypeForFile =
|
export type EmptyFolderCleanType = "skip" | "clean_both";
|
||||||
| "skipUploading" // special, mtimeLocal === mtimeRemote
|
|
||||||
| "uploadLocalDelHistToRemote" // "delLocalIfExists && delRemoteIfExists && cleanLocalDelHist && uploadLocalDelHistToRemote"
|
|
||||||
| "keepRemoteDelHist" // "delLocalIfExists && delRemoteIfExists && cleanLocalDelHist && keepRemoteDelHist"
|
|
||||||
| "uploadLocalToRemote" // "skipLocal && uploadLocalToRemote && cleanLocalDelHist && cleanRemoteDelHist"
|
|
||||||
| "downloadRemoteToLocal"; // "downloadRemoteToLocal && skipRemote && cleanLocalDelHist && cleanRemoteDelHist"
|
|
||||||
|
|
||||||
type DecisionTypeForFileSize =
|
export type ConflictActionType = "keep_newer" | "keep_larger" | "rename_both";
|
||||||
| "skipUploadingTooLarge"
|
|
||||||
| "skipDownloadingTooLarge"
|
|
||||||
| "skipUsingLocalDelTooLarge"
|
|
||||||
| "skipUsingRemoteDelTooLarge"
|
|
||||||
| "errorLocalTooLargeConflictRemote"
|
|
||||||
| "errorRemoteTooLargeConflictLocal";
|
|
||||||
|
|
||||||
type DecisionTypeForFolder =
|
export type DecisionTypeForMixedEntity =
|
||||||
| "createFolder"
|
| "only_history"
|
||||||
| "uploadLocalDelHistToRemoteFolder"
|
| "equal"
|
||||||
| "keepRemoteDelHistFolder"
|
| "local_is_modified_then_push"
|
||||||
| "skipFolder";
|
| "remote_is_modified_then_pull"
|
||||||
|
| "local_is_created_then_push"
|
||||||
|
| "remote_is_created_then_pull"
|
||||||
|
| "local_is_created_too_large_then_do_nothing"
|
||||||
|
| "remote_is_created_too_large_then_do_nothing"
|
||||||
|
| "local_is_deleted_thus_also_delete_remote"
|
||||||
|
| "remote_is_deleted_thus_also_delete_local"
|
||||||
|
| "conflict_created_then_keep_local"
|
||||||
|
| "conflict_created_then_keep_remote"
|
||||||
|
| "conflict_created_then_keep_both"
|
||||||
|
| "conflict_created_then_do_nothing"
|
||||||
|
| "conflict_modified_then_keep_local"
|
||||||
|
| "conflict_modified_then_keep_remote"
|
||||||
|
| "conflict_modified_then_keep_both"
|
||||||
|
| "folder_existed_both_then_do_nothing"
|
||||||
|
| "folder_existed_local_then_also_create_remote"
|
||||||
|
| "folder_existed_remote_then_also_create_local"
|
||||||
|
| "folder_to_be_created"
|
||||||
|
| "folder_to_skip"
|
||||||
|
| "folder_to_be_deleted_on_both"
|
||||||
|
| "folder_to_be_deleted_on_remote"
|
||||||
|
| "folder_to_be_deleted_on_local";
|
||||||
|
|
||||||
export type DecisionType =
|
/**
|
||||||
| DecisionTypeForFile
|
* uniform representation
|
||||||
| DecisionTypeForFileSize
|
* everything should be flat and primitive, so that we can copy.
|
||||||
| DecisionTypeForFolder;
|
*/
|
||||||
|
export interface Entity {
|
||||||
|
key?: string;
|
||||||
|
keyEnc?: string;
|
||||||
|
keyRaw: string;
|
||||||
|
mtimeCli?: number;
|
||||||
|
mtimeCliFmt?: string;
|
||||||
|
mtimeSvr?: number;
|
||||||
|
mtimeSvrFmt?: string;
|
||||||
|
prevSyncTime?: number;
|
||||||
|
prevSyncTimeFmt?: string;
|
||||||
|
size?: number; // might be unknown or to be filled
|
||||||
|
sizeEnc?: number;
|
||||||
|
sizeRaw: number;
|
||||||
|
hash?: string;
|
||||||
|
etag?: string;
|
||||||
|
synthesizedFolder?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadedType {
|
||||||
|
entity: Entity;
|
||||||
|
mtimeCli?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A replacement of FileOrFolderMixedState
|
||||||
|
*/
|
||||||
|
export interface MixedEntity {
|
||||||
|
key: string;
|
||||||
|
local?: Entity;
|
||||||
|
prevSync?: Entity;
|
||||||
|
remote?: Entity;
|
||||||
|
|
||||||
|
decisionBranch?: number;
|
||||||
|
decision?: DecisionTypeForMixedEntity;
|
||||||
|
conflictAction?: ConflictActionType;
|
||||||
|
|
||||||
|
sideNotes?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
export interface FileOrFolderMixedState {
|
export interface FileOrFolderMixedState {
|
||||||
key: string;
|
key: string;
|
||||||
existLocal?: boolean;
|
existLocal?: boolean;
|
||||||
@@ -161,7 +250,7 @@ export interface FileOrFolderMixedState {
|
|||||||
sizeRemoteEnc?: number;
|
sizeRemoteEnc?: number;
|
||||||
changeRemoteMtimeUsingMapping?: boolean;
|
changeRemoteMtimeUsingMapping?: boolean;
|
||||||
changeLocalMtimeUsingMapping?: boolean;
|
changeLocalMtimeUsingMapping?: boolean;
|
||||||
decision?: DecisionType;
|
decision?: string; // old DecisionType is deleted, fallback to string
|
||||||
decisionBranch?: number;
|
decisionBranch?: number;
|
||||||
syncDone?: "done";
|
syncDone?: "done";
|
||||||
remoteEncryptedKey?: string;
|
remoteEncryptedKey?: string;
|
||||||
@@ -175,6 +264,7 @@ export interface FileOrFolderMixedState {
|
|||||||
export const API_VER_STAT_FOLDER = "0.13.27";
|
export const API_VER_STAT_FOLDER = "0.13.27";
|
||||||
export const API_VER_REQURL = "0.13.26"; // desktop ver 0.13.26, iOS ver 1.1.1
|
export const API_VER_REQURL = "0.13.26"; // desktop ver 0.13.26, iOS ver 1.1.1
|
||||||
export const API_VER_REQURL_ANDROID = "0.14.6"; // Android ver 1.2.1
|
export const API_VER_REQURL_ANDROID = "0.14.6"; // Android ver 1.2.1
|
||||||
|
export const API_VER_ENSURE_REQURL_OK = "1.0.0"; // always bypass CORS here
|
||||||
|
|
||||||
export const VALID_REQURL =
|
export const VALID_REQURL =
|
||||||
(!Platform.isAndroidApp && requireApiVersion(API_VER_REQURL)) ||
|
(!Platform.isAndroidApp && requireApiVersion(API_VER_REQURL)) ||
|
||||||
@@ -184,12 +274,14 @@ export const DEFAULT_DEBUG_FOLDER = "_debug_remotely_save/";
|
|||||||
export const DEFAULT_SYNC_PLANS_HISTORY_FILE_PREFIX =
|
export const DEFAULT_SYNC_PLANS_HISTORY_FILE_PREFIX =
|
||||||
"sync_plans_hist_exported_on_";
|
"sync_plans_hist_exported_on_";
|
||||||
export const DEFAULT_LOG_HISTORY_FILE_PREFIX = "log_hist_exported_on_";
|
export const DEFAULT_LOG_HISTORY_FILE_PREFIX = "log_hist_exported_on_";
|
||||||
|
export const DEFAULT_PROFILER_RESULT_FILE_PREFIX =
|
||||||
|
"profiler_results_exported_on_";
|
||||||
|
|
||||||
export type SyncTriggerSourceType =
|
export type SyncTriggerSourceType =
|
||||||
| "manual"
|
| "manual"
|
||||||
| "auto"
|
|
||||||
| "dry"
|
| "dry"
|
||||||
| "autoOnceInit"
|
| "auto"
|
||||||
|
| "auto_once_init"
|
||||||
| "auto_sync_on_save";
|
| "auto_sync_on_save";
|
||||||
|
|
||||||
export const REMOTELY_SAVE_VERSION_2022 = "0.3.25";
|
export const REMOTELY_SAVE_VERSION_2022 = "0.3.25";
|
||||||
|
|||||||
+9
-11
@@ -3,8 +3,6 @@ import { reverseString } from "./misc";
|
|||||||
|
|
||||||
import type { RemotelySavePluginSettings } from "./baseTypes";
|
import type { RemotelySavePluginSettings } from "./baseTypes";
|
||||||
|
|
||||||
import { log } from "./moreOnLog";
|
|
||||||
|
|
||||||
const DEFAULT_README: string =
|
const DEFAULT_README: string =
|
||||||
"The file contains sensitive info, so DO NOT take screenshot of, copy, or share it to anyone! It's also generated automatically, so do not edit it manually.";
|
"The file contains sensitive info, so DO NOT take screenshot of, copy, or share it to anyone! It's also generated automatically, so do not edit it manually.";
|
||||||
|
|
||||||
@@ -19,10 +17,10 @@ interface MessyConfigType {
|
|||||||
export const messyConfigToNormal = (
|
export const messyConfigToNormal = (
|
||||||
x: MessyConfigType | RemotelySavePluginSettings | null | undefined
|
x: MessyConfigType | RemotelySavePluginSettings | null | undefined
|
||||||
): RemotelySavePluginSettings | null | undefined => {
|
): RemotelySavePluginSettings | null | undefined => {
|
||||||
// log.debug("loading, original config on disk:");
|
// console.debug("loading, original config on disk:");
|
||||||
// log.debug(x);
|
// console.debug(x);
|
||||||
if (x === null || x === undefined) {
|
if (x === null || x === undefined) {
|
||||||
log.debug("the messy config is null or undefined, skip");
|
console.debug("the messy config is null or undefined, skip");
|
||||||
return x as any;
|
return x as any;
|
||||||
}
|
}
|
||||||
if ("readme" in x && "d" in x) {
|
if ("readme" in x && "d" in x) {
|
||||||
@@ -35,12 +33,12 @@ export const messyConfigToNormal = (
|
|||||||
}) as Buffer
|
}) as Buffer
|
||||||
).toString("utf-8")
|
).toString("utf-8")
|
||||||
);
|
);
|
||||||
// log.debug("loading, parsed config is:");
|
// console.debug("loading, parsed config is:");
|
||||||
// log.debug(y);
|
// console.debug(y);
|
||||||
return y;
|
return y;
|
||||||
} else {
|
} else {
|
||||||
// return as is
|
// return as is
|
||||||
// log.debug("loading, parsed config is the same");
|
// console.debug("loading, parsed config is the same");
|
||||||
return x;
|
return x;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -52,7 +50,7 @@ export const normalConfigToMessy = (
|
|||||||
x: RemotelySavePluginSettings | null | undefined
|
x: RemotelySavePluginSettings | null | undefined
|
||||||
) => {
|
) => {
|
||||||
if (x === null || x === undefined) {
|
if (x === null || x === undefined) {
|
||||||
log.debug("the normal config is null or undefined, skip");
|
console.debug("the normal config is null or undefined, skip");
|
||||||
return x;
|
return x;
|
||||||
}
|
}
|
||||||
const y = {
|
const y = {
|
||||||
@@ -63,7 +61,7 @@ export const normalConfigToMessy = (
|
|||||||
})
|
})
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
// log.debug("encoding, encoded config is:");
|
// console.debug("encoding, encoded config is:");
|
||||||
// log.debug(y);
|
// console.debug(y);
|
||||||
return y;
|
return y;
|
||||||
};
|
};
|
||||||
|
|||||||
+21
-82
@@ -1,102 +1,41 @@
|
|||||||
import { TAbstractFile, TFolder, TFile, Vault } from "obsidian";
|
import { TAbstractFile, TFolder, TFile, Vault } from "obsidian";
|
||||||
|
|
||||||
import type { SyncPlanType } from "./sync";
|
|
||||||
import {
|
import {
|
||||||
|
readAllProfilerResultsByVault,
|
||||||
readAllSyncPlanRecordTextsByVault,
|
readAllSyncPlanRecordTextsByVault,
|
||||||
readAllLogRecordTextsByVault,
|
|
||||||
} from "./localdb";
|
} from "./localdb";
|
||||||
import type { InternalDBs } from "./localdb";
|
import type { InternalDBs } from "./localdb";
|
||||||
import { mkdirpInVault } from "./misc";
|
import { mkdirpInVault, unixTimeToStr } from "./misc";
|
||||||
import {
|
import {
|
||||||
DEFAULT_DEBUG_FOLDER,
|
DEFAULT_DEBUG_FOLDER,
|
||||||
DEFAULT_LOG_HISTORY_FILE_PREFIX,
|
DEFAULT_PROFILER_RESULT_FILE_PREFIX,
|
||||||
DEFAULT_SYNC_PLANS_HISTORY_FILE_PREFIX,
|
DEFAULT_SYNC_PLANS_HISTORY_FILE_PREFIX,
|
||||||
FileOrFolderMixedState,
|
|
||||||
} from "./baseTypes";
|
} from "./baseTypes";
|
||||||
|
|
||||||
import { log } from "./moreOnLog";
|
|
||||||
|
|
||||||
const turnSyncPlanToTable = (record: string) => {
|
|
||||||
const syncPlan: SyncPlanType = JSON.parse(record);
|
|
||||||
const { ts, tsFmt, remoteType, mixedStates } = syncPlan;
|
|
||||||
|
|
||||||
type allowedHeadersType = keyof FileOrFolderMixedState;
|
|
||||||
const headers: allowedHeadersType[] = [
|
|
||||||
"key",
|
|
||||||
"remoteEncryptedKey",
|
|
||||||
"existLocal",
|
|
||||||
"sizeLocal",
|
|
||||||
"sizeLocalEnc",
|
|
||||||
"mtimeLocal",
|
|
||||||
"deltimeLocal",
|
|
||||||
"changeLocalMtimeUsingMapping",
|
|
||||||
"existRemote",
|
|
||||||
"sizeRemote",
|
|
||||||
"sizeRemoteEnc",
|
|
||||||
"mtimeRemote",
|
|
||||||
"deltimeRemote",
|
|
||||||
"changeRemoteMtimeUsingMapping",
|
|
||||||
"decision",
|
|
||||||
"decisionBranch",
|
|
||||||
];
|
|
||||||
|
|
||||||
const lines = [
|
|
||||||
`ts: ${ts}${tsFmt !== undefined ? " / " + tsFmt : ""}`,
|
|
||||||
`remoteType: ${remoteType}`,
|
|
||||||
`| ${headers.join(" | ")} |`,
|
|
||||||
`| ${headers.map((x) => "---").join(" | ")} |`,
|
|
||||||
];
|
|
||||||
for (const [k1, v1] of Object.entries(syncPlan.mixedStates)) {
|
|
||||||
const k = k1 as string;
|
|
||||||
const v = v1 as FileOrFolderMixedState;
|
|
||||||
const singleLine = [];
|
|
||||||
for (const h of headers) {
|
|
||||||
const field = v[h];
|
|
||||||
if (field === undefined) {
|
|
||||||
singleLine.push("");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
h === "mtimeLocal" ||
|
|
||||||
h === "deltimeLocal" ||
|
|
||||||
h === "mtimeRemote" ||
|
|
||||||
h === "deltimeRemote"
|
|
||||||
) {
|
|
||||||
const fmt = v[(h + "Fmt") as allowedHeadersType] as string;
|
|
||||||
const s = `${field}${fmt !== undefined ? " / " + fmt : ""}`;
|
|
||||||
singleLine.push(s);
|
|
||||||
} else {
|
|
||||||
singleLine.push(field);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines.push(`| ${singleLine.join(" | ")} |`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join("\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
export const exportVaultSyncPlansToFiles = async (
|
export const exportVaultSyncPlansToFiles = async (
|
||||||
db: InternalDBs,
|
db: InternalDBs,
|
||||||
vault: Vault,
|
vault: Vault,
|
||||||
vaultRandomID: string,
|
vaultRandomID: string,
|
||||||
toFormat: "table" | "json" = "json"
|
howMany: number
|
||||||
) => {
|
) => {
|
||||||
log.info("exporting");
|
console.info("exporting sync plans");
|
||||||
await mkdirpInVault(DEFAULT_DEBUG_FOLDER, vault);
|
await mkdirpInVault(DEFAULT_DEBUG_FOLDER, vault);
|
||||||
const records = await readAllSyncPlanRecordTextsByVault(db, vaultRandomID);
|
const records = await readAllSyncPlanRecordTextsByVault(db, vaultRandomID);
|
||||||
let md = "";
|
let md = "";
|
||||||
if (records.length === 0) {
|
if (records.length === 0) {
|
||||||
md = "No sync plans history found";
|
md = "No sync plans history found";
|
||||||
} else {
|
} else {
|
||||||
if (toFormat === "json") {
|
if (howMany <= 0) {
|
||||||
md =
|
md =
|
||||||
"Sync plans found:\n\n" +
|
"Sync plans found:\n\n" +
|
||||||
records.map((x) => "```json\n" + x + "\n```\n").join("\n");
|
records.map((x) => "```json\n" + x + "\n```\n").join("\n");
|
||||||
} else if (toFormat === "table") {
|
|
||||||
md =
|
|
||||||
"Sync plans found:\n\n" + records.map(turnSyncPlanToTable).join("\n\n");
|
|
||||||
} else {
|
} else {
|
||||||
const _: never = toFormat;
|
md =
|
||||||
|
"Sync plans found:\n\n" +
|
||||||
|
records
|
||||||
|
.map((x) => "```json\n" + x + "\n```\n")
|
||||||
|
.slice(0, howMany)
|
||||||
|
.join("\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
@@ -104,29 +43,29 @@ export const exportVaultSyncPlansToFiles = async (
|
|||||||
await vault.create(filePath, md, {
|
await vault.create(filePath, md, {
|
||||||
mtime: ts,
|
mtime: ts,
|
||||||
});
|
});
|
||||||
log.info("finish exporting");
|
console.info("finish exporting sync plans");
|
||||||
};
|
};
|
||||||
|
|
||||||
export const exportVaultLoggerOutputToFiles = async (
|
export const exportVaultProfilerResultsToFiles = async (
|
||||||
db: InternalDBs,
|
db: InternalDBs,
|
||||||
vault: Vault,
|
vault: Vault,
|
||||||
vaultRandomID: string
|
vaultRandomID: string
|
||||||
) => {
|
) => {
|
||||||
|
console.info("exporting profiler results");
|
||||||
await mkdirpInVault(DEFAULT_DEBUG_FOLDER, vault);
|
await mkdirpInVault(DEFAULT_DEBUG_FOLDER, vault);
|
||||||
const records = await readAllLogRecordTextsByVault(db, vaultRandomID);
|
const records = await readAllProfilerResultsByVault(db, vaultRandomID);
|
||||||
let md = "";
|
let md = "";
|
||||||
if (records.length === 0) {
|
if (records.length === 0) {
|
||||||
md = "No logger history found.";
|
md = "No profiler results found";
|
||||||
} else {
|
} else {
|
||||||
md =
|
md =
|
||||||
"Logger history found:\n\n" +
|
"Profiler results found:\n\n" +
|
||||||
"```text\n" +
|
records.map((x) => "```\n" + x + "\n```\n").join("\n");
|
||||||
records.join("\n") +
|
|
||||||
"\n```\n";
|
|
||||||
}
|
}
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
const filePath = `${DEFAULT_DEBUG_FOLDER}${DEFAULT_LOG_HISTORY_FILE_PREFIX}${ts}.md`;
|
const filePath = `${DEFAULT_DEBUG_FOLDER}${DEFAULT_PROFILER_RESULT_FILE_PREFIX}${ts}.md`;
|
||||||
await vault.create(filePath, md, {
|
await vault.create(filePath, md, {
|
||||||
mtime: ts,
|
mtime: ts,
|
||||||
});
|
});
|
||||||
|
console.info("finish exporting profiler results");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { base32, base64url } from "rfc4648";
|
import { base32, base64url } from "rfc4648";
|
||||||
import { bufferToArrayBuffer, hexStringToTypedArray } from "./misc";
|
import { bufferToArrayBuffer, hexStringToTypedArray } from "./misc";
|
||||||
|
|
||||||
import { log } from "./moreOnLog";
|
|
||||||
|
|
||||||
const DEFAULT_ITER = 20000;
|
const DEFAULT_ITER = 20000;
|
||||||
|
|
||||||
// base32.stringify(Buffer.from('Salted__'))
|
// base32.stringify(Buffer.from('Salted__'))
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import {
|
||||||
|
Cipher as CipherRCloneCryptPack,
|
||||||
|
encryptedSize,
|
||||||
|
} from "@fyears/rclone-crypt";
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
import EncryptWorker from "./encryptRClone.worker";
|
||||||
|
|
||||||
|
interface RecvMsg {
|
||||||
|
status: "ok" | "error";
|
||||||
|
outputName?: string;
|
||||||
|
outputContent?: ArrayBuffer;
|
||||||
|
error?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSizeFromOrigToEnc = encryptedSize;
|
||||||
|
|
||||||
|
export class CipherRclone {
|
||||||
|
readonly password: string;
|
||||||
|
readonly cipher: CipherRCloneCryptPack;
|
||||||
|
readonly workers: Worker[];
|
||||||
|
init: boolean;
|
||||||
|
workerIdx: number;
|
||||||
|
constructor(password: string, workerNum: number) {
|
||||||
|
this.password = password;
|
||||||
|
this.init = false;
|
||||||
|
this.workerIdx = 0;
|
||||||
|
|
||||||
|
// console.debug("begin creating CipherRCloneCryptPack");
|
||||||
|
this.cipher = new CipherRCloneCryptPack("base64");
|
||||||
|
// console.debug("finish creating CipherRCloneCryptPack");
|
||||||
|
|
||||||
|
// console.debug("begin creating EncryptWorker");
|
||||||
|
this.workers = [];
|
||||||
|
for (let i = 0; i < workerNum; ++i) {
|
||||||
|
this.workers.push(new (EncryptWorker as any)() as Worker);
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.debug("finish creating EncryptWorker");
|
||||||
|
}
|
||||||
|
|
||||||
|
closeResources() {
|
||||||
|
for (let i = 0; i < this.workers.length; ++i) {
|
||||||
|
this.workers[i].terminate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async prepareByCallingWorker(): Promise<void> {
|
||||||
|
if (this.init) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// console.debug("begin prepareByCallingWorker");
|
||||||
|
await this.cipher.key(this.password, "");
|
||||||
|
// console.debug("finish getting key");
|
||||||
|
|
||||||
|
const res: Promise<void>[] = [];
|
||||||
|
for (let i = 0; i < this.workers.length; ++i) {
|
||||||
|
res.push(
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const channel = new MessageChannel();
|
||||||
|
|
||||||
|
channel.port2.onmessage = (event) => {
|
||||||
|
// console.debug("main: receiving msg in prepare");
|
||||||
|
const { status } = event.data as RecvMsg;
|
||||||
|
if (status === "ok") {
|
||||||
|
// console.debug("main: receiving init ok in prepare");
|
||||||
|
this.init = true;
|
||||||
|
resolve(); // return the class object itself
|
||||||
|
} else {
|
||||||
|
reject("error after prepareByCallingWorker");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
channel.port2.onmessageerror = (event) => {
|
||||||
|
// console.debug("main: receiving error in prepare");
|
||||||
|
reject(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
// console.debug("main: before postMessage in prepare");
|
||||||
|
this.workers[i].postMessage(
|
||||||
|
{
|
||||||
|
action: "prepare",
|
||||||
|
dataKeyBuf: this.cipher.dataKey.buffer,
|
||||||
|
nameKeyBuf: this.cipher.nameKey.buffer,
|
||||||
|
nameTweakBuf: this.cipher.nameTweak.buffer,
|
||||||
|
},
|
||||||
|
[channel.port1 /* buffer no transfered because we need to copy */]
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await Promise.all(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
async encryptNameByCallingWorker(inputName: string): Promise<string> {
|
||||||
|
// console.debug("main: start encryptNameByCallingWorker");
|
||||||
|
await this.prepareByCallingWorker();
|
||||||
|
// console.debug(
|
||||||
|
// "main: really start generate promise in encryptNameByCallingWorker"
|
||||||
|
// );
|
||||||
|
++this.workerIdx;
|
||||||
|
const whichWorker = this.workerIdx % this.workers.length;
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const channel = new MessageChannel();
|
||||||
|
|
||||||
|
channel.port2.onmessage = (event) => {
|
||||||
|
// console.debug("main: receiving msg in encryptNameByCallingWorker");
|
||||||
|
const { outputName } = event.data as RecvMsg;
|
||||||
|
if (outputName === undefined) {
|
||||||
|
reject("unknown outputName after encryptNameByCallingWorker");
|
||||||
|
} else {
|
||||||
|
resolve(outputName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
channel.port2.onmessageerror = (event) => {
|
||||||
|
// console.debug("main: receiving error in encryptNameByCallingWorker");
|
||||||
|
reject(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
// console.debug("main: before postMessage in encryptNameByCallingWorker");
|
||||||
|
this.workers[whichWorker].postMessage(
|
||||||
|
{
|
||||||
|
action: "encryptName",
|
||||||
|
inputName: inputName,
|
||||||
|
},
|
||||||
|
[channel.port1]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async decryptNameByCallingWorker(inputName: string): Promise<string> {
|
||||||
|
await this.prepareByCallingWorker();
|
||||||
|
++this.workerIdx;
|
||||||
|
const whichWorker = this.workerIdx % this.workers.length;
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const channel = new MessageChannel();
|
||||||
|
|
||||||
|
channel.port2.onmessage = (event) => {
|
||||||
|
// console.debug("main: receiving msg in decryptNameByCallingWorker");
|
||||||
|
const { outputName, status } = event.data as RecvMsg;
|
||||||
|
|
||||||
|
if (status === "error") {
|
||||||
|
reject("error");
|
||||||
|
} else {
|
||||||
|
if (outputName === undefined) {
|
||||||
|
reject("unknown outputName after decryptNameByCallingWorker");
|
||||||
|
} else {
|
||||||
|
resolve(outputName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
channel.port2.onmessageerror = (event) => {
|
||||||
|
// console.debug("main: receiving error in decryptNameByCallingWorker");
|
||||||
|
reject(event);
|
||||||
|
channel;
|
||||||
|
};
|
||||||
|
|
||||||
|
// console.debug("main: before postMessage in decryptNameByCallingWorker");
|
||||||
|
this.workers[whichWorker].postMessage(
|
||||||
|
{
|
||||||
|
action: "decryptName",
|
||||||
|
inputName: inputName,
|
||||||
|
},
|
||||||
|
[channel.port1]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async encryptContentByCallingWorker(
|
||||||
|
input: ArrayBuffer
|
||||||
|
): Promise<ArrayBuffer> {
|
||||||
|
await this.prepareByCallingWorker();
|
||||||
|
++this.workerIdx;
|
||||||
|
const whichWorker = this.workerIdx % this.workers.length;
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const channel = new MessageChannel();
|
||||||
|
|
||||||
|
channel.port2.onmessage = (event) => {
|
||||||
|
// console.debug("main: receiving msg in encryptContentByCallingWorker");
|
||||||
|
const { outputContent } = event.data as RecvMsg;
|
||||||
|
if (outputContent === undefined) {
|
||||||
|
reject("unknown outputContent after encryptContentByCallingWorker");
|
||||||
|
} else {
|
||||||
|
resolve(outputContent);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
channel.port2.onmessageerror = (event) => {
|
||||||
|
// console.debug("main: receiving error in encryptContentByCallingWorker");
|
||||||
|
reject(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
// console.debug(
|
||||||
|
// "main: before postMessage in encryptContentByCallingWorker"
|
||||||
|
// );
|
||||||
|
this.workers[whichWorker].postMessage(
|
||||||
|
{
|
||||||
|
action: "encryptContent",
|
||||||
|
inputContent: input,
|
||||||
|
},
|
||||||
|
[channel.port1, input]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async decryptContentByCallingWorker(
|
||||||
|
input: ArrayBuffer
|
||||||
|
): Promise<ArrayBuffer> {
|
||||||
|
await this.prepareByCallingWorker();
|
||||||
|
++this.workerIdx;
|
||||||
|
const whichWorker = this.workerIdx % this.workers.length;
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const channel = new MessageChannel();
|
||||||
|
|
||||||
|
channel.port2.onmessage = (event) => {
|
||||||
|
// console.debug("main: receiving msg in decryptContentByCallingWorker");
|
||||||
|
const { outputContent, status } = event.data as RecvMsg;
|
||||||
|
|
||||||
|
if (status === "error") {
|
||||||
|
reject("error");
|
||||||
|
} else {
|
||||||
|
if (outputContent === undefined) {
|
||||||
|
reject("unknown outputContent after decryptContentByCallingWorker");
|
||||||
|
} else {
|
||||||
|
resolve(outputContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
channel.port2.onmessageerror = (event) => {
|
||||||
|
// console.debug(
|
||||||
|
// "main: receiving onmessageerror in decryptContentByCallingWorker"
|
||||||
|
// );
|
||||||
|
reject(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
// console.debug(
|
||||||
|
// "main: before postMessage in decryptContentByCallingWorker"
|
||||||
|
// );
|
||||||
|
this.workers[whichWorker].postMessage(
|
||||||
|
{
|
||||||
|
action: "decryptContent",
|
||||||
|
inputContent: input,
|
||||||
|
},
|
||||||
|
[channel.port1, input]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { Cipher as CipherRCloneCryptPack } from "@fyears/rclone-crypt";
|
||||||
|
|
||||||
|
const ctx: WorkerGlobalScope = self as any;
|
||||||
|
|
||||||
|
const workerNanoID = nanoid();
|
||||||
|
const cipher = new CipherRCloneCryptPack("base64");
|
||||||
|
|
||||||
|
// console.debug(`worker [${workerNanoID}]: cipher created`);
|
||||||
|
|
||||||
|
async function encryptNameStr(input: string) {
|
||||||
|
const res = await cipher.encryptFileName(input);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptNameStr(input: string) {
|
||||||
|
return await cipher.decryptFileName(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function encryptContentBuf(input: ArrayBuffer) {
|
||||||
|
return (await cipher.encryptData(new Uint8Array(input), undefined)).buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptContentBuf(input: ArrayBuffer) {
|
||||||
|
return (await cipher.decryptData(new Uint8Array(input))).buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.addEventListener("message", async (event: any) => {
|
||||||
|
const port: MessagePort = event.ports[0];
|
||||||
|
const {
|
||||||
|
action,
|
||||||
|
dataKeyBuf,
|
||||||
|
nameKeyBuf,
|
||||||
|
nameTweakBuf,
|
||||||
|
inputName,
|
||||||
|
inputContent,
|
||||||
|
} = event.data as {
|
||||||
|
action:
|
||||||
|
| "prepare"
|
||||||
|
| "encryptContent"
|
||||||
|
| "decryptContent"
|
||||||
|
| "encryptName"
|
||||||
|
| "decryptName";
|
||||||
|
dataKeyBuf?: ArrayBuffer;
|
||||||
|
nameKeyBuf?: ArrayBuffer;
|
||||||
|
nameTweakBuf?: ArrayBuffer;
|
||||||
|
inputName?: string;
|
||||||
|
inputContent?: ArrayBuffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
// console.debug(`worker [${workerNanoID}]: receiving action=${action}`);
|
||||||
|
|
||||||
|
if (action === "prepare") {
|
||||||
|
// console.debug(`worker [${workerNanoID}]: prepare: start`);
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
dataKeyBuf === undefined ||
|
||||||
|
nameKeyBuf === undefined ||
|
||||||
|
nameTweakBuf === undefined
|
||||||
|
) {
|
||||||
|
// console.debug(`worker [${workerNanoID}]: prepare: no buffer??`);
|
||||||
|
throw Error(
|
||||||
|
`worker [${workerNanoID}]: prepare: internal keys not transferred to worker properly`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// console.debug(`worker [${workerNanoID}]: prepare: so we update`);
|
||||||
|
cipher.updateInternalKey(
|
||||||
|
new Uint8Array(dataKeyBuf),
|
||||||
|
new Uint8Array(nameKeyBuf),
|
||||||
|
new Uint8Array(nameTweakBuf)
|
||||||
|
);
|
||||||
|
port.postMessage({
|
||||||
|
status: "ok",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
port.postMessage({
|
||||||
|
status: "error",
|
||||||
|
error: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (action === "encryptName") {
|
||||||
|
try {
|
||||||
|
if (inputName === undefined) {
|
||||||
|
throw Error(
|
||||||
|
`worker [${workerNanoID}]: encryptName: internal inputName not transferred to worker properly`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const outputName = await encryptNameStr(inputName);
|
||||||
|
// console.debug(
|
||||||
|
// `worker [${workerNanoID}]: after encryptNameStr, before postMessage`
|
||||||
|
// );
|
||||||
|
port.postMessage({
|
||||||
|
status: "ok",
|
||||||
|
outputName: outputName,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`worker [${workerNanoID}]: encryptName=${inputName}`);
|
||||||
|
console.error(error);
|
||||||
|
port.postMessage({
|
||||||
|
status: "error",
|
||||||
|
error: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (action === "decryptName") {
|
||||||
|
try {
|
||||||
|
if (inputName === undefined) {
|
||||||
|
throw Error(
|
||||||
|
`worker [${workerNanoID}]: decryptName: internal inputName not transferred to worker properly`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const outputName = await decryptNameStr(inputName);
|
||||||
|
// console.debug(
|
||||||
|
// `worker [${workerNanoID}]: after decryptNameStr, before postMessage`
|
||||||
|
// );
|
||||||
|
port.postMessage({
|
||||||
|
status: "ok",
|
||||||
|
outputName: outputName,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`worker [${workerNanoID}]: decryptName=${inputName}`);
|
||||||
|
console.error(error);
|
||||||
|
port.postMessage({
|
||||||
|
status: "error",
|
||||||
|
error: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (action === "encryptContent") {
|
||||||
|
try {
|
||||||
|
if (inputContent === undefined) {
|
||||||
|
throw Error(
|
||||||
|
`worker [${workerNanoID}]: encryptContent: internal inputContent not transferred to worker properly`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const outputContent = await encryptContentBuf(inputContent);
|
||||||
|
// console.debug(
|
||||||
|
// `worker [${workerNanoID}]: after encryptContentBuf, before postMessage`
|
||||||
|
// );
|
||||||
|
port.postMessage(
|
||||||
|
{
|
||||||
|
status: "ok",
|
||||||
|
outputContent: outputContent,
|
||||||
|
},
|
||||||
|
[outputContent]
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
port.postMessage({
|
||||||
|
status: "error",
|
||||||
|
error: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (action === "decryptContent") {
|
||||||
|
try {
|
||||||
|
if (inputContent === undefined) {
|
||||||
|
throw Error(
|
||||||
|
`worker [${workerNanoID}]: decryptContent: internal inputContent not transferred to worker properly`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const outputContent = await decryptContentBuf(inputContent);
|
||||||
|
// console.debug(
|
||||||
|
// `worker [${workerNanoID}]: after decryptContentBuf, before postMessage`
|
||||||
|
// );
|
||||||
|
port.postMessage(
|
||||||
|
{
|
||||||
|
status: "ok",
|
||||||
|
outputContent: outputContent,
|
||||||
|
},
|
||||||
|
[outputContent]
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
port.postMessage({
|
||||||
|
status: "error",
|
||||||
|
error: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
port.postMessage({
|
||||||
|
status: "error",
|
||||||
|
error: `worker [${workerNanoID}]: unknown action=${action}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Entity } from "./baseTypes";
|
||||||
|
|
||||||
|
export abstract class FakeFs {
|
||||||
|
abstract kind: string;
|
||||||
|
abstract walk(): Promise<Entity[]>;
|
||||||
|
abstract stat(key: string): Promise<Entity>;
|
||||||
|
abstract mkdir(key: string, mtime?: number, ctime?: number): Promise<Entity>;
|
||||||
|
abstract writeFile(
|
||||||
|
key: string,
|
||||||
|
content: ArrayBuffer,
|
||||||
|
mtime: number,
|
||||||
|
ctime: number
|
||||||
|
): Promise<Entity>;
|
||||||
|
abstract readFile(key: string): Promise<ArrayBuffer>;
|
||||||
|
abstract rm(key: string): Promise<void>;
|
||||||
|
abstract checkConnect(callbackFunc?: any): Promise<boolean>;
|
||||||
|
abstract getUserDisplayName(): Promise<string>;
|
||||||
|
abstract revokeAuth(): Promise<any>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,739 @@
|
|||||||
|
import { FakeFs } from "./fsAll";
|
||||||
|
import { Dropbox, DropboxAuth } from "dropbox";
|
||||||
|
import type { files, DropboxResponseError, DropboxResponse } from "dropbox";
|
||||||
|
import {
|
||||||
|
DropboxConfig,
|
||||||
|
COMMAND_CALLBACK_DROPBOX,
|
||||||
|
OAUTH2_FORCE_EXPIRE_MILLISECONDS,
|
||||||
|
Entity,
|
||||||
|
} from "./baseTypes";
|
||||||
|
import random from "lodash/random";
|
||||||
|
import {
|
||||||
|
bufferToArrayBuffer,
|
||||||
|
delay,
|
||||||
|
getFolderLevels,
|
||||||
|
getParentFolder,
|
||||||
|
hasEmojiInText,
|
||||||
|
headersToRecord,
|
||||||
|
} from "./misc";
|
||||||
|
|
||||||
|
export { Dropbox } from "dropbox";
|
||||||
|
|
||||||
|
export const DEFAULT_DROPBOX_CONFIG: DropboxConfig = {
|
||||||
|
accessToken: "",
|
||||||
|
clientID: process.env.DEFAULT_DROPBOX_APP_KEY ?? "",
|
||||||
|
refreshToken: "",
|
||||||
|
accessTokenExpiresInSeconds: 0,
|
||||||
|
accessTokenExpiresAtTime: 0,
|
||||||
|
accountID: "",
|
||||||
|
username: "",
|
||||||
|
credentialsShouldBeDeletedAtTime: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDropboxPath = (fileOrFolderPath: string, remoteBaseDir: string) => {
|
||||||
|
let key = fileOrFolderPath;
|
||||||
|
if (fileOrFolderPath === "/" || fileOrFolderPath === "") {
|
||||||
|
// special
|
||||||
|
key = `/${remoteBaseDir}`;
|
||||||
|
} else if (fileOrFolderPath.startsWith("/")) {
|
||||||
|
console.warn(
|
||||||
|
`why the path ${fileOrFolderPath} starts with '/'? but we just go on.`
|
||||||
|
);
|
||||||
|
key = `/${remoteBaseDir}${fileOrFolderPath}`;
|
||||||
|
} else {
|
||||||
|
key = `/${remoteBaseDir}/${fileOrFolderPath}`;
|
||||||
|
}
|
||||||
|
if (key.endsWith("/")) {
|
||||||
|
key = key.slice(0, key.length - 1);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNormPath = (fileOrFolderPath: string, remoteBaseDir: string) => {
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
fileOrFolderPath === `/${remoteBaseDir}` ||
|
||||||
|
fileOrFolderPath.startsWith(`/${remoteBaseDir}/`)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw Error(
|
||||||
|
`"${fileOrFolderPath}" doesn't starts with "/${remoteBaseDir}/"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return fileOrFolderPath.slice(`/${remoteBaseDir}/`.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fromDropboxItemToEntity = (
|
||||||
|
x:
|
||||||
|
| files.FileMetadataReference
|
||||||
|
| files.FolderMetadataReference
|
||||||
|
| files.DeletedMetadataReference,
|
||||||
|
remoteBaseDir: string
|
||||||
|
): Entity => {
|
||||||
|
let key = getNormPath(x.path_display!, remoteBaseDir);
|
||||||
|
if (x[".tag"] === "folder" && !key.endsWith("/")) {
|
||||||
|
key = `${key}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (x[".tag"] === "folder") {
|
||||||
|
return {
|
||||||
|
key: key,
|
||||||
|
keyRaw: key,
|
||||||
|
size: 0,
|
||||||
|
sizeRaw: 0,
|
||||||
|
} as Entity;
|
||||||
|
} else if (x[".tag"] === "file") {
|
||||||
|
const mtimeCli = Date.parse(x.client_modified).valueOf();
|
||||||
|
const mtimeSvr = Date.parse(x.server_modified).valueOf();
|
||||||
|
return {
|
||||||
|
key: key,
|
||||||
|
keyRaw: key,
|
||||||
|
mtimeCli: mtimeCli,
|
||||||
|
mtimeSvr: mtimeSvr,
|
||||||
|
size: x.size,
|
||||||
|
sizeRaw: x.size,
|
||||||
|
hash: x.content_hash,
|
||||||
|
} as Entity;
|
||||||
|
} else {
|
||||||
|
// x[".tag"] === "deleted"
|
||||||
|
throw Error("do not support deleted tag");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://github.com/remotely-save/remotely-save/issues/567
|
||||||
|
* https://www.dropboxforum.com/t5/Dropbox-API-Support-Feedback/Case-Sensitivity-in-API-2/td-p/191279
|
||||||
|
* @param entities
|
||||||
|
*/
|
||||||
|
export const fixEntityListCasesInplace = (entities: { key?: string }[]) => {
|
||||||
|
for (const iterator of entities) {
|
||||||
|
if (iterator.key === undefined) {
|
||||||
|
throw Error(`dropbox list should all have key, but meet undefined`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entities.sort((a, b) => a.key!.length - b.key!.length);
|
||||||
|
// console.log(JSON.stringify(entities,null,2));
|
||||||
|
|
||||||
|
const caseMapping: Record<string, string> = { "": "" };
|
||||||
|
for (const e of entities) {
|
||||||
|
// console.log(`looking for: ${JSON.stringify(e, null, 2)}`);
|
||||||
|
|
||||||
|
let parentFolder = getParentFolder(e.key!);
|
||||||
|
if (parentFolder === "/") {
|
||||||
|
parentFolder = "";
|
||||||
|
}
|
||||||
|
const parentFolderLower = parentFolder.toLocaleLowerCase();
|
||||||
|
const segs = e.key!.split("/");
|
||||||
|
if (e.key!.endsWith("/")) {
|
||||||
|
// folder
|
||||||
|
if (caseMapping.hasOwnProperty(parentFolderLower)) {
|
||||||
|
const newKey = `${caseMapping[parentFolderLower]}${segs
|
||||||
|
.slice(-2)
|
||||||
|
.join("/")}`;
|
||||||
|
caseMapping[newKey.toLocaleLowerCase()] = newKey;
|
||||||
|
e.key = newKey;
|
||||||
|
// console.log(JSON.stringify(caseMapping,null,2));
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
throw Error(`${parentFolder} doesn't have cases record??`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// file
|
||||||
|
if (caseMapping.hasOwnProperty(parentFolderLower)) {
|
||||||
|
const newKey = `${caseMapping[parentFolderLower]}${segs
|
||||||
|
.slice(-1)
|
||||||
|
.join("/")}`;
|
||||||
|
e.key = newKey;
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
throw Error(`${parentFolder} doesn't have cases record??`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
};
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Other usual common methods
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
interface ErrSubType {
|
||||||
|
error: {
|
||||||
|
retry_after: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryReq<T>(
|
||||||
|
reqFunc: () => Promise<DropboxResponse<T>>,
|
||||||
|
extraHint: string = ""
|
||||||
|
): Promise<DropboxResponse<T> | undefined> {
|
||||||
|
const waitSeconds = [1, 2, 4, 8]; // hard code exponential backoff
|
||||||
|
for (let idx = 0; idx < waitSeconds.length; ++idx) {
|
||||||
|
try {
|
||||||
|
if (idx !== 0) {
|
||||||
|
console.warn(
|
||||||
|
`${extraHint === "" ? "" : extraHint + ": "}The ${
|
||||||
|
idx + 1
|
||||||
|
}-th try starts at time ${Date.now()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return await reqFunc();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as DropboxResponseError<ErrSubType>;
|
||||||
|
if (err.status === undefined) {
|
||||||
|
// then the err is not DropboxResponseError
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (err.status !== 429) {
|
||||||
|
// then the err is not "too many requests", give up
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idx === waitSeconds.length - 1) {
|
||||||
|
// the last retry also failed, give up
|
||||||
|
throw new Error(
|
||||||
|
`${
|
||||||
|
extraHint === "" ? "" : extraHint + ": "
|
||||||
|
}"429 too many requests", after retrying for ${
|
||||||
|
idx + 1
|
||||||
|
} times still failed.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = headersToRecord(err.headers);
|
||||||
|
const svrSec =
|
||||||
|
err.error.error.retry_after ||
|
||||||
|
parseInt(headers["retry-after"] || "1") ||
|
||||||
|
1;
|
||||||
|
const fallbackSec = waitSeconds[idx];
|
||||||
|
const secMin = Math.max(svrSec, fallbackSec);
|
||||||
|
const secMax = Math.max(secMin * 1.8, 2);
|
||||||
|
console.warn(
|
||||||
|
`${
|
||||||
|
extraHint === "" ? "" : extraHint + ": "
|
||||||
|
}We have "429 too many requests" error of ${
|
||||||
|
idx + 1
|
||||||
|
}-th try, at time ${Date.now()}, and wait for ${secMin} ~ ${secMax} seconds to retry. Original info: ${JSON.stringify(
|
||||||
|
err.error,
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
await delay(random(secMin * 1000, secMax * 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Dropbox authorization using PKCE
|
||||||
|
// see https://dropbox.tech/developers/pkce--what-and-why-
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
export const getAuthUrlAndVerifier = async (
|
||||||
|
appKey: string,
|
||||||
|
needManualPatse: boolean = false
|
||||||
|
) => {
|
||||||
|
const auth = new DropboxAuth({
|
||||||
|
clientId: appKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const callback = needManualPatse
|
||||||
|
? undefined
|
||||||
|
: `obsidian://${COMMAND_CALLBACK_DROPBOX}`;
|
||||||
|
const authUrl = (
|
||||||
|
await auth.getAuthenticationUrl(
|
||||||
|
callback as any,
|
||||||
|
undefined,
|
||||||
|
"code",
|
||||||
|
"offline",
|
||||||
|
undefined,
|
||||||
|
"none",
|
||||||
|
true
|
||||||
|
)
|
||||||
|
).toString();
|
||||||
|
const verifier = auth.getCodeVerifier();
|
||||||
|
return {
|
||||||
|
authUrl: authUrl,
|
||||||
|
verifier: verifier,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DropboxSuccessAuthRes {
|
||||||
|
access_token: string;
|
||||||
|
token_type: "bearer";
|
||||||
|
expires_in: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
scope?: string;
|
||||||
|
uid?: string;
|
||||||
|
account_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendAuthReq = async (
|
||||||
|
appKey: string,
|
||||||
|
verifier: string,
|
||||||
|
authCode: string,
|
||||||
|
errorCallBack: any
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const resp1 = await fetch("https://api.dropboxapi.com/oauth2/token", {
|
||||||
|
method: "POST",
|
||||||
|
body: new URLSearchParams({
|
||||||
|
code: authCode,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code_verifier: verifier,
|
||||||
|
client_id: appKey,
|
||||||
|
redirect_uri: `obsidian://${COMMAND_CALLBACK_DROPBOX}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const resp2 = (await resp1.json()) as DropboxSuccessAuthRes;
|
||||||
|
return resp2;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
if (errorCallBack !== undefined) {
|
||||||
|
await errorCallBack(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendRefreshTokenReq = async (
|
||||||
|
appKey: string,
|
||||||
|
refreshToken: string
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
console.info("start auto getting refreshed Dropbox access token.");
|
||||||
|
const resp1 = await fetch("https://api.dropboxapi.com/oauth2/token", {
|
||||||
|
method: "POST",
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
client_id: appKey,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const resp2 = (await resp1.json()) as DropboxSuccessAuthRes;
|
||||||
|
console.info("finish auto getting refreshed Dropbox access token.");
|
||||||
|
return resp2;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setConfigBySuccessfullAuthInplace = async (
|
||||||
|
config: DropboxConfig,
|
||||||
|
authRes: DropboxSuccessAuthRes,
|
||||||
|
saveUpdatedConfigFunc: () => Promise<any> | undefined
|
||||||
|
) => {
|
||||||
|
console.info("start updating local info of Dropbox token");
|
||||||
|
|
||||||
|
config.accessToken = authRes.access_token;
|
||||||
|
config.accessTokenExpiresInSeconds = parseInt(authRes.expires_in);
|
||||||
|
config.accessTokenExpiresAtTime =
|
||||||
|
Date.now() + parseInt(authRes.expires_in) * 1000 - 10 * 1000;
|
||||||
|
|
||||||
|
// manually set it expired after 80 days;
|
||||||
|
config.credentialsShouldBeDeletedAtTime =
|
||||||
|
Date.now() + OAUTH2_FORCE_EXPIRE_MILLISECONDS;
|
||||||
|
|
||||||
|
if (authRes.refresh_token !== undefined) {
|
||||||
|
config.refreshToken = authRes.refresh_token;
|
||||||
|
config.accountID = authRes.account_id!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveUpdatedConfigFunc !== undefined) {
|
||||||
|
await saveUpdatedConfigFunc();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info("finish updating local info of Dropbox token");
|
||||||
|
};
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// real exported interface
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
export class FakeFsDropbox extends FakeFs {
|
||||||
|
kind: "dropbox";
|
||||||
|
dropboxConfig: DropboxConfig;
|
||||||
|
remoteBaseDir: string;
|
||||||
|
saveUpdatedConfigFunc: () => Promise<any>;
|
||||||
|
dropbox!: Dropbox;
|
||||||
|
vaultFolderExists: boolean;
|
||||||
|
foldersCreatedBefore: Set<string>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
dropboxConfig: DropboxConfig,
|
||||||
|
vaultName: string,
|
||||||
|
saveUpdatedConfigFunc: () => Promise<any>
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this.kind = "dropbox";
|
||||||
|
this.dropboxConfig = dropboxConfig;
|
||||||
|
this.remoteBaseDir = this.dropboxConfig.remoteBaseDir || vaultName || "";
|
||||||
|
this.saveUpdatedConfigFunc = saveUpdatedConfigFunc;
|
||||||
|
this.vaultFolderExists = false;
|
||||||
|
this.foldersCreatedBefore = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _init() {
|
||||||
|
// check token
|
||||||
|
if (
|
||||||
|
this.dropboxConfig.accessToken === "" ||
|
||||||
|
this.dropboxConfig.refreshToken === ""
|
||||||
|
) {
|
||||||
|
throw Error("The user has not manually auth yet.");
|
||||||
|
}
|
||||||
|
const currentTs = Date.now();
|
||||||
|
const customHeaders = {
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
};
|
||||||
|
if (this.dropboxConfig.accessTokenExpiresAtTime > currentTs) {
|
||||||
|
this.dropbox = new Dropbox({
|
||||||
|
accessToken: this.dropboxConfig.accessToken,
|
||||||
|
customHeaders: customHeaders,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (this.dropboxConfig.refreshToken === "") {
|
||||||
|
throw Error(
|
||||||
|
"We need to automatically refresh token but none is stored."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const resp = await sendRefreshTokenReq(
|
||||||
|
this.dropboxConfig.clientID,
|
||||||
|
this.dropboxConfig.refreshToken
|
||||||
|
);
|
||||||
|
|
||||||
|
setConfigBySuccessfullAuthInplace(
|
||||||
|
this.dropboxConfig,
|
||||||
|
resp,
|
||||||
|
this.saveUpdatedConfigFunc
|
||||||
|
);
|
||||||
|
this.dropbox = new Dropbox({
|
||||||
|
accessToken: this.dropboxConfig.accessToken,
|
||||||
|
customHeaders: customHeaders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// check vault folder
|
||||||
|
// console.info(`checking remote has folder /${this.remoteBaseDir}`);
|
||||||
|
if (this.vaultFolderExists) {
|
||||||
|
// console.info(`already checked, /${this.remoteBaseDir} exist before`)
|
||||||
|
} else {
|
||||||
|
const res = await this.dropbox.filesListFolder({
|
||||||
|
path: "",
|
||||||
|
recursive: false,
|
||||||
|
});
|
||||||
|
for (const item of res.result.entries) {
|
||||||
|
if (item.path_display === `/${this.remoteBaseDir}`) {
|
||||||
|
this.vaultFolderExists = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!this.vaultFolderExists) {
|
||||||
|
console.info(`remote does not have folder /${this.remoteBaseDir}`);
|
||||||
|
|
||||||
|
if (hasEmojiInText(`/${this.remoteBaseDir}`)) {
|
||||||
|
throw new Error(
|
||||||
|
`/${this.remoteBaseDir}: Error: Dropbox does not support emoji in folder names.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dropbox.filesCreateFolderV2({
|
||||||
|
path: `/${this.remoteBaseDir}`,
|
||||||
|
});
|
||||||
|
console.info(`remote folder /${this.remoteBaseDir} created`);
|
||||||
|
this.vaultFolderExists = true;
|
||||||
|
} else {
|
||||||
|
// console.info(`remote folder /${this.remoteBaseDir} exists`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async walk(): Promise<Entity[]> {
|
||||||
|
await this._init();
|
||||||
|
|
||||||
|
let res = await this.dropbox.filesListFolder({
|
||||||
|
path: `/${this.remoteBaseDir}`,
|
||||||
|
recursive: true,
|
||||||
|
include_deleted: false,
|
||||||
|
limit: 1000,
|
||||||
|
});
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw Error(JSON.stringify(res));
|
||||||
|
}
|
||||||
|
// console.info(res);
|
||||||
|
|
||||||
|
const contents = res.result.entries;
|
||||||
|
const unifiedContents = contents
|
||||||
|
.filter((x) => x[".tag"] !== "deleted")
|
||||||
|
.filter((x) => x.path_display !== `/${this.remoteBaseDir}`)
|
||||||
|
.map((x) => fromDropboxItemToEntity(x, this.remoteBaseDir));
|
||||||
|
|
||||||
|
while (res.result.has_more) {
|
||||||
|
res = await this.dropbox.filesListFolderContinue({
|
||||||
|
cursor: res.result.cursor,
|
||||||
|
});
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw Error(JSON.stringify(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
const contents2 = res.result.entries;
|
||||||
|
const unifiedContents2 = contents2
|
||||||
|
.filter((x) => x[".tag"] !== "deleted")
|
||||||
|
.filter((x) => x.path_display !== `/${this.remoteBaseDir}`)
|
||||||
|
.map((x) => fromDropboxItemToEntity(x, this.remoteBaseDir));
|
||||||
|
unifiedContents.push(...unifiedContents2);
|
||||||
|
}
|
||||||
|
|
||||||
|
fixEntityListCasesInplace(unifiedContents);
|
||||||
|
|
||||||
|
return unifiedContents;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stat(key: string): Promise<Entity> {
|
||||||
|
await this._init();
|
||||||
|
return await this._statFromRoot(getDropboxPath(key, this.remoteBaseDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
async _statFromRoot(key: string): Promise<Entity> {
|
||||||
|
// if (key === "" || key === "/") {
|
||||||
|
// // filesGetMetadata doesn't support root folder
|
||||||
|
// // we instead try to list files
|
||||||
|
// // if no error occurs, we ensemble a fake result.
|
||||||
|
// const rsp = await retryReq(() =>
|
||||||
|
// client.dropbox.filesListFolder({
|
||||||
|
// path: `/${client.key}`,
|
||||||
|
// recursive: false, // don't need to recursive here
|
||||||
|
// })
|
||||||
|
// );
|
||||||
|
// if (rsp.status !== 200) {
|
||||||
|
// throw Error(JSON.stringify(rsp));
|
||||||
|
// }
|
||||||
|
// return {
|
||||||
|
// key: remotePath,
|
||||||
|
// lastModified: undefined,
|
||||||
|
// size: 0,
|
||||||
|
// remoteType: "dropbox",
|
||||||
|
// etag: undefined,
|
||||||
|
// } as Entity;
|
||||||
|
// }
|
||||||
|
|
||||||
|
const rsp = await retryReq(() =>
|
||||||
|
this.dropbox.filesGetMetadata({
|
||||||
|
path: key,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (rsp === undefined) {
|
||||||
|
throw Error("dropbox.filesGetMetadata undefinded");
|
||||||
|
}
|
||||||
|
if (rsp.status !== 200) {
|
||||||
|
throw Error(JSON.stringify(rsp));
|
||||||
|
}
|
||||||
|
return fromDropboxItemToEntity(rsp.result, this.remoteBaseDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
async mkdir(key: string, mtime?: number, ctime?: number): Promise<Entity> {
|
||||||
|
if (!key.endsWith("/")) {
|
||||||
|
throw Error(`you should not call mkdir on ${key}`);
|
||||||
|
}
|
||||||
|
await this._init();
|
||||||
|
|
||||||
|
const uploadFile = getDropboxPath(key, this.remoteBaseDir);
|
||||||
|
|
||||||
|
return await this._mkdirFromRoot(uploadFile, mtime, ctime);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _mkdirFromRoot(
|
||||||
|
key: string,
|
||||||
|
mtime?: number,
|
||||||
|
ctime?: number
|
||||||
|
): Promise<Entity> {
|
||||||
|
if (hasEmojiInText(key)) {
|
||||||
|
throw new Error(
|
||||||
|
`${key}: Error: Dropbox does not support emoji in file / folder names.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (this.foldersCreatedBefore?.has(key)) {
|
||||||
|
// created, pass
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await retryReq(
|
||||||
|
() =>
|
||||||
|
this.dropbox.filesCreateFolderV2({
|
||||||
|
path: key,
|
||||||
|
}),
|
||||||
|
key // just a hint
|
||||||
|
);
|
||||||
|
this.foldersCreatedBefore?.add(key);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as DropboxResponseError<files.CreateFolderError>;
|
||||||
|
if (err.status === undefined) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (err.status === 409) {
|
||||||
|
// pass
|
||||||
|
this.foldersCreatedBefore?.add(key);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await this._statFromRoot(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFile(
|
||||||
|
key: string,
|
||||||
|
content: ArrayBuffer,
|
||||||
|
mtime: number,
|
||||||
|
ctime: number
|
||||||
|
): Promise<Entity> {
|
||||||
|
if (key.endsWith("/")) {
|
||||||
|
throw Error(`you should not call writeFile on ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._init();
|
||||||
|
const uploadFile = getDropboxPath(key, this.remoteBaseDir);
|
||||||
|
|
||||||
|
return await this._writeFileFromRoot(
|
||||||
|
uploadFile,
|
||||||
|
content,
|
||||||
|
mtime,
|
||||||
|
ctime,
|
||||||
|
key
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _writeFileFromRoot(
|
||||||
|
key: string,
|
||||||
|
content: ArrayBuffer,
|
||||||
|
mtime: number,
|
||||||
|
ctime: number,
|
||||||
|
origKey: string
|
||||||
|
): Promise<Entity> {
|
||||||
|
if (hasEmojiInText(origKey)) {
|
||||||
|
throw new Error(
|
||||||
|
`${origKey}: Error: Dropbox does not support emoji in file / folder names.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mtimeFixed = Math.floor(mtime / 1000.0) * 1000;
|
||||||
|
const ctimeFixed = Math.floor(ctime / 1000.0) * 1000;
|
||||||
|
const mtimeStr = new Date(mtimeFixed)
|
||||||
|
.toISOString()
|
||||||
|
.replace(/\.\d{3}Z$/, "Z");
|
||||||
|
|
||||||
|
// in dropbox, we don't need to create folders before uploading! cool!
|
||||||
|
// TODO: filesUploadSession for larger files (>=150 MB)
|
||||||
|
|
||||||
|
await retryReq(
|
||||||
|
() =>
|
||||||
|
this.dropbox.filesUpload({
|
||||||
|
path: key,
|
||||||
|
contents: content,
|
||||||
|
mode: {
|
||||||
|
".tag": "overwrite",
|
||||||
|
},
|
||||||
|
client_modified: mtimeStr,
|
||||||
|
}),
|
||||||
|
origKey // hint
|
||||||
|
);
|
||||||
|
|
||||||
|
// we want to mark that parent folders are created
|
||||||
|
if (this.foldersCreatedBefore !== undefined) {
|
||||||
|
const dirs = getFolderLevels(origKey).map((x) =>
|
||||||
|
getDropboxPath(x, this.remoteBaseDir)
|
||||||
|
);
|
||||||
|
for (const dir of dirs) {
|
||||||
|
this.foldersCreatedBefore?.add(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await this._statFromRoot(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFile(key: string): Promise<ArrayBuffer> {
|
||||||
|
await this._init();
|
||||||
|
if (key.endsWith("/")) {
|
||||||
|
throw new Error(`you should not call readFile on folder ${key}`);
|
||||||
|
}
|
||||||
|
const downloadFile = getDropboxPath(key, this.remoteBaseDir);
|
||||||
|
return await this._readFileFromRoot(downloadFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _readFileFromRoot(key: string): Promise<ArrayBuffer> {
|
||||||
|
const rsp = await retryReq(
|
||||||
|
() =>
|
||||||
|
this.dropbox.filesDownload({
|
||||||
|
path: key,
|
||||||
|
}),
|
||||||
|
`downloadFromRemoteRaw=${key}`
|
||||||
|
);
|
||||||
|
if (rsp === undefined) {
|
||||||
|
throw Error(`unknown rsp from dropbox download: ${rsp}`);
|
||||||
|
}
|
||||||
|
if ((rsp.result as any).fileBlob !== undefined) {
|
||||||
|
// we get a Blob
|
||||||
|
const content = (rsp.result as any).fileBlob as Blob;
|
||||||
|
return await content.arrayBuffer();
|
||||||
|
} else if ((rsp.result as any).fileBinary !== undefined) {
|
||||||
|
// we get a Buffer
|
||||||
|
const content = (rsp.result as any).fileBinary as Buffer;
|
||||||
|
return bufferToArrayBuffer(content);
|
||||||
|
} else {
|
||||||
|
throw Error(`unknown rsp from dropbox download: ${rsp}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async rm(key: string): Promise<void> {
|
||||||
|
if (key === "/") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const remoteFileName = getDropboxPath(key, this.remoteBaseDir);
|
||||||
|
|
||||||
|
await this._init();
|
||||||
|
try {
|
||||||
|
await retryReq(
|
||||||
|
() =>
|
||||||
|
this.dropbox.filesDeleteV2({
|
||||||
|
path: remoteFileName,
|
||||||
|
}),
|
||||||
|
key // just a hint here
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("some error while deleting");
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkConnect(callbackFunc?: any): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this._init();
|
||||||
|
const results = await this._statFromRoot(`/${this.remoteBaseDir}`);
|
||||||
|
if (results === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.debug(err);
|
||||||
|
callbackFunc?.(err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserDisplayName() {
|
||||||
|
await this._init();
|
||||||
|
const acct = await this.dropbox.usersGetCurrentAccount();
|
||||||
|
return acct.result.name.display_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeAuth() {
|
||||||
|
try {
|
||||||
|
await this._init();
|
||||||
|
await this.dropbox.authTokenRevoke();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
import { CipherMethodType, Entity } from "./baseTypes";
|
||||||
|
import * as openssl from "./encryptOpenSSL";
|
||||||
|
import * as rclone from "./encryptRClone";
|
||||||
|
import { isVaildText } from "./misc";
|
||||||
|
|
||||||
|
import { FakeFs } from "./fsAll";
|
||||||
|
import cloneDeep from "lodash/cloneDeep";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* quick guess, no actual decryption here
|
||||||
|
* @param name
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
function isLikelyOpenSSLEncryptedName(name: string): boolean {
|
||||||
|
if (
|
||||||
|
name.startsWith(openssl.MAGIC_ENCRYPTED_PREFIX_BASE32) ||
|
||||||
|
name.startsWith(openssl.MAGIC_ENCRYPTED_PREFIX_BASE64URL)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* quick guess, no actual decryption here
|
||||||
|
* @param name
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
function isLikelyEncryptedName(name: string): boolean {
|
||||||
|
return isLikelyOpenSSLEncryptedName(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* quick guess, no actual decryption here, only openssl can be guessed here
|
||||||
|
* @param name
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
function isLikelyEncryptedNameNotMatchMethod(
|
||||||
|
name: string,
|
||||||
|
method: CipherMethodType
|
||||||
|
): boolean {
|
||||||
|
if (isLikelyOpenSSLEncryptedName(name) && method !== "openssl-base64") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!isLikelyOpenSSLEncryptedName(name) && method === "openssl-base64") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordCheckType {
|
||||||
|
ok: boolean;
|
||||||
|
reason:
|
||||||
|
| "empty_remote"
|
||||||
|
| "unknown_encryption_method"
|
||||||
|
| "remote_encrypted_local_no_password"
|
||||||
|
| "password_matched"
|
||||||
|
| "password_or_method_not_matched_or_remote_not_encrypted"
|
||||||
|
| "likely_no_password_both_sides"
|
||||||
|
| "encryption_method_not_matched";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Useful if isPasswordEmpty()
|
||||||
|
*/
|
||||||
|
function copyEntityAndCopyKeyEncSizeEnc(entity: Entity) {
|
||||||
|
const res = cloneDeep(entity);
|
||||||
|
res["keyEnc"] = res["keyRaw"];
|
||||||
|
res["sizeEnc"] = res["sizeRaw"];
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FakeFsEncrypt extends FakeFs {
|
||||||
|
innerFs: FakeFs;
|
||||||
|
readonly password: string;
|
||||||
|
readonly method: CipherMethodType;
|
||||||
|
cipherRClone?: rclone.CipherRclone;
|
||||||
|
cacheMapOrigToEnc: Record<string, string>;
|
||||||
|
hasCacheMap: boolean;
|
||||||
|
kind: string;
|
||||||
|
innerWalkResultCache?: Entity[];
|
||||||
|
innerWalkResultCacheTime?: number;
|
||||||
|
|
||||||
|
constructor(innerFs: FakeFs, password: string, method: CipherMethodType) {
|
||||||
|
super();
|
||||||
|
this.innerFs = innerFs;
|
||||||
|
this.password = password ?? "";
|
||||||
|
this.method = method;
|
||||||
|
this.cacheMapOrigToEnc = {};
|
||||||
|
this.hasCacheMap = false;
|
||||||
|
|
||||||
|
this.kind = `encrypt(${this.innerFs.kind},${method})`;
|
||||||
|
|
||||||
|
if (method === "rclone-base64") {
|
||||||
|
this.cipherRClone = new rclone.CipherRclone(password, 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isPasswordEmpty() {
|
||||||
|
return this.password === "";
|
||||||
|
}
|
||||||
|
|
||||||
|
isFolderAware() {
|
||||||
|
if (this.method === "openssl-base64") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.method === "rclone-base64") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw Error(`no idea about isFolderAware for method=${this.method}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* we want a little caching here.
|
||||||
|
*/
|
||||||
|
async _getInnerWalkResult(): Promise<Entity[]> {
|
||||||
|
let innerWalkResult: Entity[] | undefined = undefined;
|
||||||
|
if (
|
||||||
|
this.innerWalkResultCacheTime !== undefined &&
|
||||||
|
this.innerWalkResultCacheTime >= Date.now() - 1000
|
||||||
|
) {
|
||||||
|
innerWalkResult = this.innerWalkResultCache!;
|
||||||
|
} else {
|
||||||
|
innerWalkResult = await this.innerFs.walk();
|
||||||
|
this.innerWalkResultCache = innerWalkResult;
|
||||||
|
this.innerWalkResultCacheTime = Date.now();
|
||||||
|
}
|
||||||
|
return innerWalkResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
async isPasswordOk(): Promise<PasswordCheckType> {
|
||||||
|
const innerWalkResult = await this._getInnerWalkResult();
|
||||||
|
|
||||||
|
if (innerWalkResult === undefined || innerWalkResult.length === 0) {
|
||||||
|
// remote empty
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
reason: "empty_remote",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const santyCheckKey = innerWalkResult[0].keyRaw;
|
||||||
|
|
||||||
|
if (this.isPasswordEmpty()) {
|
||||||
|
// TODO: no way to distinguish remote rclone encrypted
|
||||||
|
// if local has no password??
|
||||||
|
if (isLikelyEncryptedName(santyCheckKey)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "remote_encrypted_local_no_password",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
reason: "likely_no_password_both_sides",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (this.method === "unknown") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "unknown_encryption_method",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (isLikelyEncryptedNameNotMatchMethod(santyCheckKey, this.method)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "encryption_method_not_matched",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const k = await this._decryptName(santyCheckKey);
|
||||||
|
if (k === undefined) {
|
||||||
|
throw Error(`decryption failed`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
reason: "password_matched",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "password_or_method_not_matched_or_remote_not_encrypted",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async walk(): Promise<Entity[]> {
|
||||||
|
const innerWalkResult = await this._getInnerWalkResult();
|
||||||
|
|
||||||
|
const res: Entity[] = [];
|
||||||
|
|
||||||
|
if (this.isPasswordEmpty()) {
|
||||||
|
for (const innerEntity of innerWalkResult) {
|
||||||
|
res.push(copyEntityAndCopyKeyEncSizeEnc(innerEntity));
|
||||||
|
this.cacheMapOrigToEnc[innerEntity.key!] = innerEntity.key!;
|
||||||
|
}
|
||||||
|
this.hasCacheMap = true;
|
||||||
|
return res;
|
||||||
|
} else {
|
||||||
|
for (const innerEntity of innerWalkResult) {
|
||||||
|
const key = await this._decryptName(innerEntity.keyRaw);
|
||||||
|
const size = key.endsWith("/") ? 0 : undefined;
|
||||||
|
res.push({
|
||||||
|
key: key,
|
||||||
|
keyRaw: innerEntity.keyRaw,
|
||||||
|
keyEnc: innerEntity.key!,
|
||||||
|
mtimeCli: innerEntity.mtimeCli,
|
||||||
|
mtimeSvr: innerEntity.mtimeSvr,
|
||||||
|
size: size,
|
||||||
|
sizeEnc: innerEntity.size!,
|
||||||
|
sizeRaw: innerEntity.sizeRaw,
|
||||||
|
hash: undefined,
|
||||||
|
synthesizedFolder: innerEntity.synthesizedFolder,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.cacheMapOrigToEnc[key] = innerEntity.keyRaw;
|
||||||
|
}
|
||||||
|
this.hasCacheMap = true;
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stat(key: string): Promise<Entity> {
|
||||||
|
if (!this.hasCacheMap) {
|
||||||
|
throw new Error("You have to build the cacheMap firstly for stat");
|
||||||
|
}
|
||||||
|
const keyEnc = this.cacheMapOrigToEnc[key];
|
||||||
|
if (keyEnc === undefined) {
|
||||||
|
throw new Error(`no encrypted key ${key} before!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const innerEntity = await this.innerFs.stat(keyEnc);
|
||||||
|
if (this.isPasswordEmpty()) {
|
||||||
|
return copyEntityAndCopyKeyEncSizeEnc(innerEntity);
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
key: key,
|
||||||
|
keyRaw: innerEntity.keyRaw,
|
||||||
|
keyEnc: innerEntity.key!,
|
||||||
|
mtimeCli: innerEntity.mtimeCli,
|
||||||
|
mtimeSvr: innerEntity.mtimeSvr,
|
||||||
|
size: undefined,
|
||||||
|
sizeEnc: innerEntity.size!,
|
||||||
|
sizeRaw: innerEntity.sizeRaw,
|
||||||
|
hash: undefined,
|
||||||
|
synthesizedFolder: innerEntity.synthesizedFolder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async mkdir(key: string, mtime?: number, ctime?: number): Promise<Entity> {
|
||||||
|
if (!this.hasCacheMap) {
|
||||||
|
throw new Error("You have to build the cacheMap firstly for mkdir");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!key.endsWith("/")) {
|
||||||
|
throw new Error(`should not call mkdir on ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let keyEnc = this.cacheMapOrigToEnc[key];
|
||||||
|
if (keyEnc === undefined) {
|
||||||
|
if (this.isPasswordEmpty()) {
|
||||||
|
keyEnc = key;
|
||||||
|
} else {
|
||||||
|
keyEnc = await this._encryptName(key);
|
||||||
|
}
|
||||||
|
this.cacheMapOrigToEnc[key] = keyEnc;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isPasswordEmpty() || this.isFolderAware()) {
|
||||||
|
const innerEntity = await this.innerFs.mkdir(keyEnc, mtime, ctime);
|
||||||
|
return copyEntityAndCopyKeyEncSizeEnc(innerEntity);
|
||||||
|
} else {
|
||||||
|
const now = Date.now();
|
||||||
|
const innerEntity = await this.innerFs.writeFile(
|
||||||
|
keyEnc,
|
||||||
|
new ArrayBuffer(0),
|
||||||
|
mtime ?? now,
|
||||||
|
ctime ?? now
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
key: key,
|
||||||
|
keyRaw: innerEntity.keyRaw,
|
||||||
|
keyEnc: innerEntity.key!,
|
||||||
|
mtimeCli: innerEntity.mtimeCli,
|
||||||
|
mtimeSvr: innerEntity.mtimeSvr,
|
||||||
|
size: 0,
|
||||||
|
sizeEnc: innerEntity.size!,
|
||||||
|
sizeRaw: innerEntity.sizeRaw,
|
||||||
|
hash: undefined,
|
||||||
|
synthesizedFolder: innerEntity.synthesizedFolder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFile(
|
||||||
|
key: string,
|
||||||
|
content: ArrayBuffer,
|
||||||
|
mtime: number,
|
||||||
|
ctime: number
|
||||||
|
): Promise<Entity> {
|
||||||
|
if (!this.hasCacheMap) {
|
||||||
|
throw new Error("You have to build the cacheMap firstly for readFile");
|
||||||
|
}
|
||||||
|
let keyEnc = this.cacheMapOrigToEnc[key];
|
||||||
|
if (keyEnc === undefined) {
|
||||||
|
if (this.isPasswordEmpty()) {
|
||||||
|
keyEnc = key;
|
||||||
|
} else {
|
||||||
|
keyEnc = await this._encryptName(key);
|
||||||
|
}
|
||||||
|
this.cacheMapOrigToEnc[key] = keyEnc;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isPasswordEmpty()) {
|
||||||
|
const innerEntity = await this.innerFs.writeFile(
|
||||||
|
keyEnc,
|
||||||
|
content,
|
||||||
|
mtime,
|
||||||
|
ctime
|
||||||
|
);
|
||||||
|
return copyEntityAndCopyKeyEncSizeEnc(innerEntity);
|
||||||
|
} else {
|
||||||
|
const contentEnc = await this._encryptContent(content);
|
||||||
|
const innerEntity = await this.innerFs.writeFile(
|
||||||
|
keyEnc,
|
||||||
|
contentEnc,
|
||||||
|
mtime,
|
||||||
|
ctime
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
key: key,
|
||||||
|
keyRaw: innerEntity.keyRaw,
|
||||||
|
keyEnc: innerEntity.key!,
|
||||||
|
mtimeCli: innerEntity.mtimeCli,
|
||||||
|
mtimeSvr: innerEntity.mtimeSvr,
|
||||||
|
size: undefined,
|
||||||
|
sizeEnc: innerEntity.size!,
|
||||||
|
sizeRaw: innerEntity.sizeRaw,
|
||||||
|
hash: undefined,
|
||||||
|
synthesizedFolder: innerEntity.synthesizedFolder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFile(key: string): Promise<ArrayBuffer> {
|
||||||
|
if (!this.hasCacheMap) {
|
||||||
|
throw new Error("You have to build the cacheMap firstly for readFile");
|
||||||
|
}
|
||||||
|
const keyEnc = this.cacheMapOrigToEnc[key];
|
||||||
|
if (keyEnc === undefined) {
|
||||||
|
throw new Error(`no encrypted key ${key} before! cannot readFile`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentEnc = await this.innerFs.readFile(keyEnc);
|
||||||
|
if (this.isPasswordEmpty()) {
|
||||||
|
return contentEnc;
|
||||||
|
} else {
|
||||||
|
const res = await this._decryptContent(contentEnc);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async rm(key: string): Promise<void> {
|
||||||
|
if (!this.hasCacheMap) {
|
||||||
|
throw new Error("You have to build the cacheMap firstly for rm");
|
||||||
|
}
|
||||||
|
const keyEnc = this.cacheMapOrigToEnc[key];
|
||||||
|
if (keyEnc === undefined) {
|
||||||
|
throw new Error(`no encrypted key ${key} before! cannot rm`);
|
||||||
|
}
|
||||||
|
return await this.innerFs.rm(keyEnc);
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkConnect(callbackFunc?: any): Promise<boolean> {
|
||||||
|
return await this.innerFs.checkConnect(callbackFunc);
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeResources() {
|
||||||
|
if (this.method === "rclone-base64" && this.cipherRClone !== undefined) {
|
||||||
|
this.cipherRClone.closeResources();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async encryptEntity(input: Entity): Promise<Entity> {
|
||||||
|
if (input.key === undefined) {
|
||||||
|
// input.key should always have value
|
||||||
|
throw Error(`input ${input.keyRaw} is abnormal without key`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isPasswordEmpty()) {
|
||||||
|
return copyEntityAndCopyKeyEncSizeEnc(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
// below is for having password
|
||||||
|
const local = cloneDeep(input);
|
||||||
|
if (local.sizeEnc === undefined && local.size !== undefined) {
|
||||||
|
// it's not filled yet, we fill it
|
||||||
|
// local.size is possibly undefined if it's "prevSync" Entity
|
||||||
|
// but local.key should always have value
|
||||||
|
local.sizeEnc = this._getSizeFromOrigToEnc(local.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (local.keyEnc === undefined || local.keyEnc === "") {
|
||||||
|
let keyEnc = this.cacheMapOrigToEnc[input.key];
|
||||||
|
if (keyEnc !== undefined && keyEnc !== "" && keyEnc !== local.key) {
|
||||||
|
// we can reuse remote encrypted key if any
|
||||||
|
local.keyEnc = keyEnc;
|
||||||
|
} else {
|
||||||
|
// we assign a new encrypted key because of no remote
|
||||||
|
keyEnc = await this._encryptName(input.key);
|
||||||
|
local.keyEnc = keyEnc;
|
||||||
|
// remember to add back to cache!
|
||||||
|
this.cacheMapOrigToEnc[input.key] = keyEnc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return local;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _encryptContent(content: ArrayBuffer) {
|
||||||
|
// console.debug("start encryptContent");
|
||||||
|
if (this.password === "") {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
if (this.method === "openssl-base64") {
|
||||||
|
const res = await openssl.encryptArrayBuffer(content, this.password);
|
||||||
|
if (res === undefined) {
|
||||||
|
throw Error(`cannot encrypt content`);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
} else if (this.method === "rclone-base64") {
|
||||||
|
const res =
|
||||||
|
await this.cipherRClone!.encryptContentByCallingWorker(content);
|
||||||
|
if (res === undefined) {
|
||||||
|
throw Error(`cannot encrypt content`);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
} else {
|
||||||
|
throw Error(`not supported encrypt method=${this.method}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _decryptContent(content: ArrayBuffer) {
|
||||||
|
// console.debug("start decryptContent");
|
||||||
|
if (this.password === "") {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
if (this.method === "openssl-base64") {
|
||||||
|
const res = await openssl.decryptArrayBuffer(content, this.password);
|
||||||
|
if (res === undefined) {
|
||||||
|
throw Error(`cannot decrypt content`);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
} else if (this.method === "rclone-base64") {
|
||||||
|
const res =
|
||||||
|
await this.cipherRClone!.decryptContentByCallingWorker(content);
|
||||||
|
if (res === undefined) {
|
||||||
|
throw Error(`cannot decrypt content`);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
} else {
|
||||||
|
throw Error(`not supported decrypt method=${this.method}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _encryptName(name: string) {
|
||||||
|
// console.debug("start encryptName");
|
||||||
|
if (this.password === "") {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
if (this.method === "openssl-base64") {
|
||||||
|
const res = await openssl.encryptStringToBase64url(name, this.password);
|
||||||
|
if (res === undefined) {
|
||||||
|
throw Error(`cannot encrypt name=${name}`);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
} else if (this.method === "rclone-base64") {
|
||||||
|
const res = await this.cipherRClone!.encryptNameByCallingWorker(name);
|
||||||
|
if (res === undefined) {
|
||||||
|
throw Error(`cannot encrypt name=${name}`);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
} else {
|
||||||
|
throw Error(`not supported encrypt method=${this.method}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _decryptName(name: string): Promise<string> {
|
||||||
|
// console.debug("start decryptName");
|
||||||
|
if (this.password === "") {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
if (this.method === "openssl-base64") {
|
||||||
|
if (name.startsWith(openssl.MAGIC_ENCRYPTED_PREFIX_BASE32)) {
|
||||||
|
// backward compitable with the openssl-base32
|
||||||
|
try {
|
||||||
|
const res = await openssl.decryptBase32ToString(name, this.password);
|
||||||
|
if (res !== undefined && isVaildText(res)) {
|
||||||
|
return res;
|
||||||
|
} else {
|
||||||
|
throw Error(`cannot decrypt name=${name}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw Error(`cannot decrypt name=${name}`);
|
||||||
|
}
|
||||||
|
} else if (name.startsWith(openssl.MAGIC_ENCRYPTED_PREFIX_BASE64URL)) {
|
||||||
|
try {
|
||||||
|
const res = await openssl.decryptBase64urlToString(
|
||||||
|
name,
|
||||||
|
this.password
|
||||||
|
);
|
||||||
|
if (res !== undefined && isVaildText(res)) {
|
||||||
|
return res;
|
||||||
|
} else {
|
||||||
|
throw Error(`cannot decrypt name=${name}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw Error(`cannot decrypt name=${name}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw Error(
|
||||||
|
`method=${this.method} but the name=${name}, likely mismatch`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (this.method === "rclone-base64") {
|
||||||
|
const res = await this.cipherRClone!.decryptNameByCallingWorker(name);
|
||||||
|
if (res === undefined) {
|
||||||
|
throw Error(`cannot decrypt name=${name}`);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
} else {
|
||||||
|
throw Error(`not supported decrypt method=${this.method}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_getSizeFromOrigToEnc(x: number) {
|
||||||
|
if (this.password === "") {
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
if (this.method === "openssl-base64") {
|
||||||
|
return openssl.getSizeFromOrigToEnc(x);
|
||||||
|
} else if (this.method === "rclone-base64") {
|
||||||
|
return rclone.getSizeFromOrigToEnc(x);
|
||||||
|
} else {
|
||||||
|
throw Error(`not supported encrypt method=${this.method}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserDisplayName(): Promise<string> {
|
||||||
|
return await this.innerFs.getUserDisplayName();
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeAuth(): Promise<any> {
|
||||||
|
return await this.innerFs.revokeAuth();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { RemotelySavePluginSettings } from "./baseTypes";
|
||||||
|
import { FakeFs } from "./fsAll";
|
||||||
|
import { FakeFsDropbox } from "./fsDropbox";
|
||||||
|
import { FakeFsOnedrive } from "./fsOnedrive";
|
||||||
|
import { FakeFsS3 } from "./fsS3";
|
||||||
|
import { FakeFsWebdav } from "./fsWebdav";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To avoid circular dependency, we need a new file here.
|
||||||
|
*/
|
||||||
|
export function getClient(
|
||||||
|
settings: RemotelySavePluginSettings,
|
||||||
|
vaultName: string,
|
||||||
|
saveUpdatedConfigFunc: () => Promise<any>
|
||||||
|
): FakeFs {
|
||||||
|
switch (settings.serviceType) {
|
||||||
|
case "s3":
|
||||||
|
return new FakeFsS3(settings.s3);
|
||||||
|
break;
|
||||||
|
case "webdav":
|
||||||
|
return new FakeFsWebdav(
|
||||||
|
settings.webdav,
|
||||||
|
vaultName,
|
||||||
|
saveUpdatedConfigFunc
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "dropbox":
|
||||||
|
return new FakeFsDropbox(
|
||||||
|
settings.dropbox,
|
||||||
|
vaultName,
|
||||||
|
saveUpdatedConfigFunc
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "onedrive":
|
||||||
|
return new FakeFsOnedrive(
|
||||||
|
settings.onedrive,
|
||||||
|
vaultName,
|
||||||
|
saveUpdatedConfigFunc
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw Error(`cannot init client for serviceType=${settings.serviceType}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
import { DEFAULT_DEBUG_FOLDER, Entity } from "./baseTypes";
|
||||||
|
import { FakeFs } from "./fsAll";
|
||||||
|
|
||||||
|
import { TFile, TFolder, type Vault } from "obsidian";
|
||||||
|
import { listFilesInObsFolder } from "./obsFolderLister";
|
||||||
|
import { Profiler } from "./profiler";
|
||||||
|
import { getFolderLevels, mkdirpInVault, statFix } from "./misc";
|
||||||
|
|
||||||
|
export class FakeFsLocal extends FakeFs {
|
||||||
|
vault: Vault;
|
||||||
|
syncConfigDir: boolean;
|
||||||
|
configDir: string;
|
||||||
|
pluginID: string;
|
||||||
|
profiler: Profiler;
|
||||||
|
deleteToWhere: "obsidian" | "system";
|
||||||
|
kind: "local";
|
||||||
|
constructor(
|
||||||
|
vault: Vault,
|
||||||
|
syncConfigDir: boolean,
|
||||||
|
configDir: string,
|
||||||
|
pluginID: string,
|
||||||
|
profiler: Profiler,
|
||||||
|
deleteToWhere: "obsidian" | "system"
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.vault = vault;
|
||||||
|
this.syncConfigDir = syncConfigDir;
|
||||||
|
this.configDir = configDir;
|
||||||
|
this.pluginID = pluginID;
|
||||||
|
this.profiler = profiler;
|
||||||
|
this.deleteToWhere = deleteToWhere;
|
||||||
|
this.kind = "local";
|
||||||
|
}
|
||||||
|
|
||||||
|
async walk(): Promise<Entity[]> {
|
||||||
|
this.profiler.addIndent();
|
||||||
|
this.profiler.insert("enter walk for local");
|
||||||
|
const local: Entity[] = [];
|
||||||
|
|
||||||
|
const localTAbstractFiles = this.vault.getAllLoadedFiles();
|
||||||
|
this.profiler.insert("finish getting walk for local");
|
||||||
|
for (const entry of localTAbstractFiles) {
|
||||||
|
let r: Entity | undefined = undefined;
|
||||||
|
let key = entry.path;
|
||||||
|
|
||||||
|
if (entry.path === "/") {
|
||||||
|
// ignore
|
||||||
|
continue;
|
||||||
|
} else if (entry instanceof TFile) {
|
||||||
|
let mtimeLocal: number | undefined = entry.stat.mtime;
|
||||||
|
if (mtimeLocal <= 0) {
|
||||||
|
mtimeLocal = entry.stat.ctime;
|
||||||
|
}
|
||||||
|
if (mtimeLocal === 0) {
|
||||||
|
mtimeLocal = undefined;
|
||||||
|
}
|
||||||
|
if (mtimeLocal === undefined) {
|
||||||
|
throw Error(
|
||||||
|
`Your file has last modified time 0: ${key}, don't know how to deal with it`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
r = {
|
||||||
|
key: entry.path, // local always unencrypted
|
||||||
|
keyRaw: entry.path,
|
||||||
|
mtimeCli: mtimeLocal,
|
||||||
|
mtimeSvr: mtimeLocal,
|
||||||
|
size: entry.stat.size, // local always unencrypted
|
||||||
|
sizeRaw: entry.stat.size,
|
||||||
|
};
|
||||||
|
} else if (entry instanceof TFolder) {
|
||||||
|
key = `${entry.path}/`;
|
||||||
|
r = {
|
||||||
|
key: key,
|
||||||
|
keyRaw: key,
|
||||||
|
size: 0,
|
||||||
|
sizeRaw: 0,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw Error(`unexpected ${entry}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.keyRaw.startsWith(DEFAULT_DEBUG_FOLDER)) {
|
||||||
|
// skip listing the debug folder,
|
||||||
|
// which should always not involved in sync
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
local.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.profiler.insert("finish transforming walk for local");
|
||||||
|
|
||||||
|
if (this.syncConfigDir) {
|
||||||
|
this.profiler.insert("into syncConfigDir");
|
||||||
|
const syncFiles = await listFilesInObsFolder(
|
||||||
|
this.configDir,
|
||||||
|
this.vault,
|
||||||
|
this.pluginID
|
||||||
|
);
|
||||||
|
for (const f of syncFiles) {
|
||||||
|
local.push(f);
|
||||||
|
}
|
||||||
|
this.profiler.insert("finish syncConfigDir");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.profiler.insert("finish walk for local");
|
||||||
|
this.profiler.removeIndent();
|
||||||
|
return local;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stat(key: string): Promise<Entity> {
|
||||||
|
const statRes = await statFix(this.vault, key);
|
||||||
|
if (statRes === undefined || statRes === null) {
|
||||||
|
throw Error(`${key} does not exist! cannot stat for local`);
|
||||||
|
}
|
||||||
|
const isFolder = statRes.type === "folder";
|
||||||
|
return {
|
||||||
|
key: isFolder ? `${key}/` : key, // local always unencrypted
|
||||||
|
keyRaw: isFolder ? `${key}/` : key,
|
||||||
|
mtimeCli: statRes.mtime,
|
||||||
|
mtimeSvr: statRes.mtime,
|
||||||
|
size: statRes.size, // local always unencrypted
|
||||||
|
sizeRaw: statRes.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async mkdir(key: string, mtime?: number, ctime?: number): Promise<Entity> {
|
||||||
|
// console.debug(`mkdir: ${key}`);
|
||||||
|
await mkdirpInVault(key, this.vault);
|
||||||
|
return await this.stat(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFile(
|
||||||
|
key: string,
|
||||||
|
content: ArrayBuffer,
|
||||||
|
mtime: number,
|
||||||
|
ctime: number
|
||||||
|
): Promise<Entity> {
|
||||||
|
await this.vault.adapter.writeBinary(key, content, {
|
||||||
|
mtime: mtime,
|
||||||
|
});
|
||||||
|
return await this.stat(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFile(key: string): Promise<ArrayBuffer> {
|
||||||
|
return await this.vault.adapter.readBinary(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async rm(key: string): Promise<void> {
|
||||||
|
if (this.deleteToWhere === "obsidian") {
|
||||||
|
await this.vault.adapter.trashLocal(key);
|
||||||
|
} else {
|
||||||
|
// "system"
|
||||||
|
if (!(await this.vault.adapter.trashSystem(key))) {
|
||||||
|
await this.vault.adapter.trashLocal(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async checkConnect(callbackFunc?: any): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserDisplayName(): Promise<string> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeAuth(): Promise<any> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Entity } from "./baseTypes";
|
||||||
|
import { FakeFs } from "./fsAll";
|
||||||
|
|
||||||
|
export class FakeFsMock extends FakeFs {
|
||||||
|
kind: "mock";
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.kind = "mock";
|
||||||
|
}
|
||||||
|
|
||||||
|
async walk(): Promise<Entity[]> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async stat(key: string): Promise<Entity> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async mkdir(key: string, mtime: number, ctime: number): Promise<Entity> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFile(
|
||||||
|
key: string,
|
||||||
|
content: ArrayBuffer,
|
||||||
|
mtime: number,
|
||||||
|
ctime: number
|
||||||
|
): Promise<Entity> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFile(key: string): Promise<ArrayBuffer> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async rm(key: string): Promise<void> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkConnect(callbackFunc?: any): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserDisplayName(): Promise<string> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeAuth(): Promise<any> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+812
@@ -0,0 +1,812 @@
|
|||||||
|
import type { _Object, PutObjectCommandInput } from "@aws-sdk/client-s3";
|
||||||
|
import {
|
||||||
|
DeleteObjectCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
HeadObjectCommand,
|
||||||
|
HeadObjectCommandOutput,
|
||||||
|
ListObjectsV2Command,
|
||||||
|
ListObjectsV2CommandInput,
|
||||||
|
PutObjectCommand,
|
||||||
|
S3Client,
|
||||||
|
} from "@aws-sdk/client-s3";
|
||||||
|
import { Upload } from "@aws-sdk/lib-storage";
|
||||||
|
import { HttpRequest, HttpResponse } from "@smithy/protocol-http";
|
||||||
|
import {
|
||||||
|
FetchHttpHandler,
|
||||||
|
FetchHttpHandlerOptions,
|
||||||
|
} from "@smithy/fetch-http-handler";
|
||||||
|
// @ts-ignore
|
||||||
|
import { requestTimeout } from "@smithy/fetch-http-handler/dist-es/request-timeout";
|
||||||
|
import { buildQueryString } from "@smithy/querystring-builder";
|
||||||
|
import { HttpHandlerOptions } from "@aws-sdk/types";
|
||||||
|
import { Buffer } from "buffer";
|
||||||
|
import * as mime from "mime-types";
|
||||||
|
import { Platform, requestUrl, RequestUrlParam } from "obsidian";
|
||||||
|
import { Readable } from "stream";
|
||||||
|
import * as path from "path";
|
||||||
|
import AggregateError from "aggregate-error";
|
||||||
|
import { DEFAULT_CONTENT_TYPE, S3Config, VALID_REQURL } from "./baseTypes";
|
||||||
|
import { bufferToArrayBuffer, getFolderLevels } from "./misc";
|
||||||
|
import PQueue from "p-queue";
|
||||||
|
|
||||||
|
import { Entity } from "./baseTypes";
|
||||||
|
import { FakeFs } from "./fsAll";
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// special handler using Obsidian requestUrl
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is close to origin implementation of FetchHttpHandler
|
||||||
|
* https://github.com/aws/aws-sdk-js-v3/blob/main/packages/fetch-http-handler/src/fetch-http-handler.ts
|
||||||
|
* that is released under Apache 2 License.
|
||||||
|
* But this uses Obsidian requestUrl instead.
|
||||||
|
*/
|
||||||
|
class ObsHttpHandler extends FetchHttpHandler {
|
||||||
|
requestTimeoutInMs: number | undefined;
|
||||||
|
reverseProxyNoSignUrl: string | undefined;
|
||||||
|
constructor(
|
||||||
|
options?: FetchHttpHandlerOptions,
|
||||||
|
reverseProxyNoSignUrl?: string
|
||||||
|
) {
|
||||||
|
super(options);
|
||||||
|
this.requestTimeoutInMs =
|
||||||
|
options === undefined ? undefined : options.requestTimeout;
|
||||||
|
this.reverseProxyNoSignUrl = reverseProxyNoSignUrl;
|
||||||
|
}
|
||||||
|
async handle(
|
||||||
|
request: HttpRequest,
|
||||||
|
{ abortSignal }: HttpHandlerOptions = {}
|
||||||
|
): Promise<{ response: HttpResponse }> {
|
||||||
|
if (abortSignal?.aborted) {
|
||||||
|
const abortError = new Error("Request aborted");
|
||||||
|
abortError.name = "AbortError";
|
||||||
|
return Promise.reject(abortError);
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = request.path;
|
||||||
|
if (request.query) {
|
||||||
|
const queryString = buildQueryString(request.query);
|
||||||
|
if (queryString) {
|
||||||
|
path += `?${queryString}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { port, method } = request;
|
||||||
|
let url = `${request.protocol}//${request.hostname}${
|
||||||
|
port ? `:${port}` : ""
|
||||||
|
}${path}`;
|
||||||
|
if (
|
||||||
|
this.reverseProxyNoSignUrl !== undefined &&
|
||||||
|
this.reverseProxyNoSignUrl !== ""
|
||||||
|
) {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
urlObj.host = this.reverseProxyNoSignUrl;
|
||||||
|
url = urlObj.href;
|
||||||
|
}
|
||||||
|
const body =
|
||||||
|
method === "GET" || method === "HEAD" ? undefined : request.body;
|
||||||
|
|
||||||
|
const transformedHeaders: Record<string, string> = {};
|
||||||
|
for (const key of Object.keys(request.headers)) {
|
||||||
|
const keyLower = key.toLowerCase();
|
||||||
|
if (keyLower === "host" || keyLower === "content-length") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
transformedHeaders[keyLower] = request.headers[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
let contentType: string | undefined = undefined;
|
||||||
|
if (transformedHeaders["content-type"] !== undefined) {
|
||||||
|
contentType = transformedHeaders["content-type"];
|
||||||
|
}
|
||||||
|
|
||||||
|
let transformedBody: any = body;
|
||||||
|
if (ArrayBuffer.isView(body)) {
|
||||||
|
transformedBody = bufferToArrayBuffer(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const param: RequestUrlParam = {
|
||||||
|
body: transformedBody,
|
||||||
|
headers: transformedHeaders,
|
||||||
|
method: method,
|
||||||
|
url: url,
|
||||||
|
contentType: contentType,
|
||||||
|
};
|
||||||
|
|
||||||
|
const raceOfPromises = [
|
||||||
|
requestUrl(param).then((rsp) => {
|
||||||
|
const headers = rsp.headers;
|
||||||
|
const headersLower: Record<string, string> = {};
|
||||||
|
for (const key of Object.keys(headers)) {
|
||||||
|
headersLower[key.toLowerCase()] = headers[key];
|
||||||
|
}
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(new Uint8Array(rsp.arrayBuffer));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
response: new HttpResponse({
|
||||||
|
headers: headersLower,
|
||||||
|
statusCode: rsp.status,
|
||||||
|
body: stream,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
requestTimeout(this.requestTimeoutInMs),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (abortSignal) {
|
||||||
|
raceOfPromises.push(
|
||||||
|
new Promise<never>((resolve, reject) => {
|
||||||
|
abortSignal.onabort = () => {
|
||||||
|
const abortError = new Error("Request aborted");
|
||||||
|
abortError.name = "AbortError";
|
||||||
|
reject(abortError);
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.race(raceOfPromises);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// other stuffs
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
export const simpleTransRemotePrefix = (x: string) => {
|
||||||
|
if (x === undefined) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
let y = path.posix.normalize(x.trim());
|
||||||
|
if (y === undefined || y === "" || y === "/" || y === ".") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (y.startsWith("/")) {
|
||||||
|
y = y.slice(1);
|
||||||
|
}
|
||||||
|
if (!y.endsWith("/")) {
|
||||||
|
y = `${y}/`;
|
||||||
|
}
|
||||||
|
return y;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_S3_CONFIG: S3Config = {
|
||||||
|
s3Endpoint: "",
|
||||||
|
s3Region: "",
|
||||||
|
s3AccessKeyID: "",
|
||||||
|
s3SecretAccessKey: "",
|
||||||
|
s3BucketName: "",
|
||||||
|
bypassCorsLocally: true,
|
||||||
|
partsConcurrency: 20,
|
||||||
|
forcePathStyle: false,
|
||||||
|
remotePrefix: "",
|
||||||
|
useAccurateMTime: false, // it causes money, disable by default
|
||||||
|
reverseProxyNoSignUrl: "",
|
||||||
|
generateFolderObject: false, // new version, by default not generate folders
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Body of resp of aws GetObject has mix types
|
||||||
|
* and we want to get ArrayBuffer here.
|
||||||
|
* See https://github.com/aws/aws-sdk-js-v3/issues/1877
|
||||||
|
* @param b The Body of GetObject
|
||||||
|
* @returns Promise<ArrayBuffer>
|
||||||
|
*/
|
||||||
|
const getObjectBodyToArrayBuffer = async (
|
||||||
|
b: Readable | ReadableStream | Blob | undefined
|
||||||
|
) => {
|
||||||
|
if (b === undefined) {
|
||||||
|
throw Error(`ObjectBody is undefined and don't know how to deal with it`);
|
||||||
|
}
|
||||||
|
if (b instanceof Readable) {
|
||||||
|
return (await new Promise((resolve, reject) => {
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
b.on("data", (chunk) => chunks.push(chunk));
|
||||||
|
b.on("error", reject);
|
||||||
|
b.on("end", () => resolve(bufferToArrayBuffer(Buffer.concat(chunks))));
|
||||||
|
})) as ArrayBuffer;
|
||||||
|
} else if (b instanceof ReadableStream) {
|
||||||
|
return await new Response(b, {}).arrayBuffer();
|
||||||
|
} else if (b instanceof Blob) {
|
||||||
|
return await b.arrayBuffer();
|
||||||
|
} else {
|
||||||
|
throw TypeError(`The type of ${b} is not one of the supported types`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getS3Client = (s3Config: S3Config) => {
|
||||||
|
let endpoint = s3Config.s3Endpoint;
|
||||||
|
if (!(endpoint.startsWith("http://") || endpoint.startsWith("https://"))) {
|
||||||
|
endpoint = `https://${endpoint}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let s3Client: S3Client;
|
||||||
|
if (VALID_REQURL && s3Config.bypassCorsLocally) {
|
||||||
|
s3Client = new S3Client({
|
||||||
|
region: s3Config.s3Region,
|
||||||
|
endpoint: endpoint,
|
||||||
|
forcePathStyle: s3Config.forcePathStyle,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: s3Config.s3AccessKeyID,
|
||||||
|
secretAccessKey: s3Config.s3SecretAccessKey,
|
||||||
|
},
|
||||||
|
requestHandler: new ObsHttpHandler(
|
||||||
|
undefined,
|
||||||
|
s3Config.reverseProxyNoSignUrl
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
s3Client = new S3Client({
|
||||||
|
region: s3Config.s3Region,
|
||||||
|
endpoint: endpoint,
|
||||||
|
forcePathStyle: s3Config.forcePathStyle,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: s3Config.s3AccessKeyID,
|
||||||
|
secretAccessKey: s3Config.s3SecretAccessKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
s3Client.middlewareStack.add(
|
||||||
|
(next, context) => (args) => {
|
||||||
|
(args.request as any).headers["cache-control"] = "no-cache";
|
||||||
|
return next(args);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: "build",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return s3Client;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLocalNoPrefixPath = (
|
||||||
|
fileOrFolderPathWithRemotePrefix: string,
|
||||||
|
remotePrefix: string
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
fileOrFolderPathWithRemotePrefix === `${remotePrefix}` ||
|
||||||
|
fileOrFolderPathWithRemotePrefix.startsWith(`${remotePrefix}`)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw Error(
|
||||||
|
`"${fileOrFolderPathWithRemotePrefix}" doesn't starts with "${remotePrefix}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return fileOrFolderPathWithRemotePrefix.slice(`${remotePrefix}`.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRemoteWithPrefixPath = (
|
||||||
|
fileOrFolderPath: string,
|
||||||
|
remotePrefix: string
|
||||||
|
) => {
|
||||||
|
if (remotePrefix === undefined || remotePrefix === "") {
|
||||||
|
return fileOrFolderPath;
|
||||||
|
}
|
||||||
|
let key = fileOrFolderPath;
|
||||||
|
if (fileOrFolderPath === "/" || fileOrFolderPath === "") {
|
||||||
|
// special
|
||||||
|
key = remotePrefix;
|
||||||
|
}
|
||||||
|
if (!fileOrFolderPath.startsWith("/")) {
|
||||||
|
key = `${remotePrefix}${fileOrFolderPath}`;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fromS3ObjectToEntity = (
|
||||||
|
x: _Object,
|
||||||
|
remotePrefix: string,
|
||||||
|
mtimeRecords: Record<string, number>,
|
||||||
|
ctimeRecords: Record<string, number>
|
||||||
|
) => {
|
||||||
|
// console.debug(`fromS3ObjectToEntity: ${x.Key!}, ${JSON.stringify(x,null,2)}`);
|
||||||
|
// S3 officially only supports seconds precision!!!!!
|
||||||
|
const mtimeSvr = Math.floor(x.LastModified!.valueOf() / 1000.0) * 1000;
|
||||||
|
let mtimeCli = mtimeSvr;
|
||||||
|
if (x.Key! in mtimeRecords) {
|
||||||
|
const m2 = mtimeRecords[x.Key!];
|
||||||
|
if (m2 !== 0) {
|
||||||
|
// to be compatible with RClone, we read and store the time in seconds in new version!
|
||||||
|
if (m2 >= 1000000000000) {
|
||||||
|
// it's a millsecond, uploaded by old codes..
|
||||||
|
mtimeCli = m2;
|
||||||
|
} else {
|
||||||
|
// it's a second, uploaded by new codes of the plugin from March 24, 2024
|
||||||
|
mtimeCli = m2 * 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const key = getLocalNoPrefixPath(x.Key!, remotePrefix); // we remove prefix here
|
||||||
|
const r: Entity = {
|
||||||
|
key: key, // from s3's repsective, the keyRaw is the key, we will change it in decyption
|
||||||
|
keyRaw: key,
|
||||||
|
mtimeSvr: mtimeSvr,
|
||||||
|
mtimeCli: mtimeCli,
|
||||||
|
sizeRaw: x.Size!,
|
||||||
|
size: x.Size!, // from s3's repsective, the sizeRaw is the size, we will change it in decyption
|
||||||
|
etag: x.ETag,
|
||||||
|
synthesizedFolder: false,
|
||||||
|
};
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fromS3HeadObjectToEntity = (
|
||||||
|
fileOrFolderPathWithRemotePrefix: string,
|
||||||
|
x: HeadObjectCommandOutput,
|
||||||
|
remotePrefix: string
|
||||||
|
) => {
|
||||||
|
// console.debug(`fromS3HeadObjectToEntity: ${fileOrFolderPathWithRemotePrefix}: ${JSON.stringify(x,null,2)}`);
|
||||||
|
// S3 officially only supports seconds precision!!!!!
|
||||||
|
const mtimeSvr = Math.floor(x.LastModified!.valueOf() / 1000.0) * 1000;
|
||||||
|
let mtimeCli = mtimeSvr;
|
||||||
|
if (x.Metadata !== undefined) {
|
||||||
|
const m2 = Math.floor(
|
||||||
|
parseFloat(x.Metadata.mtime || x.Metadata.MTime || "0")
|
||||||
|
);
|
||||||
|
if (m2 !== 0) {
|
||||||
|
// to be compatible with RClone, we read and store the time in seconds in new version!
|
||||||
|
if (m2 >= 1000000000000) {
|
||||||
|
// it's a millsecond, uploaded by old codes..
|
||||||
|
mtimeCli = m2;
|
||||||
|
} else {
|
||||||
|
// it's a second, uploaded by new codes of the plugin from March 24, 2024
|
||||||
|
mtimeCli = m2 * 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// console.debug(
|
||||||
|
// `fromS3HeadObjectToEntity, fileOrFolderPathWithRemotePrefix=${fileOrFolderPathWithRemotePrefix}, remotePrefix=${remotePrefix}, x=${JSON.stringify(
|
||||||
|
// x
|
||||||
|
// )} `
|
||||||
|
// );
|
||||||
|
const key = getLocalNoPrefixPath(
|
||||||
|
fileOrFolderPathWithRemotePrefix,
|
||||||
|
remotePrefix
|
||||||
|
);
|
||||||
|
// console.debug(`fromS3HeadObjectToEntity, key=${key} after removing prefix`);
|
||||||
|
return {
|
||||||
|
key: key,
|
||||||
|
keyRaw: key,
|
||||||
|
mtimeSvr: mtimeSvr,
|
||||||
|
mtimeCli: mtimeCli,
|
||||||
|
sizeRaw: x.ContentLength,
|
||||||
|
size: x.ContentLength,
|
||||||
|
etag: x.ETag,
|
||||||
|
synthesizedFolder: false,
|
||||||
|
} as Entity;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class FakeFsS3 extends FakeFs {
|
||||||
|
s3Config: S3Config;
|
||||||
|
s3Client: S3Client;
|
||||||
|
kind: "s3";
|
||||||
|
synthFoldersCache: Record<string, Entity>;
|
||||||
|
constructor(s3Config: S3Config) {
|
||||||
|
super();
|
||||||
|
this.s3Config = s3Config;
|
||||||
|
this.s3Client = getS3Client(s3Config);
|
||||||
|
this.kind = "s3";
|
||||||
|
this.synthFoldersCache = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async walk(): Promise<Entity[]> {
|
||||||
|
const res = (await this._walkFromRoot(this.s3Config.remotePrefix)).filter(
|
||||||
|
(x) => x.key !== "" && x.key !== "/"
|
||||||
|
);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the input key contains basedir (prefix),
|
||||||
|
* but the result doesn't contain it.
|
||||||
|
*/
|
||||||
|
async _walkFromRoot(prefixOfRawKeys: string | undefined) {
|
||||||
|
const confCmd = {
|
||||||
|
Bucket: this.s3Config.s3BucketName,
|
||||||
|
} as ListObjectsV2CommandInput;
|
||||||
|
if (prefixOfRawKeys !== undefined && prefixOfRawKeys !== "") {
|
||||||
|
confCmd.Prefix = prefixOfRawKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contents = [] as _Object[];
|
||||||
|
const mtimeRecords: Record<string, number> = {};
|
||||||
|
const ctimeRecords: Record<string, number> = {};
|
||||||
|
const queueHead = new PQueue({
|
||||||
|
concurrency: this.s3Config.partsConcurrency,
|
||||||
|
autoStart: true,
|
||||||
|
});
|
||||||
|
queueHead.on("error", (error) => {
|
||||||
|
queueHead.pause();
|
||||||
|
queueHead.clear();
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
let isTruncated = true;
|
||||||
|
do {
|
||||||
|
const rsp = await this.s3Client.send(new ListObjectsV2Command(confCmd));
|
||||||
|
|
||||||
|
if (rsp.$metadata.httpStatusCode !== 200) {
|
||||||
|
throw Error("some thing bad while listing remote!");
|
||||||
|
}
|
||||||
|
if (rsp.Contents === undefined) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
contents.push(...rsp.Contents);
|
||||||
|
|
||||||
|
if (this.s3Config.useAccurateMTime) {
|
||||||
|
// head requests of all objects, love it
|
||||||
|
for (const content of rsp.Contents) {
|
||||||
|
queueHead.add(async () => {
|
||||||
|
const rspHead = await this.s3Client.send(
|
||||||
|
new HeadObjectCommand({
|
||||||
|
Bucket: this.s3Config.s3BucketName,
|
||||||
|
Key: content.Key,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (rspHead.$metadata.httpStatusCode !== 200) {
|
||||||
|
throw Error("some thing bad while heading single object!");
|
||||||
|
}
|
||||||
|
if (rspHead.Metadata === undefined) {
|
||||||
|
// pass
|
||||||
|
} else {
|
||||||
|
mtimeRecords[content.Key!] = Math.floor(
|
||||||
|
parseFloat(
|
||||||
|
rspHead.Metadata.mtime || rspHead.Metadata.MTime || "0"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
ctimeRecords[content.Key!] = Math.floor(
|
||||||
|
parseFloat(
|
||||||
|
rspHead.Metadata.ctime || rspHead.Metadata.CTime || "0"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isTruncated = rsp.IsTruncated ?? false;
|
||||||
|
confCmd.ContinuationToken = rsp.NextContinuationToken;
|
||||||
|
if (
|
||||||
|
isTruncated &&
|
||||||
|
(confCmd.ContinuationToken === undefined ||
|
||||||
|
confCmd.ContinuationToken === "")
|
||||||
|
) {
|
||||||
|
throw Error("isTruncated is true but no continuationToken provided");
|
||||||
|
}
|
||||||
|
} while (isTruncated);
|
||||||
|
|
||||||
|
// wait for any head requests
|
||||||
|
await queueHead.onIdle();
|
||||||
|
|
||||||
|
// ensemble fake rsp
|
||||||
|
// in the end, we need to transform the response list
|
||||||
|
// back to the local contents-alike list
|
||||||
|
const res: Entity[] = [];
|
||||||
|
const realEnrities = new Set<string>();
|
||||||
|
for (const remoteObj of contents) {
|
||||||
|
const remoteEntity = fromS3ObjectToEntity(
|
||||||
|
remoteObj,
|
||||||
|
this.s3Config.remotePrefix ?? "",
|
||||||
|
mtimeRecords,
|
||||||
|
ctimeRecords
|
||||||
|
);
|
||||||
|
realEnrities.add(remoteEntity.key!);
|
||||||
|
res.push(remoteEntity);
|
||||||
|
|
||||||
|
for (const f of getFolderLevels(remoteEntity.key!, true)) {
|
||||||
|
if (realEnrities.has(f)) {
|
||||||
|
delete this.synthFoldersCache[f];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!this.synthFoldersCache.hasOwnProperty(f) ||
|
||||||
|
remoteEntity.mtimeSvr! >= this.synthFoldersCache[f].mtimeSvr!
|
||||||
|
) {
|
||||||
|
this.synthFoldersCache[f] = {
|
||||||
|
key: f,
|
||||||
|
keyRaw: f,
|
||||||
|
size: 0,
|
||||||
|
sizeRaw: 0,
|
||||||
|
sizeEnc: 0,
|
||||||
|
mtimeSvr: remoteEntity.mtimeSvr,
|
||||||
|
mtimeSvrFmt: remoteEntity.mtimeSvrFmt,
|
||||||
|
mtimeCli: remoteEntity.mtimeCli,
|
||||||
|
mtimeCliFmt: remoteEntity.mtimeCliFmt,
|
||||||
|
synthesizedFolder: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(this.synthFoldersCache)) {
|
||||||
|
res.push(this.synthFoldersCache[key]);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stat(key: string): Promise<Entity> {
|
||||||
|
if (this.synthFoldersCache.hasOwnProperty(key)) {
|
||||||
|
return this.synthFoldersCache[key];
|
||||||
|
}
|
||||||
|
let keyFullPath = key;
|
||||||
|
keyFullPath = getRemoteWithPrefixPath(
|
||||||
|
keyFullPath,
|
||||||
|
this.s3Config.remotePrefix ?? ""
|
||||||
|
);
|
||||||
|
return await this._statFromRoot(keyFullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the input key contains basedir (prefix),
|
||||||
|
* but the result doesn't contain it.
|
||||||
|
*/
|
||||||
|
async _statFromRoot(key: string): Promise<Entity> {
|
||||||
|
if (
|
||||||
|
this.s3Config.remotePrefix !== undefined &&
|
||||||
|
this.s3Config.remotePrefix !== "" &&
|
||||||
|
!key.startsWith(this.s3Config.remotePrefix)
|
||||||
|
) {
|
||||||
|
throw Error(`_statFromRoot should only accept prefix-ed path`);
|
||||||
|
}
|
||||||
|
const res = await this.s3Client.send(
|
||||||
|
new HeadObjectCommand({
|
||||||
|
Bucket: this.s3Config.s3BucketName,
|
||||||
|
Key: key,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return fromS3HeadObjectToEntity(key, res, this.s3Config.remotePrefix ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async mkdir(key: string, mtime?: number, ctime?: number): Promise<Entity> {
|
||||||
|
if (!key.endsWith("/")) {
|
||||||
|
throw new Error(`You should not call mkdir on ${key}!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateFolderObject = this.s3Config.generateFolderObject ?? false;
|
||||||
|
if (!generateFolderObject) {
|
||||||
|
const synth = {
|
||||||
|
key: key,
|
||||||
|
keyRaw: key,
|
||||||
|
size: 0,
|
||||||
|
sizeRaw: 0,
|
||||||
|
sizeEnc: 0,
|
||||||
|
mtimeSvr: mtime,
|
||||||
|
mtimeCli: mtime,
|
||||||
|
synthesizedFolder: true,
|
||||||
|
};
|
||||||
|
this.synthFoldersCache[key] = synth;
|
||||||
|
return synth;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadFile = getRemoteWithPrefixPath(
|
||||||
|
key,
|
||||||
|
this.s3Config.remotePrefix ?? ""
|
||||||
|
);
|
||||||
|
return await this._mkdirFromRoot(uploadFile, mtime, ctime);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _mkdirFromRoot(key: string, mtime?: number, ctime?: number) {
|
||||||
|
if (
|
||||||
|
this.s3Config.remotePrefix !== undefined &&
|
||||||
|
this.s3Config.remotePrefix !== "" &&
|
||||||
|
!key.startsWith(this.s3Config.remotePrefix)
|
||||||
|
) {
|
||||||
|
throw Error(`_mkdirFromRoot should only accept prefix-ed path`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = DEFAULT_CONTENT_TYPE;
|
||||||
|
const p: PutObjectCommandInput = {
|
||||||
|
Bucket: this.s3Config.s3BucketName,
|
||||||
|
Key: key,
|
||||||
|
Body: "",
|
||||||
|
ContentType: contentType,
|
||||||
|
ContentLength: 0, // interesting we need to set this to avoid the warning
|
||||||
|
};
|
||||||
|
const metadata: Record<string, string> = {};
|
||||||
|
if (mtime !== undefined && mtime !== 0) {
|
||||||
|
metadata["MTime"] = `${mtime / 1000.0}`;
|
||||||
|
}
|
||||||
|
if (ctime !== undefined && ctime !== 0) {
|
||||||
|
metadata["CTime"] = `${ctime / 1000.0}`;
|
||||||
|
}
|
||||||
|
if (Object.keys(metadata).length > 0) {
|
||||||
|
p["Metadata"] = metadata;
|
||||||
|
}
|
||||||
|
await this.s3Client.send(new PutObjectCommand(p));
|
||||||
|
return await this._statFromRoot(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFile(
|
||||||
|
key: string,
|
||||||
|
content: ArrayBuffer,
|
||||||
|
mtime: number,
|
||||||
|
ctime: number
|
||||||
|
): Promise<Entity> {
|
||||||
|
const uploadFile = getRemoteWithPrefixPath(
|
||||||
|
key,
|
||||||
|
this.s3Config.remotePrefix ?? ""
|
||||||
|
);
|
||||||
|
const res = await this._writeFileFromRoot(
|
||||||
|
uploadFile,
|
||||||
|
content,
|
||||||
|
mtime,
|
||||||
|
ctime
|
||||||
|
);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the input key contains basedir (prefix),
|
||||||
|
* but the result doesn't contain it.
|
||||||
|
*/
|
||||||
|
async _writeFileFromRoot(
|
||||||
|
key: string,
|
||||||
|
content: ArrayBuffer,
|
||||||
|
mtime: number,
|
||||||
|
ctime: number
|
||||||
|
): Promise<Entity> {
|
||||||
|
if (
|
||||||
|
this.s3Config.remotePrefix !== undefined &&
|
||||||
|
this.s3Config.remotePrefix !== "" &&
|
||||||
|
!key.startsWith(this.s3Config.remotePrefix)
|
||||||
|
) {
|
||||||
|
throw Error(`_writeFileFromRoot should only accept prefix-ed path`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytesIn5MB = 5242880;
|
||||||
|
const body = new Uint8Array(content);
|
||||||
|
|
||||||
|
let contentType = DEFAULT_CONTENT_TYPE;
|
||||||
|
contentType =
|
||||||
|
mime.contentType(mime.lookup(key) || DEFAULT_CONTENT_TYPE) ||
|
||||||
|
DEFAULT_CONTENT_TYPE;
|
||||||
|
|
||||||
|
const upload = new Upload({
|
||||||
|
client: this.s3Client,
|
||||||
|
queueSize: this.s3Config.partsConcurrency, // concurrency
|
||||||
|
partSize: bytesIn5MB, // minimal 5MB by default
|
||||||
|
leavePartsOnError: false,
|
||||||
|
params: {
|
||||||
|
Bucket: this.s3Config.s3BucketName,
|
||||||
|
Key: key,
|
||||||
|
Body: body,
|
||||||
|
ContentType: contentType,
|
||||||
|
Metadata: {
|
||||||
|
MTime: `${mtime / 1000.0}`,
|
||||||
|
CTime: `${ctime / 1000.0}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
upload.on("httpUploadProgress", (progress) => {
|
||||||
|
// console.info(progress);
|
||||||
|
});
|
||||||
|
await upload.done();
|
||||||
|
|
||||||
|
return await this._statFromRoot(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFile(key: string): Promise<ArrayBuffer> {
|
||||||
|
if (key.endsWith("/")) {
|
||||||
|
throw new Error(`you should not call readFile on folder ${key}`);
|
||||||
|
}
|
||||||
|
const downloadFile = getRemoteWithPrefixPath(
|
||||||
|
key,
|
||||||
|
this.s3Config.remotePrefix ?? ""
|
||||||
|
);
|
||||||
|
|
||||||
|
return await this._readFileFromRoot(downloadFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _readFileFromRoot(key: string): Promise<ArrayBuffer> {
|
||||||
|
if (
|
||||||
|
this.s3Config.remotePrefix !== undefined &&
|
||||||
|
this.s3Config.remotePrefix !== "" &&
|
||||||
|
!key.startsWith(this.s3Config.remotePrefix)
|
||||||
|
) {
|
||||||
|
throw Error(`_readFileFromRoot should only accept prefix-ed path`);
|
||||||
|
}
|
||||||
|
const data = await this.s3Client.send(
|
||||||
|
new GetObjectCommand({
|
||||||
|
Bucket: this.s3Config.s3BucketName,
|
||||||
|
Key: key,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const bodyContents = await getObjectBodyToArrayBuffer(data.Body);
|
||||||
|
return bodyContents;
|
||||||
|
}
|
||||||
|
|
||||||
|
async rm(key: string): Promise<void> {
|
||||||
|
if (key === "/") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.synthFoldersCache.hasOwnProperty(key)) {
|
||||||
|
delete this.synthFoldersCache[key];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteFileName = getRemoteWithPrefixPath(
|
||||||
|
key,
|
||||||
|
this.s3Config.remotePrefix ?? ""
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.s3Client.send(
|
||||||
|
new DeleteObjectCommand({
|
||||||
|
Bucket: this.s3Config.s3BucketName,
|
||||||
|
Key: remoteFileName,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: do we need to delete folder recursively?
|
||||||
|
// maybe we should not
|
||||||
|
// because the outer sync algorithm should do that
|
||||||
|
// (await this._walkFromRoot(remoteFileName)).map(...)
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkConnect(callbackFunc?: any): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// TODO: no universal way now, just check this in connectivity
|
||||||
|
if (Platform.isIosApp && this.s3Config.s3Endpoint.startsWith("http://")) {
|
||||||
|
throw Error(
|
||||||
|
`Your s3 endpoint could only be https, not http, because of the iOS restriction.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// const results = await this.s3Client.send(
|
||||||
|
// new HeadBucketCommand({ Bucket: this.s3Config.s3BucketName })
|
||||||
|
// );
|
||||||
|
// very simplified version of listing objects
|
||||||
|
const confCmd = {
|
||||||
|
Bucket: this.s3Config.s3BucketName,
|
||||||
|
} as ListObjectsV2CommandInput;
|
||||||
|
const results = await this.s3Client.send(
|
||||||
|
new ListObjectsV2Command(confCmd)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
results === undefined ||
|
||||||
|
results.$metadata === undefined ||
|
||||||
|
results.$metadata.httpStatusCode === undefined
|
||||||
|
) {
|
||||||
|
const err = "results or $metadata or httStatusCode is undefined";
|
||||||
|
console.debug(err);
|
||||||
|
if (callbackFunc !== undefined) {
|
||||||
|
callbackFunc(err);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return results.$metadata.httpStatusCode === 200;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.debug(err);
|
||||||
|
if (callbackFunc !== undefined) {
|
||||||
|
if (this.s3Config.s3Endpoint.contains(this.s3Config.s3BucketName)) {
|
||||||
|
const err2 = new AggregateError([
|
||||||
|
err,
|
||||||
|
new Error(
|
||||||
|
"Maybe you've included the bucket name inside the endpoint setting. Please remove the bucket name and try again."
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
callbackFunc(err2);
|
||||||
|
} else {
|
||||||
|
callbackFunc(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserDisplayName(): Promise<string> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeAuth() {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
}
|
||||||
+494
@@ -0,0 +1,494 @@
|
|||||||
|
import { getReasonPhrase } from "http-status-codes/build/cjs/utils-functions";
|
||||||
|
import { Buffer } from "buffer";
|
||||||
|
import cloneDeep from "lodash/cloneDeep";
|
||||||
|
import { Queue } from "@fyears/tsqueue";
|
||||||
|
import chunk from "lodash/chunk";
|
||||||
|
import flatten from "lodash/flatten";
|
||||||
|
import { Platform, requestUrl } from "obsidian";
|
||||||
|
import { FakeFs } from "./fsAll";
|
||||||
|
import { bufferToArrayBuffer } from "./misc";
|
||||||
|
import { Entity, VALID_REQURL, WebdavConfig } from "./baseTypes";
|
||||||
|
import type {
|
||||||
|
FileStat,
|
||||||
|
WebDAVClient,
|
||||||
|
RequestOptionsWithState,
|
||||||
|
// Response,
|
||||||
|
// ResponseDataDetailed,
|
||||||
|
} from "webdav";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://stackoverflow.com/questions/32850898/how-to-check-if-a-string-has-any-non-iso-8859-1-characters-with-javascript
|
||||||
|
* @param str
|
||||||
|
* @returns true if all are iso 8859 1 chars
|
||||||
|
*/
|
||||||
|
function onlyAscii(str: string) {
|
||||||
|
return !/[^\u0000-\u00ff]/g.test(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://stackoverflow.com/questions/12539574/
|
||||||
|
* @param obj
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
function objKeyToLower(obj: Record<string, string>) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(obj).map(([k, v]) => [k.toLowerCase(), v])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
import { getPatcher } from "webdav/dist/web/index.js";
|
||||||
|
if (VALID_REQURL) {
|
||||||
|
getPatcher().patch(
|
||||||
|
"request",
|
||||||
|
async (options: RequestOptionsWithState): Promise<Response> => {
|
||||||
|
const transformedHeaders = objKeyToLower({ ...options.headers });
|
||||||
|
delete transformedHeaders["host"];
|
||||||
|
delete transformedHeaders["content-length"];
|
||||||
|
|
||||||
|
const reqContentType =
|
||||||
|
transformedHeaders["accept"] ?? transformedHeaders["content-type"];
|
||||||
|
|
||||||
|
const retractedHeaders = { ...transformedHeaders };
|
||||||
|
if (retractedHeaders.hasOwnProperty("authorization")) {
|
||||||
|
retractedHeaders["authorization"] = "<retracted>";
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug(`before request:`);
|
||||||
|
console.debug(`url: ${options.url}`);
|
||||||
|
console.debug(`method: ${options.method}`);
|
||||||
|
console.debug(`headers: ${JSON.stringify(retractedHeaders, null, 2)}`);
|
||||||
|
console.debug(`reqContentType: ${reqContentType}`);
|
||||||
|
|
||||||
|
let r = await requestUrl({
|
||||||
|
url: options.url,
|
||||||
|
method: options.method,
|
||||||
|
body: options.data as string | ArrayBuffer,
|
||||||
|
headers: transformedHeaders,
|
||||||
|
contentType: reqContentType,
|
||||||
|
throw: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
r.status === 401 &&
|
||||||
|
Platform.isIosApp &&
|
||||||
|
!options.url.endsWith("/") &&
|
||||||
|
!options.url.endsWith(".md") &&
|
||||||
|
options.method.toUpperCase() === "PROPFIND"
|
||||||
|
) {
|
||||||
|
// don't ask me why,
|
||||||
|
// some webdav servers have some mysterious behaviours,
|
||||||
|
// if a folder doesn't exist without slash, the servers return 401 instead of 404
|
||||||
|
// here is a dirty hack that works
|
||||||
|
console.debug(`so we have 401, try appending request url with slash`);
|
||||||
|
r = await requestUrl({
|
||||||
|
url: `${options.url}/`,
|
||||||
|
method: options.method,
|
||||||
|
body: options.data as string | ArrayBuffer,
|
||||||
|
headers: transformedHeaders,
|
||||||
|
contentType: reqContentType,
|
||||||
|
throw: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug(`after request:`);
|
||||||
|
const rspHeaders = objKeyToLower({ ...r.headers });
|
||||||
|
console.debug(`rspHeaders: ${JSON.stringify(rspHeaders, null, 2)}`);
|
||||||
|
for (let key in rspHeaders) {
|
||||||
|
if (rspHeaders.hasOwnProperty(key)) {
|
||||||
|
// avoid the error:
|
||||||
|
// Failed to read the 'headers' property from 'ResponseInit': String contains non ISO-8859-1 code point.
|
||||||
|
// const possibleNonAscii = [
|
||||||
|
// "Content-Disposition",
|
||||||
|
// "X-Accel-Redirect",
|
||||||
|
// "X-Outfilename",
|
||||||
|
// "X-Sendfile"
|
||||||
|
// ];
|
||||||
|
// for (const p of possibleNonAscii) {
|
||||||
|
// if (key === p || key === p.toLowerCase()) {
|
||||||
|
// rspHeaders[key] = encodeURIComponent(rspHeaders[key]);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
if (!onlyAscii(rspHeaders[key])) {
|
||||||
|
console.debug(`rspHeaders[key] needs encode: ${key}`);
|
||||||
|
rspHeaders[key] = encodeURIComponent(rspHeaders[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let r2: Response | undefined = undefined;
|
||||||
|
const statusText = getReasonPhrase(r.status);
|
||||||
|
console.debug(`statusText: ${statusText}`);
|
||||||
|
if ([101, 103, 204, 205, 304].includes(r.status)) {
|
||||||
|
// A null body status is a status that is 101, 103, 204, 205, or 304.
|
||||||
|
// https://fetch.spec.whatwg.org/#statuses
|
||||||
|
// fix this: Failed to construct 'Response': Response with null body status cannot have body
|
||||||
|
r2 = new Response(null, {
|
||||||
|
status: r.status,
|
||||||
|
statusText: statusText,
|
||||||
|
headers: rspHeaders,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
r2 = new Response(r.arrayBuffer, {
|
||||||
|
status: r.status,
|
||||||
|
statusText: statusText,
|
||||||
|
headers: rspHeaders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return r2;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
import { AuthType, BufferLike, createClient } from "webdav/dist/web/index.js";
|
||||||
|
|
||||||
|
export const DEFAULT_WEBDAV_CONFIG = {
|
||||||
|
address: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
authType: "basic",
|
||||||
|
manualRecursive: true,
|
||||||
|
depth: "manual_1",
|
||||||
|
remoteBaseDir: "",
|
||||||
|
} as WebdavConfig;
|
||||||
|
|
||||||
|
const getWebdavPath = (fileOrFolderPath: string, remoteBaseDir: string) => {
|
||||||
|
let key = fileOrFolderPath;
|
||||||
|
if (fileOrFolderPath === "/" || fileOrFolderPath === "") {
|
||||||
|
// special
|
||||||
|
key = `/${remoteBaseDir}/`;
|
||||||
|
} else if (fileOrFolderPath.startsWith("/")) {
|
||||||
|
console.warn(
|
||||||
|
`why the path ${fileOrFolderPath} starts with '/'? but we just go on.`
|
||||||
|
);
|
||||||
|
key = `/${remoteBaseDir}${fileOrFolderPath}`;
|
||||||
|
} else {
|
||||||
|
key = `/${remoteBaseDir}/${fileOrFolderPath}`;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNormPath = (fileOrFolderPath: string, remoteBaseDir: string) => {
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
fileOrFolderPath === `/${remoteBaseDir}` ||
|
||||||
|
fileOrFolderPath.startsWith(`/${remoteBaseDir}/`)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw Error(
|
||||||
|
`"${fileOrFolderPath}" doesn't starts with "/${remoteBaseDir}/"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileOrFolderPath.slice(`/${remoteBaseDir}/`.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fromWebdavItemToEntity = (x: FileStat, remoteBaseDir: string): Entity => {
|
||||||
|
let key = getNormPath(x.filename, remoteBaseDir);
|
||||||
|
if (x.type === "directory" && !key.endsWith("/")) {
|
||||||
|
key = `${key}/`;
|
||||||
|
}
|
||||||
|
const mtimeSvr = Date.parse(x.lastmod).valueOf();
|
||||||
|
return {
|
||||||
|
key: key,
|
||||||
|
keyRaw: key,
|
||||||
|
mtimeSvr: mtimeSvr,
|
||||||
|
mtimeCli: mtimeSvr, // TODO: no universal way to set mtime in webdav
|
||||||
|
size: x.size,
|
||||||
|
sizeRaw: x.size,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export class FakeFsWebdav extends FakeFs {
|
||||||
|
kind: "webdav";
|
||||||
|
|
||||||
|
webdavConfig: WebdavConfig;
|
||||||
|
remoteBaseDir: string;
|
||||||
|
client!: WebDAVClient;
|
||||||
|
vaultFolderExists: boolean;
|
||||||
|
saveUpdatedConfigFunc: () => Promise<any>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
webdavConfig: WebdavConfig,
|
||||||
|
vaultName: string,
|
||||||
|
saveUpdatedConfigFunc: () => Promise<any>
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this.kind = "webdav";
|
||||||
|
this.webdavConfig = cloneDeep(webdavConfig);
|
||||||
|
this.webdavConfig.address = encodeURI(this.webdavConfig.address);
|
||||||
|
this.remoteBaseDir = this.webdavConfig.remoteBaseDir || vaultName || "";
|
||||||
|
this.vaultFolderExists = false;
|
||||||
|
this.saveUpdatedConfigFunc = saveUpdatedConfigFunc;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _init() {
|
||||||
|
// init client if not inited
|
||||||
|
if (this.client !== undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Platform.isIosApp && !this.webdavConfig.address.startsWith("https")) {
|
||||||
|
throw Error(
|
||||||
|
`Your webdav address could only be https, not http, because of the iOS restriction.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
this.webdavConfig.username !== "" &&
|
||||||
|
this.webdavConfig.password !== ""
|
||||||
|
) {
|
||||||
|
this.client = createClient(this.webdavConfig.address, {
|
||||||
|
username: this.webdavConfig.username,
|
||||||
|
password: this.webdavConfig.password,
|
||||||
|
headers: headers,
|
||||||
|
authType:
|
||||||
|
this.webdavConfig.authType === "digest"
|
||||||
|
? AuthType.Digest
|
||||||
|
: AuthType.Password,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.info("no password");
|
||||||
|
this.client = createClient(this.webdavConfig.address, {
|
||||||
|
headers: headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// check vault folder
|
||||||
|
if (this.vaultFolderExists) {
|
||||||
|
// pass
|
||||||
|
} else {
|
||||||
|
const res = await this.client.exists(`/${this.remoteBaseDir}/`);
|
||||||
|
if (res) {
|
||||||
|
// console.info("remote vault folder exits!");
|
||||||
|
this.vaultFolderExists = true;
|
||||||
|
} else {
|
||||||
|
console.info("remote vault folder not exists, creating");
|
||||||
|
await this.client.createDirectory(`/${this.remoteBaseDir}/`);
|
||||||
|
console.info("remote vault folder created!");
|
||||||
|
this.vaultFolderExists = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adjust depth parameter
|
||||||
|
if (
|
||||||
|
this.webdavConfig.depth === "auto" ||
|
||||||
|
this.webdavConfig.depth === "auto_1" ||
|
||||||
|
this.webdavConfig.depth === "auto_infinity" ||
|
||||||
|
this.webdavConfig.depth === "auto_unknown"
|
||||||
|
) {
|
||||||
|
this.webdavConfig.depth = "manual_1";
|
||||||
|
this.webdavConfig.manualRecursive = true;
|
||||||
|
if (this.saveUpdatedConfigFunc !== undefined) {
|
||||||
|
await this.saveUpdatedConfigFunc();
|
||||||
|
console.info(
|
||||||
|
`webdav depth="auto_???" is changed to ${this.webdavConfig.depth}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async walk(): Promise<Entity[]> {
|
||||||
|
await this._init();
|
||||||
|
|
||||||
|
let contents = [] as FileStat[];
|
||||||
|
if (
|
||||||
|
this.webdavConfig.depth === "auto" ||
|
||||||
|
this.webdavConfig.depth === "auto_unknown" ||
|
||||||
|
this.webdavConfig.depth === "auto_1" ||
|
||||||
|
this.webdavConfig.depth === "auto_infinity" /* don't trust auto now */ ||
|
||||||
|
this.webdavConfig.depth === "manual_1"
|
||||||
|
) {
|
||||||
|
// the remote doesn't support infinity propfind,
|
||||||
|
// we need to do a bfs here
|
||||||
|
const q = new Queue([`/${this.remoteBaseDir}`]);
|
||||||
|
const CHUNK_SIZE = 10;
|
||||||
|
while (q.length > 0) {
|
||||||
|
const itemsToFetch: string[] = [];
|
||||||
|
while (q.length > 0) {
|
||||||
|
itemsToFetch.push(q.pop()!);
|
||||||
|
}
|
||||||
|
const itemsToFetchChunks = chunk(itemsToFetch, CHUNK_SIZE);
|
||||||
|
// console.debug(itemsToFetchChunks);
|
||||||
|
const subContents = [] as FileStat[];
|
||||||
|
for (const singleChunk of itemsToFetchChunks) {
|
||||||
|
const r = singleChunk.map((x) => {
|
||||||
|
return this.client.getDirectoryContents(x, {
|
||||||
|
deep: false,
|
||||||
|
details: false /* no need for verbose details here */,
|
||||||
|
// TODO: to support .obsidian,
|
||||||
|
// we need to load all files including dot,
|
||||||
|
// anyway to reduce the resources?
|
||||||
|
// glob: "/**" /* avoid dot files by using glob */,
|
||||||
|
}) as Promise<FileStat[]>;
|
||||||
|
});
|
||||||
|
const r2 = flatten(await Promise.all(r));
|
||||||
|
subContents.push(...r2);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < subContents.length; ++i) {
|
||||||
|
const f = subContents[i];
|
||||||
|
contents.push(f);
|
||||||
|
if (f.type === "directory") {
|
||||||
|
q.push(f.filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// the remote supports infinity propfind
|
||||||
|
contents = (await this.client.getDirectoryContents(
|
||||||
|
`/${this.remoteBaseDir}`,
|
||||||
|
{
|
||||||
|
deep: true,
|
||||||
|
details: false /* no need for verbose details here */,
|
||||||
|
// TODO: to support .obsidian,
|
||||||
|
// we need to load all files including dot,
|
||||||
|
// anyway to reduce the resources?
|
||||||
|
// glob: "/**" /* avoid dot files by using glob */,
|
||||||
|
}
|
||||||
|
)) as FileStat[];
|
||||||
|
}
|
||||||
|
return contents.map((x) => fromWebdavItemToEntity(x, this.remoteBaseDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
async stat(key: string): Promise<Entity> {
|
||||||
|
await this._init();
|
||||||
|
const fullPath = getWebdavPath(key, this.remoteBaseDir);
|
||||||
|
return await this._statFromRoot(fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _statFromRoot(key: string): Promise<Entity> {
|
||||||
|
const res = (await this.client.stat(key, {
|
||||||
|
details: false,
|
||||||
|
})) as FileStat;
|
||||||
|
return fromWebdavItemToEntity(res, this.remoteBaseDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
async mkdir(key: string, mtime?: number, ctime?: number): Promise<Entity> {
|
||||||
|
if (!key.endsWith("/")) {
|
||||||
|
throw Error(`you should not call mkdir on ${key}`);
|
||||||
|
}
|
||||||
|
await this._init();
|
||||||
|
const uploadFile = getWebdavPath(key, this.remoteBaseDir);
|
||||||
|
return await this._mkdirFromRoot(uploadFile, mtime, ctime);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _mkdirFromRoot(
|
||||||
|
key: string,
|
||||||
|
mtime?: number,
|
||||||
|
ctime?: number
|
||||||
|
): Promise<Entity> {
|
||||||
|
await this.client.createDirectory(key, {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
return await this._statFromRoot(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFile(
|
||||||
|
key: string,
|
||||||
|
content: ArrayBuffer,
|
||||||
|
mtime: number,
|
||||||
|
ctime: number
|
||||||
|
): Promise<Entity> {
|
||||||
|
if (key.endsWith("/")) {
|
||||||
|
throw Error(`you should not call writeFile on ${key}`);
|
||||||
|
}
|
||||||
|
await this._init();
|
||||||
|
const uploadFile = getWebdavPath(key, this.remoteBaseDir);
|
||||||
|
return await this._writeFileFromRoot(uploadFile, content, mtime, ctime);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _writeFileFromRoot(
|
||||||
|
key: string,
|
||||||
|
content: ArrayBuffer,
|
||||||
|
mtime: number,
|
||||||
|
ctime: number
|
||||||
|
): Promise<Entity> {
|
||||||
|
await this.client.putFileContents(key, content, {
|
||||||
|
overwrite: true,
|
||||||
|
onUploadProgress: (progress: any) => {
|
||||||
|
console.info(`Uploaded ${progress.loaded} bytes of ${progress.total}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return await this._statFromRoot(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFile(key: string): Promise<ArrayBuffer> {
|
||||||
|
if (key.endsWith("/")) {
|
||||||
|
throw Error(`you should not call readFile on ${key}`);
|
||||||
|
}
|
||||||
|
await this._init();
|
||||||
|
const downloadFile = getWebdavPath(key, this.remoteBaseDir);
|
||||||
|
return await this._readFileFromRoot(downloadFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _readFileFromRoot(key: string): Promise<ArrayBuffer> {
|
||||||
|
const buff = (await this.client.getFileContents(key)) as BufferLike;
|
||||||
|
if (buff instanceof ArrayBuffer) {
|
||||||
|
return buff;
|
||||||
|
} else if (buff instanceof Buffer) {
|
||||||
|
return bufferToArrayBuffer(buff);
|
||||||
|
}
|
||||||
|
throw Error(`unexpected file content result with type ${typeof buff}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async rm(key: string): Promise<void> {
|
||||||
|
if (key === "/") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this._init();
|
||||||
|
try {
|
||||||
|
const remoteFileName = getWebdavPath(key, this.remoteBaseDir);
|
||||||
|
await this.client.deleteFile(remoteFileName);
|
||||||
|
// console.info(`delete ${remoteFileName} succeeded`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("some error while deleting");
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkConnect(callbackFunc?: any): Promise<boolean> {
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
this.webdavConfig.address.startsWith("http://") ||
|
||||||
|
this.webdavConfig.address.startsWith("https://")
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const err =
|
||||||
|
"Error: the url should start with http(s):// but it does not!";
|
||||||
|
console.error(err);
|
||||||
|
if (callbackFunc !== undefined) {
|
||||||
|
callbackFunc(err);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this._init();
|
||||||
|
const results = await this._statFromRoot(`/${this.remoteBaseDir}/`);
|
||||||
|
if (results === undefined) {
|
||||||
|
const err = "results is undefined";
|
||||||
|
console.error(err);
|
||||||
|
callbackFunc?.(err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
callbackFunc?.(err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserDisplayName(): Promise<string> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeAuth() {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
-7
@@ -5,24 +5,34 @@ import {
|
|||||||
COMMAND_URI,
|
COMMAND_URI,
|
||||||
UriParams,
|
UriParams,
|
||||||
RemotelySavePluginSettings,
|
RemotelySavePluginSettings,
|
||||||
|
QRExportType,
|
||||||
} from "./baseTypes";
|
} from "./baseTypes";
|
||||||
|
import { getShrinkedSettings } from "./fsOnedrive";
|
||||||
import { log } from "./moreOnLog";
|
|
||||||
|
|
||||||
export const exportQrCodeUri = async (
|
export const exportQrCodeUri = async (
|
||||||
settings: RemotelySavePluginSettings,
|
settings: RemotelySavePluginSettings,
|
||||||
currentVaultName: string,
|
currentVaultName: string,
|
||||||
pluginVersion: string
|
pluginVersion: string,
|
||||||
|
exportFields: QRExportType
|
||||||
) => {
|
) => {
|
||||||
const settings2 = cloneDeep(settings);
|
let settings2: Partial<RemotelySavePluginSettings> = {};
|
||||||
delete settings2.dropbox;
|
|
||||||
delete settings2.onedrive;
|
if (exportFields === "all_but_oauth2") {
|
||||||
|
settings2 = cloneDeep(settings);
|
||||||
|
delete settings2.dropbox;
|
||||||
|
delete settings2.onedrive;
|
||||||
|
} else if (exportFields === "dropbox") {
|
||||||
|
settings2 = { dropbox: cloneDeep(settings.dropbox) };
|
||||||
|
} else if (exportFields === "onedrive") {
|
||||||
|
settings2 = { onedrive: getShrinkedSettings(settings.onedrive) };
|
||||||
|
}
|
||||||
|
|
||||||
delete settings2.vaultRandomID;
|
delete settings2.vaultRandomID;
|
||||||
const data = encodeURIComponent(JSON.stringify(settings2));
|
const data = encodeURIComponent(JSON.stringify(settings2));
|
||||||
const vault = encodeURIComponent(currentVaultName);
|
const vault = encodeURIComponent(currentVaultName);
|
||||||
const version = encodeURIComponent(pluginVersion);
|
const version = encodeURIComponent(pluginVersion);
|
||||||
const rawUri = `obsidian://${COMMAND_URI}?func=settings&version=${version}&vault=${vault}&data=${data}`;
|
const rawUri = `obsidian://${COMMAND_URI}?func=settings&version=${version}&vault=${vault}&data=${data}`;
|
||||||
// log.info(uri)
|
// console.info(uri)
|
||||||
const imgUri = await QRCode.toDataURL(rawUri);
|
const imgUri = await QRCode.toDataURL(rawUri);
|
||||||
return {
|
return {
|
||||||
rawUri,
|
rawUri,
|
||||||
@@ -36,6 +46,20 @@ export interface ProcessQrCodeResultType {
|
|||||||
result?: RemotelySavePluginSettings;
|
result?: RemotelySavePluginSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* we also support directly parse the uri, instead of relying on web browser
|
||||||
|
* @param input
|
||||||
|
*/
|
||||||
|
export const parseUriByHand = (input: string) => {
|
||||||
|
if (!input.startsWith("obsidian://remotely-save?func=settings&")) {
|
||||||
|
throw Error(`not valid string`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const k = new URL(input);
|
||||||
|
const output = Object.fromEntries(k.searchParams);
|
||||||
|
return output;
|
||||||
|
};
|
||||||
|
|
||||||
export const importQrCodeUri = (
|
export const importQrCodeUri = (
|
||||||
inputParams: any,
|
inputParams: any,
|
||||||
currentVaultName: string
|
currentVaultName: string
|
||||||
|
|||||||
+103
-71
@@ -5,53 +5,58 @@
|
|||||||
"goback": "Go Back",
|
"goback": "Go Back",
|
||||||
"submit": "Submit",
|
"submit": "Submit",
|
||||||
"sometext": "Here are some texts.",
|
"sometext": "Here are some texts.",
|
||||||
"syncrun_alreadyrunning": "{{pluginName}} already running in stage {{syncStatus}}!",
|
"syncrun_alreadyrunning": "New command {{newTriggerSource}} stops because {{pluginName}} is already running in stage {{syncStatus}}!",
|
||||||
"syncrun_syncingribbon": "{{pluginName}}: syncing from {{triggerSource}}",
|
"syncrun_syncingribbon": "{{pluginName}}: syncing from {{triggerSource}}",
|
||||||
"syncrun_step0": "0/8 Remotely Save running in dry mode, not actual file changes would happen.",
|
"syncrun_step0": "0/8 Remotely Save is running in dry mode, thus not actual file changes would happen.",
|
||||||
"syncrun_step1": "1/8 Remotely Save Sync Preparing ({{serviceType}})",
|
"syncrun_step1": "1/8 Remotely Save is preparing ({{serviceType}})",
|
||||||
"syncrun_step2": "2/8 Starting to fetch remote meta data.",
|
"syncrun_step2": "2/8 Starting to fetch remote meta data.",
|
||||||
"syncrun_step3": "3/8 Checking password correct or not.",
|
"syncrun_step3": "3/8 Checking password correct or not.",
|
||||||
"syncrun_passworderr": "Something goes wrong while checking password.",
|
"syncrun_passworderr": "Something goes wrong while checking password.",
|
||||||
"syncrun_step4": "4/8 Trying to fetch extra meta data from remote.",
|
"syncrun_step4": "4/8 Starting to fetch local meta data.",
|
||||||
"syncrun_step5": "5/8 Starting to fetch local meta data.",
|
"syncrun_step5": "5/8 Starting to fetch local prev sync data.",
|
||||||
"syncrun_step6": "6/8 Starting to generate sync plan.",
|
"syncrun_step6": "6/8 Starting to generate sync plan.",
|
||||||
"syncrun_step7": "7/8 Remotely Save Sync data exchanging!",
|
"syncrun_step7": "7/8 Remotely Save Sync data is exchanging!",
|
||||||
"syncrun_step7skip": "7/8 Remotely Save real sync is skipped in dry run mode.",
|
"syncrun_step7skip": "7/8 Remotely Save real sync is skipped in dry run mode.",
|
||||||
"syncrun_step8": "8/8 Remotely Save finish!",
|
"syncrun_step8": "8/8 Remotely Save finished!",
|
||||||
"syncrun_shortstep0": "0/2 Remotely Save running in dry mode, not actual file changes would happen.",
|
"syncrun_shortstep0": "0/2 Remotely Save is running in dry mode, not actual file changes would happen.",
|
||||||
"syncrun_shortstep1": "1/2 Remotely Save Sync Start Running ({{serviceType}})",
|
"syncrun_shortstep1": "1/2 Remotely Save starts running ({{serviceType}})",
|
||||||
"syncrun_shortstep2skip": "2/2 Remotely Save real sync is skipped in dry run mode.",
|
"syncrun_shortstep2skip": "2/2 Remotely Save real sync is skipped in dry run mode.",
|
||||||
"syncrun_shortstep2": "2/2 Remotely Save finish!",
|
"syncrun_shortstep2": "2/2 Remotely Save finished!",
|
||||||
"syncrun_abort": "{{manifestID}}-{{theDate}}: abort sync, triggerSource={{triggerSource}}, error while {{syncStatus}}",
|
"syncrun_abort": "{{manifestID}}-{{theDate}}: abort sync, triggerSource={{triggerSource}}, error while {{syncStatus}}",
|
||||||
"protocol_saveqr": "New not-oauth2 settings for {{manifestName}} saved. Reopen the plugin Settings to the effect.",
|
"syncrun_abort_protectmodifypercentage": "Abort! you set changing files >= {{protectModifyPercentage}}% is not allowed but {{realModifyDeleteCount}}/{{allFilesCount}}={{percent}}% is going to be modified or deleted! If you are sure you want this sync, please adjust the allowed ratio in the settings.",
|
||||||
"protocol_callbacknotsupported": "Your uri call a callback that's not supported yet: {{params}}",
|
"protocol_saveqr": "New settings for {{manifestName}} is imported and saved. Reopen the plugin settings to make it effective.",
|
||||||
|
"protocol_callbacknotsupported": "Your uri calls a callback that's not supported yet: {{params}}",
|
||||||
"protocol_dropbox_connecting": "Connecting to Dropbox...\nPlease DO NOT close this modal.",
|
"protocol_dropbox_connecting": "Connecting to Dropbox...\nPlease DO NOT close this modal.",
|
||||||
"protocol_dropbox_connect_succ": "Good! We've connected to Dropbox as user {{username}}!",
|
"protocol_dropbox_connect_succ": "Good! We've connected to Dropbox as user {{username}}!",
|
||||||
"protocol_dropbox_connect_succ_revoke": "You've connected as user {{username}}. If you want to disconnect, click this button.",
|
"protocol_dropbox_connect_succ_revoke": "You've connected as user {{username}}. If you want to disconnect, click this button.",
|
||||||
"protocol_dropbox_connect_fail": "Something went wrong from response from Dropbox. Maybe you rejected the auth?",
|
"protocol_dropbox_connect_fail": "Something went wrong from response from Dropbox. Maybe the network connection is not good. Maybe you rejected the auth?",
|
||||||
"protocol_dropbox_connect_unknown": "Do not know how to deal with the callback: {{params}}",
|
"protocol_dropbox_connect_unknown": "Do not know how to deal with the callback: {{params}}",
|
||||||
|
"protocol_dropbox_no_modal": "You are not startting Dropbox connection from the settings page. Abort.",
|
||||||
"protocol_onedrive_connecting": "Connecting to OneDrive...\nPlease DO NOT close this modal.",
|
"protocol_onedrive_connecting": "Connecting to OneDrive...\nPlease DO NOT close this modal.",
|
||||||
"protocol_onedrive_connect_succ_revoke": "You've connected as user {{username}}. If you want to disconnect, click this button.",
|
"protocol_onedrive_connect_succ_revoke": "You've connected as user {{username}}. If you want to disconnect, click this button.",
|
||||||
"protocol_onedrive_connect_fail": "Something went wrong from response from OneDrive. Maybe you rejected the auth?",
|
"protocol_onedrive_connect_fail": "Something went wrong from response from OneDrive. Maybe you rejected the auth?",
|
||||||
"protocol_onedrive_connect_unknown": "Do not know how to deal with the callback: {{params}}",
|
"protocol_onedrive_connect_unknown": "Do not know how to deal with the callback: {{params}}",
|
||||||
"command_startsync": "start sync",
|
"command_startsync": "start sync",
|
||||||
"command_drynrun": "start sync (dry run only)",
|
"command_drynrun": "start sync (dry run only)",
|
||||||
"command_exportsyncplans_json": "export sync plans in json format",
|
"command_exportsyncplans_1": "export sync plans (latest 1)",
|
||||||
"command_exportsyncplans_table": "export sync plans in table format",
|
"command_exportsyncplans_5": "export sync plans (latest 5)",
|
||||||
|
"command_exportsyncplans_all": "export sync plans (all)",
|
||||||
|
|
||||||
"command_exportlogsindb": "export logs saved in db",
|
"command_exportlogsindb": "export logs saved in db",
|
||||||
|
|
||||||
"statusbar_time_years": "{{time}} years",
|
"statusbar_time_years": "Synced {{time}} years ago",
|
||||||
"statusbar_time_months": "{{time}} months",
|
"statusbar_time_months": "Synced {{time}} months ago",
|
||||||
"statusbar_time_weeks": "{{time}} weeks",
|
"statusbar_time_weeks": "Synced {{time}} weeks ago",
|
||||||
"statusbar_time_days": "{{time}} days",
|
"statusbar_time_days": "Synced {{time}} days ago",
|
||||||
"statusbar_time_hours": "{{time}} hours",
|
"statusbar_time_hours": "Synced {{time}} hours ago",
|
||||||
"statusbar_time_minutes": "{{time}} minutes",
|
"statusbar_time_minutes": "Synced {{time}} minutes ago",
|
||||||
"statusbar_time_lessminute": "less than a minute",
|
"statusbar_time_lessminute": "Synced less than a minute ago",
|
||||||
"statusbar_lastsync": "Synced {{time}} ago",
|
"statusbar_lastsync": "Synced {{time}} ago",
|
||||||
|
"statusbar_syncing": "Syncing...",
|
||||||
|
"statusbar_now": "Synced just now",
|
||||||
"statusbar_lastsync_label": "Last successful Sync on {{date}}",
|
"statusbar_lastsync_label": "Last successful Sync on {{date}}",
|
||||||
"statusbar_lastsync_never": "Never Synced",
|
"statusbar_lastsync_never": "Never Synced",
|
||||||
"statusbar_lastsync_never_label": "Never Synced before",
|
"statusbar_lastsync_never_label": "Never Synced before",
|
||||||
|
|
||||||
"modal_password_title": "Hold on and PLEASE READ ON...",
|
"modal_password_title": "Hold on and PLEASE READ ON...",
|
||||||
"modal_password_shortdesc": "If the field is not empty, files would be encrypted locally before being uploaded.\nIf the field is empty, then files would be uploaded without encryption.",
|
"modal_password_shortdesc": "If the field is not empty, files would be encrypted locally before being uploaded.\nIf the field is empty, then files would be uploaded without encryption.",
|
||||||
"modal_password_attn1": "Attention 1/5: The vault name is NOT encrypted. The plugin creates a folder with the vault name on some remote services.",
|
"modal_password_attn1": "Attention 1/5: The vault name is NOT encrypted. The plugin creates a folder with the vault name on some remote services.",
|
||||||
@@ -61,6 +66,8 @@
|
|||||||
"modal_password_attn5": "Attention 5/5: The longer the password, the better.",
|
"modal_password_attn5": "Attention 5/5: The longer the password, the better.",
|
||||||
"modal_password_secondconfirm": "The Second Confirm to change password.",
|
"modal_password_secondconfirm": "The Second Confirm to change password.",
|
||||||
"modal_password_notice": "New password saved!",
|
"modal_password_notice": "New password saved!",
|
||||||
|
"modal_encryptionmethod_title": "Hold on and PLEASE READ ON...",
|
||||||
|
"modal_encryptionmethod_shortdesc": "You are changing the encrpytion method but you have set the password before.\nAfter switching the method, you need to <b>manually</b> and <b>fully</b> delete every encrypted vault files in the remote and re-sync (so that re-upload) the newly encrypted files again.",
|
||||||
"modal_remotebasedir_title": "You are changing the remote base directory config",
|
"modal_remotebasedir_title": "You are changing the remote base directory config",
|
||||||
"modal_remotebasedir_shortdesc": "1. The plugin would NOT automatically move the content from the old directory to the new one directly on the remote. Everything syncs from the beginning again.\n2. If you set the string to the empty, the config would be reset to use the vault folder name (the default config).\n3. The remote directory name itself would not be encrypted even you've set an E2E password.\n4. Some special char like '?', '/', '\\' are not allowed. Spaces in the beginning or in the end are also trimmed.",
|
"modal_remotebasedir_shortdesc": "1. The plugin would NOT automatically move the content from the old directory to the new one directly on the remote. Everything syncs from the beginning again.\n2. If you set the string to the empty, the config would be reset to use the vault folder name (the default config).\n3. The remote directory name itself would not be encrypted even you've set an E2E password.\n4. Some special char like '?', '/', '\\' are not allowed. Spaces in the beginning or in the end are also trimmed.",
|
||||||
"modal_remotebasedir_invaliddirhint": "Your input contains special characters like '?', '/', '\\' which are not allowed.",
|
"modal_remotebasedir_invaliddirhint": "Your input contains special characters like '?', '/', '\\' which are not allowed.",
|
||||||
@@ -85,6 +92,7 @@
|
|||||||
"modal_dropboxauth_maualinput_conn_succ_revoke": "You've connected as user {{username}}. If you want to disconnect, click this button.",
|
"modal_dropboxauth_maualinput_conn_succ_revoke": "You've connected as user {{username}}. If you want to disconnect, click this button.",
|
||||||
"modal_dropboxauth_maualinput_conn_fail": "Something goes wrong while connecting to Dropbox.",
|
"modal_dropboxauth_maualinput_conn_fail": "Something goes wrong while connecting to Dropbox.",
|
||||||
"modal_onedriveauth_shortdesc": "Currently only OneDrive for personal is supported. OneDrive for Business is NOT supported (yet).\nVisit the address in a browser, and follow the steps.\nFinally you should be redirected to Obsidian.",
|
"modal_onedriveauth_shortdesc": "Currently only OneDrive for personal is supported. OneDrive for Business is NOT supported (yet).\nVisit the address in a browser, and follow the steps.\nFinally you should be redirected to Obsidian.",
|
||||||
|
"modal_onedriveauth_shortdesc_linux": "It seems that you are using Obsidian on Linux, and you might not be able to jump back here properly. Please consider <a href=\"https://github.com/remotely-save/remotely-save/issues/415\">using</a> the flatpack version of Obsidian, or creating an <a href=\"https://github.com/remotely-save/remotely-save/blob/master/docs/linux.md\"><code>obsidian.desktop</code> file</a>.",
|
||||||
"modal_onedriveauth_copybutton": "Click to copy the auth url",
|
"modal_onedriveauth_copybutton": "Click to copy the auth url",
|
||||||
"modal_onedriveauth_copynotice": "The auth url is copied to the clipboard!",
|
"modal_onedriveauth_copynotice": "The auth url is copied to the clipboard!",
|
||||||
"modal_onedriverevokeauth_step1": "Step 1: Go to the following address, click the \"Edit\" button for the plugin, then click \"Remove these permissions\" button on the page.",
|
"modal_onedriverevokeauth_step1": "Step 1: Go to the following address, click the \"Edit\" button for the plugin, then click \"Remove these permissions\" button on the page.",
|
||||||
@@ -97,20 +105,21 @@
|
|||||||
"modal_syncconfig_attn": "Attention 1/2: This only syncs (copies) the whole Obsidian config dir, not other startting-with-dot folders or files. Except for ignoring folders .git and node_modules, it also doesn't understand the meaning of sub-files and sub-folders inside the config dir.\nAttention 2/2: After the config dir is synced, plugins settings might be corrupted, and Obsidian might need to be restarted to load the new settings.\nIf you are agreed to take your own risk, please click the following second confirm button.",
|
"modal_syncconfig_attn": "Attention 1/2: This only syncs (copies) the whole Obsidian config dir, not other startting-with-dot folders or files. Except for ignoring folders .git and node_modules, it also doesn't understand the meaning of sub-files and sub-folders inside the config dir.\nAttention 2/2: After the config dir is synced, plugins settings might be corrupted, and Obsidian might need to be restarted to load the new settings.\nIf you are agreed to take your own risk, please click the following second confirm button.",
|
||||||
"modal_syncconfig_secondconfirm": "The Second Confirm To Enable.",
|
"modal_syncconfig_secondconfirm": "The Second Confirm To Enable.",
|
||||||
"modal_syncconfig_notice": "You've enabled syncing config folder!",
|
"modal_syncconfig_notice": "You've enabled syncing config folder!",
|
||||||
"modal_qr_shortdesc": "This exports not-oauth2 settings. (It means that Dropbox, OneDrive info are NOT exported.)\nYou can use another device to scan this qrcode.\nOr, you can click the button to copy the special url.",
|
"modal_qr_shortdesc": "This exports (partial) settings.\nYou can use another device to scan this qrcode.\nOr, you can click the button to copy the special uri and paste it into another device's web browser or Remotely Save Import Setting.",
|
||||||
"modal_qr_button": "Click to copy the special URI",
|
"modal_qr_button": "Click to copy the special URI",
|
||||||
"modal_qr_button_notice": "The special uri is copied to the clipboard!",
|
"modal_qr_button_notice": "The special uri is copied to the clipboard!",
|
||||||
"modal_sizesconflict_title": "Remotely Save: Some conflict were found while skipping large files",
|
"modal_sizesconflict_title": "Remotely Save: Some conflict were found while skipping large files",
|
||||||
"modal_sizesconflict_desc": "You've set skipping files larger than {{thresholdMB}} MB ({{thresholdBytes}} bytes).\nBut the following files have sizes larger than the threshold on one side, and sizes smaller than the threshold on the other side.\nTo avoid unexpected overwriting or deleting, the plugin stops, and you have to manually deal with at least one side of the files.",
|
"modal_sizesconflict_desc": "You've set skipping files larger than {{thresholdMB}} MB ({{thresholdBytes}} bytes).\nBut the following files have sizes larger than the threshold on one side, and sizes smaller than the threshold on the other side.\nTo avoid unexpected overwriting or deleting, the plugin stops, and you have to manually deal with at least one side of the files.",
|
||||||
"modal_sizesconflict_copybutton": "Click to copy all the below sizes conflicts info",
|
"modal_sizesconflict_copybutton": "Click to copy all the below sizes conflicts info",
|
||||||
"modal_sizesconflict_copynotice": "All the sizes conflicts info have been copied to the clipboard!",
|
"modal_sizesconflict_copynotice": "All the sizes conflicts info have been copied to the clipboard!",
|
||||||
"modal_logtohttpserver_title": "Log To HTTP(S) Server Is DANGEROUS!",
|
|
||||||
"modal_logtohttpserver_desc": "All your sensitive logging information will be posted to the HTTP(S) server without any authentications!!!!!\nPlease make sure you trust the HTTP(S) server, and it's better to setup a HTTPS one instead of HTTP one.\nIt's for debugging purposes only, especially on mobile.",
|
|
||||||
"modal_logtohttpserver_secondconfirm": "I know it's dangerous, and insist, and am willing to bear all possible losses.",
|
|
||||||
"modal_logtohttpserver_notice": "OK.",
|
|
||||||
"settings_basic": "Basic Settings",
|
"settings_basic": "Basic Settings",
|
||||||
"settings_password": "Encryption Password",
|
"settings_password": "Encryption Password",
|
||||||
"settings_password_desc": "Password for E2E encryption. Empty for no password. You need to click \"Confirm\". Attention: the password and other info are saved locally.",
|
"settings_password_desc": "Password for E2E encryption. Empty for no password. You need to click \"Confirm\". Attention: The password and other info are saved locally. After changing the password, you need to manually delete every original files in the remote, and re-sync (so that upload) the encrypted files again.",
|
||||||
|
"settings_encryptionmethod": "Encryption Method",
|
||||||
|
"settings_encryptionmethod_desc": "Encryption method for E2E encryption. RClone Crypt format is recommended but it doesn't encrypt path structure. OpenSSL enc is the legacy format of this plugin. <b>Both are not affliated with official RClone and OpenSSL product or community.</b> Attention: After switching the method, you need to manually delete every original files in the remote and re-sync (so that upload) the encrypted files again. More info in the <a href='https://github.com/remotely-save/remotely-save/tree/master/docs/encryption'>online doc</a>.",
|
||||||
|
"settings_encryptionmethod_rclone": "RClone Crypt (recommended)",
|
||||||
|
"settings_encryptionmethod_openssl": "OpenSSL enc (legacy)",
|
||||||
|
|
||||||
"settings_autorun": "Schedule For Auto Run",
|
"settings_autorun": "Schedule For Auto Run",
|
||||||
"settings_autorun_desc": "The plugin tries to schedule the running after every interval. Battery may be impacted.",
|
"settings_autorun_desc": "The plugin tries to schedule the running after every interval. Battery may be impacted.",
|
||||||
"settings_autorun_notset": "(not set)",
|
"settings_autorun_notset": "(not set)",
|
||||||
@@ -124,17 +133,13 @@
|
|||||||
"settings_runoncestartup_1sec": "sync once after 1 second of start up",
|
"settings_runoncestartup_1sec": "sync once after 1 second of start up",
|
||||||
"settings_runoncestartup_10sec": "sync once after 10 seconds of start up",
|
"settings_runoncestartup_10sec": "sync once after 10 seconds of start up",
|
||||||
"settings_runoncestartup_30sec": "sync once after 30 seconds of start up",
|
"settings_runoncestartup_30sec": "sync once after 30 seconds of start up",
|
||||||
"settings_saverun": "Sync On Save (experimental)",
|
"settings_synconsave": "Sync On Save (experimental)",
|
||||||
"settings_saverun_desc": "A sync will be triggered if a file save action happened within a few seconds. Please pay attention that syncing is potentially a heavy action and battery may be impacted. (May need to reload the plugin or restart Obsidian after changing)",
|
"settings_synconsave_desc": "If you change your file, the plugin tries to trigger a sync.",
|
||||||
"settings_saverun_notset": "(not set)",
|
"settings_synconsave_disable": "Disable (default)",
|
||||||
"settings_saverun_1sec": "check every 1 second",
|
"settings_synconsave_enable": "Enable",
|
||||||
"settings_saverun_5sec": "check every 5 seconds",
|
|
||||||
"settings_saverun_10sec": "check every 10 seconds (recommended)",
|
|
||||||
"settings_saverun_1min": "check every 1 minute",
|
|
||||||
"settings_skiplargefiles": "Skip Large Files",
|
"settings_skiplargefiles": "Skip Large Files",
|
||||||
"settings_skiplargefiles_desc": "Skip files with sizes larger than the threshold. Here 1 MB = 10^6 bytes.",
|
"settings_skiplargefiles_desc": "Skip files with sizes larger than the threshold. Here 1 MB = 10^6 bytes.",
|
||||||
"settings_skiplargefiles_notset": "(not set)",
|
"settings_skiplargefiles_notset": "(not set)",
|
||||||
|
|
||||||
"settings_ignorepaths": "Regex Of Paths To Ignore",
|
"settings_ignorepaths": "Regex Of Paths To Ignore",
|
||||||
"settings_ignorepaths_desc": "Regex of paths of folders or files to ignore. One regex per line. The path is relative to the vault root without leading slash.",
|
"settings_ignorepaths_desc": "Regex of paths of folders or files to ignore. One regex per line. The path is relative to the vault root without leading slash.",
|
||||||
"settings_enablestatusbar_info": "Show Last Successful Sync In Status Bar",
|
"settings_enablestatusbar_info": "Show Last Successful Sync In Status Bar",
|
||||||
@@ -144,7 +149,6 @@
|
|||||||
"settings_resetstatusbar_time_desc": "Reset last successful sync time.",
|
"settings_resetstatusbar_time_desc": "Reset last successful sync time.",
|
||||||
"settings_resetstatusbar_button": "Reset",
|
"settings_resetstatusbar_button": "Reset",
|
||||||
"settings_resetstatusbar_notice": "Reset done!",
|
"settings_resetstatusbar_notice": "Reset done!",
|
||||||
|
|
||||||
"settings_checkonnectivity": "Check Connectivity",
|
"settings_checkonnectivity": "Check Connectivity",
|
||||||
"settings_checkonnectivity_desc": "Check connectivity.",
|
"settings_checkonnectivity_desc": "Check connectivity.",
|
||||||
"settings_checkonnectivity_button": "Check",
|
"settings_checkonnectivity_button": "Check",
|
||||||
@@ -163,7 +167,7 @@
|
|||||||
"settings_s3_prod3": "Configuring CORS",
|
"settings_s3_prod3": "Configuring CORS",
|
||||||
"settings_s3_endpoint": "Endpoint",
|
"settings_s3_endpoint": "Endpoint",
|
||||||
"settings_s3_region": "Region",
|
"settings_s3_region": "Region",
|
||||||
"settings_s3_region_desc": "If you are not sure what to enter, you could try the value: us-east-1",
|
"settings_s3_region_desc": "If you are not sure what to enter, you could try the value: us-east-1 .",
|
||||||
"settings_s3_accesskeyid": "Access Key ID",
|
"settings_s3_accesskeyid": "Access Key ID",
|
||||||
"settings_s3_accesskeyid_desc": "Access key ID. Attention: Access key ID and other info are saved locally.",
|
"settings_s3_accesskeyid_desc": "Access key ID. Attention: Access key ID and other info are saved locally.",
|
||||||
"settings_s3_secretaccesskey": "Secret Access Key",
|
"settings_s3_secretaccesskey": "Secret Access Key",
|
||||||
@@ -173,8 +177,16 @@
|
|||||||
"settings_s3_bypasscorslocally_desc": "The plugin allows skipping server CORS config in new version of Obsidian ( desktop>=0.13.25 or iOS>=1.1.1 or Android>=1.2.1). If you encounter any issues, please disable this setting and config CORS on servers (allowing requests from app://obsidian.md and capacitor://localhost and http://localhost and add ETag into exposed headers).",
|
"settings_s3_bypasscorslocally_desc": "The plugin allows skipping server CORS config in new version of Obsidian ( desktop>=0.13.25 or iOS>=1.1.1 or Android>=1.2.1). If you encounter any issues, please disable this setting and config CORS on servers (allowing requests from app://obsidian.md and capacitor://localhost and http://localhost and add ETag into exposed headers).",
|
||||||
"settings_s3_parts": "Parts Concurrency",
|
"settings_s3_parts": "Parts Concurrency",
|
||||||
"settings_s3_parts_desc": "Large files are split into small parts to upload in S3. How many parts do you want to upload in parallel at most?",
|
"settings_s3_parts_desc": "Large files are split into small parts to upload in S3. How many parts do you want to upload in parallel at most?",
|
||||||
|
"settings_s3_accuratemtime": "Use Accurate MTime",
|
||||||
|
"settings_s3_accuratemtime_desc": "Read the uploaded accurate last modified time for better sync algorithm. But it causes extra api requests / time / money to the S3 endpoint.",
|
||||||
"settings_s3_urlstyle": "S3 URL style",
|
"settings_s3_urlstyle": "S3 URL style",
|
||||||
"settings_s3_urlstyle_desc": "Whether to force path-style URLs for S3 objects (e.g., https://s3.amazonaws.com/*/ instead of https://*.s3.amazonaws.com/).",
|
"settings_s3_urlstyle_desc": "Whether to force path-style URLs for S3 objects (e.g., https://s3.amazonaws.com/*/ instead of https://*.s3.amazonaws.com/).",
|
||||||
|
"settings_s3_reverse_proxy_no_sign_url": "S3 Reverse Proxy (No Sign) Url (experimental)",
|
||||||
|
"settings_s3_reverse_proxy_no_sign_url_desc": "S3 reverse proxy url without signature. This is useful if you use a revers proxy but do not change the original credential signature. No http(s):// prefix. Leave it blank if you don't know what it is.",
|
||||||
|
"settings_s3_generatefolderobject": "Generate Folder Object Or Not",
|
||||||
|
"settings_s3_generatefolderobject_desc": "S3 doesn't have \"real\" folder. If you set \"Generate\" here (or use old version), the plugin will upload a zero-byte object endding with \"/\" to represent the folder. In the new version, the plugin skips generating folder object by default.",
|
||||||
|
"settings_s3_generatefolderobject_notgenerate": "Not generate (default)",
|
||||||
|
"settings_s3_generatefolderobject_generate": "Generate",
|
||||||
"settings_s3_connect_succ": "Great! The bucket can be accessed.",
|
"settings_s3_connect_succ": "Great! The bucket can be accessed.",
|
||||||
"settings_s3_connect_fail": "The S3 bucket cannot be reached.",
|
"settings_s3_connect_fail": "The S3 bucket cannot be reached.",
|
||||||
"settings_dropbox": "Remote For Dropbox",
|
"settings_dropbox": "Remote For Dropbox",
|
||||||
@@ -222,8 +234,7 @@
|
|||||||
"settings_webdav_auth": "Auth Type",
|
"settings_webdav_auth": "Auth Type",
|
||||||
"settings_webdav_auth_desc": "If no password, this option would be ignored.",
|
"settings_webdav_auth_desc": "If no password, this option would be ignored.",
|
||||||
"settings_webdav_depth": "Depth Header Sent To Servers",
|
"settings_webdav_depth": "Depth Header Sent To Servers",
|
||||||
"settings_webdav_depth_desc": "Webdav servers should be configured to allow requests with header Depth being '1' or 'Infinity'. The plugin needs to know this info. If you are not sure what's this, choose \"auto\".",
|
"settings_webdav_depth_desc": "Webdav servers should be configured to allow requests with header Depth being '1' or 'Infinity'. If you are not sure what's this, choose \"depth='1'\". If you are sure your server supports depth='infinity', please choose that to get way better performance.",
|
||||||
"settings_webdav_depth_auto": "auto detect",
|
|
||||||
"settings_webdav_depth_1": "only supports depth='1'",
|
"settings_webdav_depth_1": "only supports depth='1'",
|
||||||
"settings_webdav_depth_inf": "supports depth='infinity'",
|
"settings_webdav_depth_inf": "supports depth='infinity'",
|
||||||
"settings_webdav_connect_succ": "Great! The webdav server can be accessed.",
|
"settings_webdav_connect_succ": "Great! The webdav server can be accessed.",
|
||||||
@@ -246,45 +257,65 @@
|
|||||||
"settings_deletetowhere_desc": "Which trash should the plugin put the files into while deleting?",
|
"settings_deletetowhere_desc": "Which trash should the plugin put the files into while deleting?",
|
||||||
"settings_deletetowhere_system_trash": "system trash (default)",
|
"settings_deletetowhere_system_trash": "system trash (default)",
|
||||||
"settings_deletetowhere_obsidian_trash": "Obsidian .trash folder",
|
"settings_deletetowhere_obsidian_trash": "Obsidian .trash folder",
|
||||||
|
"settings_conflictaction": "Action For Conflict",
|
||||||
|
"settings_conflictaction_desc": "If a file is created or modified on both side since last update, it's a conflict event. How to deal with it? This only works for bidirectional sync.",
|
||||||
|
"settings_conflictaction_keep_newer": "newer version survives (default)",
|
||||||
|
"settings_conflictaction_keep_larger": "larger size version survives",
|
||||||
|
"settings_cleanemptyfolder": "Action For Empty Folders",
|
||||||
|
"settings_cleanemptyfolder_desc": "The sync algorithm majorly deals with files, so you need to specify how to deal with empty folders.",
|
||||||
|
"settings_cleanemptyfolder_skip": "leave them as is (default)",
|
||||||
|
"settings_cleanemptyfolder_clean_both": "delete local and remote",
|
||||||
|
"settings_protectmodifypercentage": "Abort Sync If Modification Above Percentage",
|
||||||
|
"settings_protectmodifypercentage_desc": "Abort the sync if more than n% of the files are going to be deleted / modified. Useful to protect users' files from unexpected modifications. You can set to 100 to disable the protection, or set to 0 to always block the sync.",
|
||||||
|
"settings_protectmodifypercentage_000_desc": "0 (always block)",
|
||||||
|
"settings_protectmodifypercentage_050_desc": "50 (default)",
|
||||||
|
"settings_protectmodifypercentage_100_desc": "100 (disable the protection)",
|
||||||
|
"setting_syncdirection": "Sync Direction",
|
||||||
|
"setting_syncdirection_desc": "Which direction should the plugin sync to? Please be aware that only CHANGED files (based on time and size) are synced regardless any option.",
|
||||||
|
"setting_syncdirection_bidirectional_desc": "Bidirectional (default)",
|
||||||
|
"setting_syncdirection_incremental_push_only_desc": "Incremental Push Only (aka backup mode)",
|
||||||
|
"setting_syncdirection_incremental_pull_only_desc": "Incremental Pull Only",
|
||||||
|
"settings_enablemobilestatusbar": "Mobile Status Bar (experimental)",
|
||||||
|
"settings_enablemobilestatusbar_desc": "By default Obsidian mobile hides status bar. But some users want to show it up. So here is a hack.",
|
||||||
"settings_importexport": "Import and Export Partial Settings",
|
"settings_importexport": "Import and Export Partial Settings",
|
||||||
"settings_export": "Export",
|
"settings_export": "Export",
|
||||||
"settings_export_desc": "Export not-oauth2 settings by generating a qrcode.",
|
"settings_export_desc": "Export settings by generating a QR code or URI.",
|
||||||
"settings_export_desc_button": "Get QR Code",
|
"settings_export_all_but_oauth2_button": "Export Non-Oauth2 Part",
|
||||||
|
"settings_export_dropbox_button": "Export Dropbox Part",
|
||||||
|
"settings_export_onedrive_button": "Export OneDrive Part",
|
||||||
"settings_import": "Import",
|
"settings_import": "Import",
|
||||||
"settings_import_desc": "You should open a camera or scan-qrcode app, to manually scan the QR code.",
|
"settings_import_desc": "Paste the exported URI into here and click \"Import\". Or, you can open a camera or scan-qrcode app to scan the QR code.",
|
||||||
|
"settings_import_button": "Import",
|
||||||
|
"settings_import_error_notice": "Your URI string is empty or not correct!",
|
||||||
"settings_debug": "Debug",
|
"settings_debug": "Debug",
|
||||||
"settings_debuglevel": "Alter Console Log Level",
|
"settings_debuglevel": "Alter Notice Level",
|
||||||
"settings_debuglevel_desc": "By default the log level is \"info\". You can change to \"debug\" to get verbose information in console.",
|
"settings_debuglevel_desc": "By default the notice level is \"info\". You can change to \"debug\" to get verbose information while syncing.",
|
||||||
"settings_outputsettingsconsole": "Output Current Settings From Disk To Console",
|
"settings_outputsettingsconsole": "Output Current Settings From Disk To Console",
|
||||||
"settings_outputsettingsconsole_desc": "The settings save on disk in encoded. Click this to see the decoded settings in console.",
|
"settings_outputsettingsconsole_desc": "The settings save on disk in encoded. Click this to see the decoded settings in console.",
|
||||||
"settings_outputsettingsconsole_button": "Output",
|
"settings_outputsettingsconsole_button": "Output",
|
||||||
"settings_outputsettingsconsole_notice": "Finished outputing in console.",
|
"settings_outputsettingsconsole_notice": "Finished outputing in console.",
|
||||||
|
"settings_obfuscatesettingfile": "Obfuscate The Setting File Or Not",
|
||||||
|
"settings_obfuscatesettingfile_desc": "The setting file (data.json) has some sensitive information. It's strongly recommended to obfuscate it to avoid unexpected read and modification. If you are sure to view and edit it manually, you can disable the obfuscation.",
|
||||||
|
"settings_viewconsolelog": "View Console Log",
|
||||||
|
"settings_viewconsolelog_desc": "On desktop, please press \"ctrl+shift+i\" or \"cmd+shift+i\" to view the log. On mobile, please install the third-party plugin <a href='https://obsidian.md/plugins?search=Logstravaganza'>Logstravaganza</a> to export the console log to a note.",
|
||||||
"settings_syncplans": "Export Sync Plans",
|
"settings_syncplans": "Export Sync Plans",
|
||||||
"settings_syncplans_desc": "Sync plans are created every time after you trigger sync and before the actual sync. Useful to know what would actually happen in those sync. Click the button to export sync plans. You can choose to export to json or table format inside Markdown, which are equivalent.",
|
"settings_syncplans_desc": "Sync plans are created every time after you trigger sync and before the actual sync. Useful to know what would actually happen in those sync. Click the button to export sync plans.",
|
||||||
"settings_syncplans_button_json": "Export (json)",
|
"settings_syncplans_button_1": "Export latest 1",
|
||||||
"settings_syncplans_button_table": "Export (table)",
|
"settings_syncplans_button_5": "Export latest 5",
|
||||||
|
"settings_syncplans_button_all": "Export All",
|
||||||
"settings_syncplans_notice": "Sync plans history exported.",
|
"settings_syncplans_notice": "Sync plans history exported.",
|
||||||
"settings_delsyncplans": "Delete Sync Plans History In DB",
|
"settings_delsyncplans": "Delete Sync Plans History In DB",
|
||||||
"settings_delsyncplans_desc": "Delete sync plans history in DB.",
|
"settings_delsyncplans_desc": "Delete sync plans history in DB.",
|
||||||
"settings_delsyncplans_button": "Delete Sync Plans History",
|
"settings_delsyncplans_button": "Delete Sync Plans History",
|
||||||
"settings_delsyncplans_notice": "Sync plans history (in DB) deleted.",
|
"settings_delsyncplans_notice": "Sync plans history (in DB) deleted.",
|
||||||
"settings_logtodb": "Save Console Logs Into DB",
|
"settings_delprevsync": "Delete Prev Sync Details In DB",
|
||||||
"settings_logtodb_desc": "Enable this to save all console logs into DB, so that we can export and read them later. Especially useful for mobile debugging. Has performance cost and may impact battery. The history is saved for at most 10000 records and 7 days old.",
|
"settings_delprevsync_desc": "The sync algorithm keeps the previous successful sync information in DB to determine the file changes. If you want to ignore them so that all files are treated newly created, you can delete the prev sync info here.",
|
||||||
"settings_logtodbexport": "Export Console Logs From DB",
|
"settings_delprevsync_button": "Delete Prev Sync Details",
|
||||||
"settings_logtodbexport_desc": "Export the saved console logs of this vault from DB into a new file in the folder {{&debugFolder}} .",
|
"settings_delprevsync_notice": "Previous sync history (in local DB) deleted",
|
||||||
"settings_logtodbexport_button": "Export",
|
"settings_profiler_results": "Export Profiler Results",
|
||||||
"settings_logtodbexport_notice": "Console logs of this vault from DB are exported!",
|
"settings_profiler_results_desc": "The plugin records the time cost of each steps. Here you can export them to know which step is slow.",
|
||||||
"settings_logtodbclear": "Delete All Console Logs In DB",
|
"settings_profiler_results_notice": "Profiler results exported.",
|
||||||
"settings_logtodbclear_desc": "Delete all the console logs saved inside DB. This setting applies to all saved logs for all vaults in this device.",
|
"settings_profiler_results_button_all": "Export All",
|
||||||
"settings_logtodbclear_button": "Delete",
|
|
||||||
"settings_logtodbclear_notice": "All console logs saved in DB are deleted!",
|
|
||||||
"settings_logtohttpserver": "Log To HTTP Server Temporarily",
|
|
||||||
"settings_logtohttpserver_desc": "It's very dangerous and please use the function with greate cautions!!!!! It will temporarily allow sending console loggins to HTTP server.",
|
|
||||||
"settings_logtohttpserver_reset_notice": "Already removed the setting of logging to HTTP server.",
|
|
||||||
"settings_delsyncmap": "Delete Sync Mappings History In DB",
|
|
||||||
"settings_delsyncmap_desc": "Sync mappings history stores the actual LOCAL last modified time of the REMOTE objects. Clearing it may cause unnecessary data exchanges in next-time sync. Click the button to delete sync mappings history in DB.",
|
|
||||||
"settings_delsyncmap_button": "Delete Sync Mappings",
|
|
||||||
"settings_delsyncmap_notice": "Sync mappings history (in local DB) deleted",
|
|
||||||
"settings_outputbasepathvaultid": "Output Vault Base Path And Randomly Assigned ID",
|
"settings_outputbasepathvaultid": "Output Vault Base Path And Randomly Assigned ID",
|
||||||
"settings_outputbasepathvaultid_desc": "For debugging purposes.",
|
"settings_outputbasepathvaultid_desc": "For debugging purposes.",
|
||||||
"settings_outputbasepathvaultid_button": "Output",
|
"settings_outputbasepathvaultid_button": "Output",
|
||||||
@@ -292,10 +323,11 @@
|
|||||||
"settings_resetcache_desc": "Reset local internal caches/databases (for debugging purposes). You would want to reload the plugin after resetting this. This option will not empty the {s3, password...} settings.",
|
"settings_resetcache_desc": "Reset local internal caches/databases (for debugging purposes). You would want to reload the plugin after resetting this. This option will not empty the {s3, password...} settings.",
|
||||||
"settings_resetcache_button": "Reset",
|
"settings_resetcache_button": "Reset",
|
||||||
"settings_resetcache_notice": "Local internal cache/databases deleted. Please manually reload the plugin.",
|
"settings_resetcache_notice": "Local internal cache/databases deleted. Please manually reload the plugin.",
|
||||||
"syncalgov2_title": "Remotely Save has a better sync algorithm",
|
|
||||||
"syncalgov2_texts": "Welcome to use Remotely Save!\nFrom version 0.3.0, a new algorithm has been developed, but it needs uploading extra meta data files _remotely-save-metadata-on-remote.{json,bin} to YOUR configured cloud destinations, besides your notes.\nSo that, for example, the second device can know that what files/folders have been deleted on the first device by reading those files.\nIf you agree, plase click the button \"Agree\", and enjoy the plugin! AND PLEASE REMEMBER TO BACKUP YOUR VAULT FIRSTLY!\nIf you do not agree, you should stop using the current and later versions of Remotely Save. You could consider manually install the old version 0.2.14 which uses old algorithm and does not upload any extra meta data files. By clicking the \"Do Not Agree\" button, the plugin will unload itself, and you need to manually disable it in Obsidian settings.",
|
|
||||||
"syncalgov2_button_agree": "Agree",
|
|
||||||
"syncalgov2_button_disagree": "Do Not Agree",
|
|
||||||
|
|
||||||
"official_notice_2024_first_party": "Plugin Remotely-Save is back to the party and get a HUGE update!🎉🎉🎉 Try it yourself or see the release note on https://github.com/remotely-save/remotely-save/releases."
|
"syncalgov3_title": "Remotely Save has HUGE updates on the sync algorithm",
|
||||||
|
"syncalgov3_texts": "Welcome to use Remotely Save!\nFrom this version, a new algorithm has been developed:\n<ul><li>More robust deletion sync,</li><li>minimal conflict handling,</li><li>no meta data uploaded any more,</li><li>deletion / modification protection,</li><li>backup mode</li><li>new encryption method</li><li>...</li></ul>\nStay tune for more! A full introduction is in the <a href='https://github.com/remotely-save/remotely-save/tree/master/docs/sync_algorithm/v3/intro.md'>doc website</a>.\nIf you agree to use this, please read and check two checkboxes then click the \"Agree\" button, and enjoy the plugin!\nIf you do not agree, please click the \"Do Not Agree\" button, the plugin will unload itself.\nAlso, please consider <a href='https://github.com/remotely-save/remotely-save'>visit the GitHub repo and star ⭐ it</a>! Or even <a href='https://github.com/remotely-save/donation'>buy me a coffee</a>. Your support is very important to me! Thanks!",
|
||||||
|
"syncalgov3_checkbox_manual_backup": "I will backup my vault manually firstly.",
|
||||||
|
"syncalgov3_checkbox_requiremultidevupdate": "I understand I need to update the plugin ACROSS ALL DEVICES to make them work properly.",
|
||||||
|
"syncalgov3_button_agree": "Agree",
|
||||||
|
"syncalgov3_button_disagree": "Do Not Agree"
|
||||||
}
|
}
|
||||||
|
|||||||
+93
-62
@@ -5,15 +5,15 @@
|
|||||||
"goback": "返回",
|
"goback": "返回",
|
||||||
"submit": "提交",
|
"submit": "提交",
|
||||||
"sometext": "这里有一段文字。",
|
"sometext": "这里有一段文字。",
|
||||||
"syncrun_alreadyrunning": "{{pluginName}} 正处于此阶段:{{syncStatus}}!",
|
"syncrun_alreadyrunning": "{{pluginName}} 正处于此阶段:{{syncStatus}}!中断触发 {{newTriggerSource}}。",
|
||||||
"syncrun_syncingribbon": "{{pluginName}}:正在由 {{triggerSource}} 触发运行",
|
"syncrun_syncingribbon": "{{pluginName}}:正在由 {{triggerSource}} 触发运行",
|
||||||
"syncrun_step0": "0/8 Remotely Save 在空跑(dry run)模式,不会发生实际的文件交换。",
|
"syncrun_step0": "0/8 Remotely Save 在空跑(dry run)模式,不会发生实际的文件交换。",
|
||||||
"syncrun_step1": "1/8 Remotely Save 准备同步({{serviceType}})",
|
"syncrun_step1": "1/8 Remotely Save 准备同步({{serviceType}})",
|
||||||
"syncrun_step2": "2/8 正在获取远端的元数据。",
|
"syncrun_step2": "2/8 正在获取远端的元数据。",
|
||||||
"syncrun_step3": "3/8 正在检查密码正确与否。",
|
"syncrun_step3": "3/8 正在检查密码正确与否。",
|
||||||
"syncrun_passworderr": "检查密码时候出错。",
|
"syncrun_passworderr": "检查密码时候出错。",
|
||||||
"syncrun_step4": "4/8 正在获取远端的额外的元数据。",
|
"syncrun_step4": "4/8 正在获取本地的元数据。",
|
||||||
"syncrun_step5": "5/8 正在获取本地的元数据。",
|
"syncrun_step5": "5/8 正在获取本地上一次同步的元数据。",
|
||||||
"syncrun_step6": "6/8 正在生成同步计划。",
|
"syncrun_step6": "6/8 正在生成同步计划。",
|
||||||
"syncrun_step7": "7/8 Remotely Save 开始发生数据交换!",
|
"syncrun_step7": "7/8 Remotely Save 开始发生数据交换!",
|
||||||
"syncrun_step7skip": "7/8 Remotely Save 在空跑模式,跳过实际数据交换步骤。",
|
"syncrun_step7skip": "7/8 Remotely Save 在空跑模式,跳过实际数据交换步骤。",
|
||||||
@@ -23,13 +23,15 @@
|
|||||||
"syncrun_shortstep2skip": "2/2 Remotely Save 在空跑模式,跳过实际数据交换步骤。",
|
"syncrun_shortstep2skip": "2/2 Remotely Save 在空跑模式,跳过实际数据交换步骤。",
|
||||||
"syncrun_shortstep2": "2/2 Remotely Save 已完成同步!",
|
"syncrun_shortstep2": "2/2 Remotely Save 已完成同步!",
|
||||||
"syncrun_abort": "{{manifestID}}-{{theDate}}:中断同步,同步来源={{triggerSource}},出错阶段={{syncStatus}}",
|
"syncrun_abort": "{{manifestID}}-{{theDate}}:中断同步,同步来源={{triggerSource}},出错阶段={{syncStatus}}",
|
||||||
"protocol_saveqr": " {{manifestName}} 新的非 oauth2 设置保存完成。请重启插件设置页使之生效。",
|
"syncrun_abort_protectmodifypercentage": "中断同步!您设置了不允许 >= {{protectModifyPercentage}}% 的变更,但是现在 {{realModifyDeleteCount}}/{{allFilesCount}}={{percent}}% 的文件会被修改或删除!如果您确认这次同步是您想要的,那么请在设置里修改允许比例。",
|
||||||
|
"protocol_saveqr": " {{manifestName}} 的新设置导入完成。请重启插件设置页使之生效。",
|
||||||
"protocol_callbacknotsupported": "您的 uri callback 暂不支持: {{params}}",
|
"protocol_callbacknotsupported": "您的 uri callback 暂不支持: {{params}}",
|
||||||
"protocol_dropbox_connecting": "正在连接 Dropbox……\n请不要关闭此弹窗。",
|
"protocol_dropbox_connecting": "正在连接 Dropbox……\n请不要关闭此弹窗。",
|
||||||
"protocol_dropbox_connect_succ": "好!我们作为用户 {{username}} 连接上了 Dropbox!",
|
"protocol_dropbox_connect_succ": "好!我们作为用户 {{username}} 连接上了 Dropbox!",
|
||||||
"protocol_dropbox_connect_succ_revoke": "您已作为用户 {{username}} 连接上了。如果您想取消连接,请点击此按钮。",
|
"protocol_dropbox_connect_succ_revoke": "您已作为用户 {{username}} 连接上了。如果您想取消连接,请点击此按钮。",
|
||||||
"protocol_dropbox_connect_fail": "Dropbox 的回调请求有点异常。您是否拒绝了鉴权?",
|
"protocol_dropbox_connect_fail": "Dropbox 的回调请求有点异常。您是否网络不顺畅?您是否拒绝了鉴权?",
|
||||||
"protocol_dropbox_connect_unknown": "不知道如何处理此 callback:{{params}}",
|
"protocol_dropbox_connect_unknown": "不知道如何处理此 callback:{{params}}",
|
||||||
|
"protocol_dropbox_no_modal": "您不是在设置页的弹窗里触发 Dropbox 的。中止操作。",
|
||||||
"protocol_onedrive_connecting": "正在连接 OneDrive……\n请不要关闭此弹窗。",
|
"protocol_onedrive_connecting": "正在连接 OneDrive……\n请不要关闭此弹窗。",
|
||||||
"protocol_onedrive_connect_succ_revoke": "您已作为用户 {{username}} 连接上了。如果您想取消连接,请点击此按钮。",
|
"protocol_onedrive_connect_succ_revoke": "您已作为用户 {{username}} 连接上了。如果您想取消连接,请点击此按钮。",
|
||||||
"protocol_onedrive_connect_fail": "OneDrive 的回调请求有点异常。您是否拒绝了鉴权?",
|
"protocol_onedrive_connect_fail": "OneDrive 的回调请求有点异常。您是否拒绝了鉴权?",
|
||||||
@@ -37,21 +39,24 @@
|
|||||||
"command_startsync": "开始同步(start sync)",
|
"command_startsync": "开始同步(start sync)",
|
||||||
"command_drynrun": "开始同步(空跑模式)(start sync (dry run only))",
|
"command_drynrun": "开始同步(空跑模式)(start sync (dry run only))",
|
||||||
"command_exportsyncplans_json": "导出同步计划为 json 格式(export sync plans in json format)",
|
"command_exportsyncplans_json": "导出同步计划为 json 格式(export sync plans in json format)",
|
||||||
"command_exportsyncplans_table": "导出同步计划为表格格式(export sync plans in table format)",
|
"command_exportsyncplans_1": "导出同步计划(最近 1 次)(export sync plans (latest 1))",
|
||||||
|
"command_exportsyncplans_5": "导出同步计划(最近 5 次)(export sync plans (latest 5))",
|
||||||
|
"command_exportsyncplans_all": "导出同步计划(所有)(export sync plans (all))",
|
||||||
"command_exportlogsindb": "从数据库导出终端日志(export logs saved in db)",
|
"command_exportlogsindb": "从数据库导出终端日志(export logs saved in db)",
|
||||||
|
|
||||||
"statusbar_time_years": "{{time}} 年前",
|
"statusbar_time_years": "{{time}} 年前同步",
|
||||||
"statusbar_time_months": "{{time}} 月前",
|
"statusbar_time_months": "{{time}} 月前同步",
|
||||||
"statusbar_time_weeks": "{{time}} 周前",
|
"statusbar_time_weeks": "{{time}} 周前同步",
|
||||||
"statusbar_time_days": "{{time}} 天前",
|
"statusbar_time_days": "{{time}} 天前同步",
|
||||||
"statusbar_time_hours": "{{time}} 小时前",
|
"statusbar_time_hours": "{{time}} 小时前同步",
|
||||||
"statusbar_time_minutes": "{{time}} 分钟前",
|
"statusbar_time_minutes": "{{time}} 分钟前同步",
|
||||||
"statusbar_time_lessminute": "一分钟之内",
|
"statusbar_time_lessminute": "一分钟之内同步",
|
||||||
"statusbar_lastsync": "上一次同步于:{{time}}",
|
"statusbar_lastsync": "上一次同步于:{{time}}",
|
||||||
|
"statusbar_syncing": "正在同步",
|
||||||
|
"statusbar_now": "刚同步完",
|
||||||
"statusbar_lastsync_label": "上一次同步于:{{date}}",
|
"statusbar_lastsync_label": "上一次同步于:{{date}}",
|
||||||
"statusbar_lastsync_never": "没触发过同步",
|
"statusbar_lastsync_never": "没触发过同步",
|
||||||
"statusbar_lastsync_never_label": "没触发过同步",
|
"statusbar_lastsync_never_label": "没触发过同步",
|
||||||
|
|
||||||
"modal_password_title": "稍等一下,请阅读下文:",
|
"modal_password_title": "稍等一下,请阅读下文:",
|
||||||
"modal_password_shortdesc": "如果密码不是空的,那么文件会在上传之前,在本地先用此密码加密。\n如果密码是空的,那么文件会被非加密地上传。",
|
"modal_password_shortdesc": "如果密码不是空的,那么文件会在上传之前,在本地先用此密码加密。\n如果密码是空的,那么文件会被非加密地上传。",
|
||||||
"modal_password_attn1": "注意 1/5:库(Vault)名字是不会加密的!本插件会在一些远程存储里创建一个和库名字有着同名的文件夹。",
|
"modal_password_attn1": "注意 1/5:库(Vault)名字是不会加密的!本插件会在一些远程存储里创建一个和库名字有着同名的文件夹。",
|
||||||
@@ -61,6 +66,8 @@
|
|||||||
"modal_password_attn5": "注意 5/5:密码越长越好。",
|
"modal_password_attn5": "注意 5/5:密码越长越好。",
|
||||||
"modal_password_secondconfirm": "再次确认保存新密码",
|
"modal_password_secondconfirm": "再次确认保存新密码",
|
||||||
"modal_password_notice": "新密码已保存!",
|
"modal_password_notice": "新密码已保存!",
|
||||||
|
"modal_encryptionmethod_title": "稍等一下,请阅读下文:",
|
||||||
|
"modal_encryptionmethod_shortdesc": "您正在修改加密方式,但是您已经设置了密码。\n修改加密方式之后,您需要<b>手动</b>和<b>完全</b>删除在远端的之前加密过的库文件,然后重新同步(从而重新上传)新的加密文件。",
|
||||||
"modal_remotebasedir_title": "您正在修改远端基文件夹设置",
|
"modal_remotebasedir_title": "您正在修改远端基文件夹设置",
|
||||||
"modal_remotebasedir_shortdesc": "1. 本插件并不会自动在远端把内容从旧文件夹移动到新文件夹。所有内容都会重新同步。\n2. 如果你使得文本输入框为空,那么本设置会被重设回库的文件夹名(默认设置)。\n3. 即使您设置了端对端加密的密码,远端文件夹名称本身也不会被加密。\n4. 某些特殊字符,如“?”、“/”、“\\”是不允许的。文本前后的空格也会被自动删去。",
|
"modal_remotebasedir_shortdesc": "1. 本插件并不会自动在远端把内容从旧文件夹移动到新文件夹。所有内容都会重新同步。\n2. 如果你使得文本输入框为空,那么本设置会被重设回库的文件夹名(默认设置)。\n3. 即使您设置了端对端加密的密码,远端文件夹名称本身也不会被加密。\n4. 某些特殊字符,如“?”、“/”、“\\”是不允许的。文本前后的空格也会被自动删去。",
|
||||||
"modal_remotebasedir_invaliddirhint": "您所输入的内容含有某些特殊字符,如“?”、“/”、“\\”,它们是不允许的。",
|
"modal_remotebasedir_invaliddirhint": "您所输入的内容含有某些特殊字符,如“?”、“/”、“\\”,它们是不允许的。",
|
||||||
@@ -85,6 +92,7 @@
|
|||||||
"modal_dropboxauth_maualinput_conn_succ_revoke": "您已作为用户 {{username}} 连接到 Dropbox。如果您想断开连接,点击此按钮。",
|
"modal_dropboxauth_maualinput_conn_succ_revoke": "您已作为用户 {{username}} 连接到 Dropbox。如果您想断开连接,点击此按钮。",
|
||||||
"modal_dropboxauth_maualinput_conn_fail": "连接 Dropbox 途中出错了。",
|
"modal_dropboxauth_maualinput_conn_fail": "连接 Dropbox 途中出错了。",
|
||||||
"modal_onedriveauth_shortdesc": "现在只支持个人版 OneDrive,(暂)不支持企业版。\n在浏览器中访问以下地址,然后按照网页提示操作。\n到了最后,您应该会被自动重定向回来 Obsidian。",
|
"modal_onedriveauth_shortdesc": "现在只支持个人版 OneDrive,(暂)不支持企业版。\n在浏览器中访问以下地址,然后按照网页提示操作。\n到了最后,您应该会被自动重定向回来 Obsidian。",
|
||||||
|
"modal_onedriveauth_shortdesc_linux": "您正在用 Linux,有可能无法跳转回来。请考虑<a href=\"https://github.com/remotely-save/remotely-save/issues/415\">使用</a> flatpack 版本的 Obsidian,或创建 <a href=\"https://github.com/remotely-save/remotely-save/blob/master/docs/linux.md\"><code>obsidian.desktop</code> 文件</a>。",
|
||||||
"modal_onedriveauth_copybutton": "点击此按钮从而复制鉴权 url",
|
"modal_onedriveauth_copybutton": "点击此按钮从而复制鉴权 url",
|
||||||
"modal_onedriveauth_copynotice": "鉴权 url 已复制到剪贴板!",
|
"modal_onedriveauth_copynotice": "鉴权 url 已复制到剪贴板!",
|
||||||
"modal_onedriverevokeauth_step1": "第 1 步:用浏览器打开以下地址,点击本插件对应的“Edit”按钮,点击“Remove these permissions”按钮。",
|
"modal_onedriverevokeauth_step1": "第 1 步:用浏览器打开以下地址,点击本插件对应的“Edit”按钮,点击“Remove these permissions”按钮。",
|
||||||
@@ -97,20 +105,20 @@
|
|||||||
"modal_syncconfig_attn": "注意 1/2:此设置只同步(复制)整个 Obsidian 的配置文件夹,但是不会同步其它 . 开头的文件夹或文件。除了会忽略 .git 和 node_modules 文件夹之外,它也并不理解配置文件夹的里各个子文件或子文件夹的含义。\n注意 2/2:配置文件夹被同步之后,各插件的设置或许会出错,且 Obsidian 或许需要重启来重载各插件的新配置。\n如果您同意自行承受以上风险,您可以点击以下再次确认按钮。",
|
"modal_syncconfig_attn": "注意 1/2:此设置只同步(复制)整个 Obsidian 的配置文件夹,但是不会同步其它 . 开头的文件夹或文件。除了会忽略 .git 和 node_modules 文件夹之外,它也并不理解配置文件夹的里各个子文件或子文件夹的含义。\n注意 2/2:配置文件夹被同步之后,各插件的设置或许会出错,且 Obsidian 或许需要重启来重载各插件的新配置。\n如果您同意自行承受以上风险,您可以点击以下再次确认按钮。",
|
||||||
"modal_syncconfig_secondconfirm": "再次确认开启",
|
"modal_syncconfig_secondconfirm": "再次确认开启",
|
||||||
"modal_syncconfig_notice": "您已开启配置文件夹的同步!",
|
"modal_syncconfig_notice": "您已开启配置文件夹的同步!",
|
||||||
"modal_qr_shortdesc": "这里可导出非 oauth2 设置。(意味着:Dropbox 和 OneDrive 信息不会被导出。)\n您可以使用另一个设备来扫描此 QR 码。\n又或者,您可以点击以下按钮复制此特殊 URI。",
|
"modal_qr_shortdesc": "这里可导出(部分)设置。\n您可以使用另一个设备来扫描此 QR 码。\n又或者,您可以点击以下按钮复制此特殊 URI,然后粘贴到另一台设备的网络浏览器或 Remotely Save 设置里的导入部分。",
|
||||||
"modal_qr_button": "点击此按钮复制特殊 URI",
|
"modal_qr_button": "点击此按钮复制特殊 URI",
|
||||||
"modal_qr_button_notice": "特殊 URI 已被复制到剪贴板!",
|
"modal_qr_button_notice": "特殊 URI 已被复制到剪贴板!",
|
||||||
"modal_sizesconflict_title": "Remotely Save:跳过大文件的时候出现了一些冲突",
|
"modal_sizesconflict_title": "Remotely Save:跳过大文件的时候出现了一些冲突",
|
||||||
"modal_sizesconflict_desc": "您设置了跳过同步大于 {{thresholdMB}} MB({{thresholdBytes}} bytes)的文件。\n但是以下文件的大小,在一端大于阈值,在另一端则小于阈值。\n为了避免意外的覆盖或删除,插件停止了运作,您需要手动处理至少一端的文件。",
|
"modal_sizesconflict_desc": "您设置了跳过同步大于 {{thresholdMB}} MB({{thresholdBytes}} bytes)的文件。\n但是以下文件的大小,在一端大于阈值,在另一端则小于阈值。\n为了避免意外的覆盖或删除,插件停止了运作,您需要手动处理至少一端的文件。",
|
||||||
"modal_sizesconflict_copybutton": "点击以复制以下所有文件大小冲突信息",
|
"modal_sizesconflict_copybutton": "点击以复制以下所有文件大小冲突信息",
|
||||||
"modal_sizesconflict_copynotice": "所有的文件大小冲突信息,已被复制到剪贴板!",
|
"modal_sizesconflict_copynotice": "所有的文件大小冲突信息,已被复制到剪贴板!",
|
||||||
"modal_logtohttpserver_title": "转发终端日志到 HTTP 服务器,此操作很危险!",
|
|
||||||
"modal_logtohttpserver_desc": "所有您的带敏感信息的终端日志,都会被转发到 HTTP(S) 服务器,没有任何鉴权!!!!!\n请确保您信任对应的服务器,最好设置为 HTTPS 而不是 HTTP。\n仅仅用于 debug 用途,例如手机上的 debug。",
|
|
||||||
"modal_logtohttpserver_secondconfirm": "我知道很危险,坚持要设置,愿意承担所有可能损失。",
|
|
||||||
"modal_logtohttpserver_notice": "已设置。",
|
|
||||||
"settings_basic": "基本设置",
|
"settings_basic": "基本设置",
|
||||||
"settings_password": "密码",
|
"settings_password": "密码",
|
||||||
"settings_password_desc": "端到端加密的密码。不填写则代表没密码。您需要点击“确认”来修改。注意:密码和其它信息都会在本地保存。",
|
"settings_password_desc": "端到端加密的密码。不填写则代表没密码。您需要点击“确认”来修改。注意:密码和其它信息都会在本地保存。如果您修改了密码,您需要手动删除远端的所有文件,重新同步(从而上传)加密文件。",
|
||||||
|
"settings_encryptionmethod": "加密方法",
|
||||||
|
"settings_encryptionmethod_desc": "端到端加密的方法。推荐选用 RClone Crypt 方法,但是它没有加密文件路径结构。OpenSSL enc 是本插件一开始就支持的方式。<b>两种方法都和 RClone、OpenSSL 官方产品和社区无利益相关。</b>如果您修改了加密方法,您需要手动删除远端的所有文件,重新同步(从而上传)加密文件。更多详细说明见<a href='https://github.com/remotely-save/remotely-save/tree/master/docs/encryption'>在线文档</a>。",
|
||||||
|
"settings_encryptionmethod_rclone": "RClone Crypt(推荐)",
|
||||||
|
"settings_encryptionmethod_openssl": "OpenSSL enc(旧方法)",
|
||||||
"settings_autorun": "自动运行",
|
"settings_autorun": "自动运行",
|
||||||
"settings_autorun_desc": "每隔一段时间,此插件尝试自动同步。会影响到电池用量。",
|
"settings_autorun_desc": "每隔一段时间,此插件尝试自动同步。会影响到电池用量。",
|
||||||
"settings_autorun_notset": "(不设置)",
|
"settings_autorun_notset": "(不设置)",
|
||||||
@@ -124,17 +132,13 @@
|
|||||||
"settings_runoncestartup_1sec": "启动后第 1 秒运行一次",
|
"settings_runoncestartup_1sec": "启动后第 1 秒运行一次",
|
||||||
"settings_runoncestartup_10sec": "启动后第 10 秒运行一次",
|
"settings_runoncestartup_10sec": "启动后第 10 秒运行一次",
|
||||||
"settings_runoncestartup_30sec": "启动后第 30 秒运行一次",
|
"settings_runoncestartup_30sec": "启动后第 30 秒运行一次",
|
||||||
"settings_saverun": "保存时同步(实验性质)",
|
"settings_synconsave": "保存时同步(实验性质)",
|
||||||
"settings_saverun_desc": "插件如果检查到当前文件在最近一段时间有修改保存过,则尝试同步。请注意,同步是一个很重的操作,因此会影响到耗电量。(修改设置后可能需要重载插件或重启。)",
|
"settings_synconsave_desc": "插件如果检查到当前文件在最近一段时间有修改保存过,则尝试同步。请注意,同步是一个很重的操作,因此会影响到耗电量。(修改设置后可能需要重载插件或重启。)",
|
||||||
"settings_saverun_notset": "(不设置)",
|
"settings_synconsave_disable": "关闭(默认)",
|
||||||
"settings_saverun_1sec": "隔 1 秒检查一次",
|
"settings_synconsave_enable": "开启",
|
||||||
"settings_saverun_5sec": "隔 5 秒检查一次",
|
|
||||||
"settings_saverun_10sec": "隔 10 秒检查一次(推荐)",
|
|
||||||
"settings_saverun_1min": "隔 1 分钟检查一次",
|
|
||||||
"settings_skiplargefiles": "跳过大文件",
|
"settings_skiplargefiles": "跳过大文件",
|
||||||
"settings_skiplargefiles_desc": "跳过大于某一个阈值的文件。这里 1 MB = 10^6 bytes。",
|
"settings_skiplargefiles_desc": "跳过大于某一个阈值的文件。这里 1 MB = 10^6 bytes。",
|
||||||
"settings_skiplargefiles_notset": "(不设置)",
|
"settings_skiplargefiles_notset": "(不设置)",
|
||||||
|
|
||||||
"settings_ignorepaths": "忽略的文件或文件夹的正则表达式",
|
"settings_ignorepaths": "忽略的文件或文件夹的正则表达式",
|
||||||
"settings_ignorepaths_desc": "忽略的文件或文件夹的正则表达式。每行一条。路径是相对于库(Vault)根目录的,没有前置 / 符号。",
|
"settings_ignorepaths_desc": "忽略的文件或文件夹的正则表达式。每行一条。路径是相对于库(Vault)根目录的,没有前置 / 符号。",
|
||||||
"settings_enablestatusbar_info": "在状态栏显示上一次成功的同步",
|
"settings_enablestatusbar_info": "在状态栏显示上一次成功的同步",
|
||||||
@@ -144,7 +148,6 @@
|
|||||||
"settings_resetstatusbar_time_desc": "重设上一次成功同步的时间记录。",
|
"settings_resetstatusbar_time_desc": "重设上一次成功同步的时间记录。",
|
||||||
"settings_resetstatusbar_button": "重设",
|
"settings_resetstatusbar_button": "重设",
|
||||||
"settings_resetstatusbar_notice": "重设完毕!",
|
"settings_resetstatusbar_notice": "重设完毕!",
|
||||||
|
|
||||||
"settings_checkonnectivity": "检查可否连接",
|
"settings_checkonnectivity": "检查可否连接",
|
||||||
"settings_checkonnectivity_desc": "检查可否连接。",
|
"settings_checkonnectivity_desc": "检查可否连接。",
|
||||||
"settings_checkonnectivity_button": "检查",
|
"settings_checkonnectivity_button": "检查",
|
||||||
@@ -163,7 +166,7 @@
|
|||||||
"settings_s3_prod3": "设置 CORS",
|
"settings_s3_prod3": "设置 CORS",
|
||||||
"settings_s3_endpoint": "服务地址(Endpoint)",
|
"settings_s3_endpoint": "服务地址(Endpoint)",
|
||||||
"settings_s3_region": "区域(Region)",
|
"settings_s3_region": "区域(Region)",
|
||||||
"settings_s3_region_desc": "如果您不知道这个值填什么,可以尝试填写此默认值:us-east-1",
|
"settings_s3_region_desc": "如果您不知道这个值填什么,可以尝试填写:us-east-1。",
|
||||||
"settings_s3_accesskeyid": "Access Key ID",
|
"settings_s3_accesskeyid": "Access Key ID",
|
||||||
"settings_s3_accesskeyid_desc": "Access key ID。注意:access key ID 和其它信息都会在本地保存。",
|
"settings_s3_accesskeyid_desc": "Access key ID。注意:access key ID 和其它信息都会在本地保存。",
|
||||||
"settings_s3_secretaccesskey": "Secret Access Key",
|
"settings_s3_secretaccesskey": "Secret Access Key",
|
||||||
@@ -173,8 +176,16 @@
|
|||||||
"settings_s3_bypasscorslocally_desc": "对于 Obsidian 新版本(桌面版>=0.13.25 或 iOS>=1.1.1 或 Android>=1.2.1),本插件可以跳过服务器设置 CORS 的步骤。如果您遇到任意问题,可以关闭此设定,并在服务端设置 CORS(允许来自 app://obsidian.md 和 capacitor://localhost 和 http://localhost 的请求且增加 ETag 到暴露 headers 里)。",
|
"settings_s3_bypasscorslocally_desc": "对于 Obsidian 新版本(桌面版>=0.13.25 或 iOS>=1.1.1 或 Android>=1.2.1),本插件可以跳过服务器设置 CORS 的步骤。如果您遇到任意问题,可以关闭此设定,并在服务端设置 CORS(允许来自 app://obsidian.md 和 capacitor://localhost 和 http://localhost 的请求且增加 ETag 到暴露 headers 里)。",
|
||||||
"settings_s3_parts": "分块并行度",
|
"settings_s3_parts": "分块并行度",
|
||||||
"settings_s3_parts_desc": "在 S3 里,大文件会被分块上传。您希望同一时间最多有多少个分块被上传?",
|
"settings_s3_parts_desc": "在 S3 里,大文件会被分块上传。您希望同一时间最多有多少个分块被上传?",
|
||||||
|
"settings_s3_accuratemtime": "使用准确的文件修改时间",
|
||||||
|
"settings_s3_accuratemtime_desc": "读取(已上传的)准确的文件修改时间,有助于同步算法更加准确和稳定。但是它也会导致额外的 api 请求、时间、金钱花费。",
|
||||||
"settings_s3_urlstyle": "S3 URL style",
|
"settings_s3_urlstyle": "S3 URL style",
|
||||||
"settings_s3_urlstyle_desc": "是否对 S3 对象强制使用 path style URL(例如使用 https://s3.amazonaws.com/*/ 而不是 https://*.s3.amazonaws.com/)。",
|
"settings_s3_urlstyle_desc": "是否对 S3 对象强制使用 path style URL(例如使用 https://s3.amazonaws.com/*/ 而不是 https://*.s3.amazonaws.com/)。",
|
||||||
|
"settings_s3_reverse_proxy_no_sign_url": "S3 反向代理(不签名)地址(实验性质)",
|
||||||
|
"settings_s3_reverse_proxy_no_sign_url_desc": "不会参与到签名的 S3 反向代理地址。如果您有一个反向代理,但是不想修改原始鉴权签名,这里就可以填写。没有 http(s):// 前缀。如果您不知道这是什么,留空即可。",
|
||||||
|
"settings_s3_generatefolderobject": "是否生成文件夹 Object",
|
||||||
|
"settings_s3_generatefolderobject_desc": "S3 不存在“真正”的文件夹。如果您设置了“生成”(或用了旧版本),那么插件会上传 0 字节的以“/”结尾的 Object 来代表文件夹。新版本插件会默认跳过生成这种文件夹 Object。",
|
||||||
|
"settings_s3_generatefolderobject_notgenerate": "不生成(默认)",
|
||||||
|
"settings_s3_generatefolderobject_generate": "生成",
|
||||||
"settings_s3_connect_succ": "很好!可以访问到对应存储桶。",
|
"settings_s3_connect_succ": "很好!可以访问到对应存储桶。",
|
||||||
"settings_s3_connect_fail": "无法访问到对应存储桶。",
|
"settings_s3_connect_fail": "无法访问到对应存储桶。",
|
||||||
"settings_dropbox": "Dropbox 设置",
|
"settings_dropbox": "Dropbox 设置",
|
||||||
@@ -222,8 +233,7 @@
|
|||||||
"settings_webdav_auth": "鉴权类型",
|
"settings_webdav_auth": "鉴权类型",
|
||||||
"settings_webdav_auth_desc": "如果不填写密码,本设置会被忽略。",
|
"settings_webdav_auth_desc": "如果不填写密码,本设置会被忽略。",
|
||||||
"settings_webdav_depth": "发送到服务器的 Depth header",
|
"settings_webdav_depth": "发送到服务器的 Depth header",
|
||||||
"settings_webdav_depth_desc": "Webdav 服务器会被设为接收 Depth header 为“1”或“Infinity”请求。本插件需要得知此信息。如果您不清楚如何设置,可以选择“自动检测”",
|
"settings_webdav_depth_desc": "Webdav 服务器会被设为接收 Depth header 为“1”或“Infinity”请求。如果您不清楚如何设置,可以选择 depth='1'。如果那你确认服务器支持 depth='infinity',请选择它,会获得更加好的性能。",
|
||||||
"settings_webdav_depth_auto": "自动检测",
|
|
||||||
"settings_webdav_depth_1": "只支持 depth='1'",
|
"settings_webdav_depth_1": "只支持 depth='1'",
|
||||||
"settings_webdav_depth_inf": "支持 depth='infinity'",
|
"settings_webdav_depth_inf": "支持 depth='infinity'",
|
||||||
"settings_webdav_connect_succ": "很好!可以连接上 Webdav 服务器。",
|
"settings_webdav_connect_succ": "很好!可以连接上 Webdav 服务器。",
|
||||||
@@ -246,45 +256,65 @@
|
|||||||
"settings_deletetowhere_desc": "插件触发删除操作时候,删除到哪里?",
|
"settings_deletetowhere_desc": "插件触发删除操作时候,删除到哪里?",
|
||||||
"settings_deletetowhere_system_trash": "系统回收站(默认)",
|
"settings_deletetowhere_system_trash": "系统回收站(默认)",
|
||||||
"settings_deletetowhere_obsidian_trash": "Obsidian .trash 文件夹",
|
"settings_deletetowhere_obsidian_trash": "Obsidian .trash 文件夹",
|
||||||
|
"settings_conflictaction": "处理冲突",
|
||||||
|
"settings_conflictaction_desc": "如果一个文件,在本地和服务器都被创建或者修改了,那么这就是一个“冲突”情况。如何处理?这个设置只在双向同步时候生效。",
|
||||||
|
"settings_conflictaction_keep_newer": "保留最后修改的版本(默认)",
|
||||||
|
"settings_conflictaction_keep_larger": "保留文件体积较大的版本",
|
||||||
|
"settings_cleanemptyfolder": "处理空文件夹",
|
||||||
|
"settings_cleanemptyfolder_desc": "同步算法主要是针对文件处理的,您需要手动指定空文件夹如何处理。",
|
||||||
|
"settings_cleanemptyfolder_skip": "跳过处理空文件夹(默认)",
|
||||||
|
"settings_cleanemptyfolder_clean_both": "删除本地和服务器的空文件夹",
|
||||||
|
"settings_protectmodifypercentage": "如果修改超过百分比则中止同步",
|
||||||
|
"settings_protectmodifypercentage_desc": "如果算法检测到超过 n% 的文件会被修改或删除,则中止同步。从而可以保护用户的文件免受预料之外的修改。您可以设置为 100 而去除此保护,也可以设置为 0 总是强制中止所有同步。",
|
||||||
|
"settings_protectmodifypercentage_000_desc": "0(总是强制中止)",
|
||||||
|
"settings_protectmodifypercentage_050_desc": "50(默认值)",
|
||||||
|
"settings_protectmodifypercentage_100_desc": "100(去除此保护)",
|
||||||
|
"setting_syncdirection": "同步方向",
|
||||||
|
"setting_syncdirection_desc": "插件应该向哪里同步?注意每个选项都是只有修改了的文件(基于修改时间和大小判断)才会触发同步动作。",
|
||||||
|
"setting_syncdirection_bidirectional_desc": "双向同步(默认)",
|
||||||
|
"setting_syncdirection_incremental_push_only_desc": "只增量推送(也即:备份模式)",
|
||||||
|
"setting_syncdirection_incremental_pull_only_desc": "只增量拉取",
|
||||||
|
"settings_enablemobilestatusbar": "手机的状态栏(实验性质)",
|
||||||
|
"settings_enablemobilestatusbar_desc": "Obsidian 手机版默认隐藏了状态栏。有些用户希望展示它。这里提供了设置选项。",
|
||||||
"settings_importexport": "导入导出部分设置",
|
"settings_importexport": "导入导出部分设置",
|
||||||
"settings_export": "导出",
|
"settings_export": "导出",
|
||||||
"settings_export_desc": "用 QR 码导出非 oauth2 的设置信息。",
|
"settings_export_desc": "用 QR 码或 URI 导出设置信息。",
|
||||||
"settings_export_desc_button": "生成 QR 码",
|
"settings_export_all_but_oauth2_button": "导出非 Oauth2 部分",
|
||||||
|
"settings_export_dropbox_button": "导出 Dropbox 部分",
|
||||||
|
"settings_export_onedrive_button": "导出 OneDrive 部分",
|
||||||
"settings_import": "导入",
|
"settings_import": "导入",
|
||||||
"settings_import_desc": "您需要使用系统拍摄 app 或者扫描 QR 码的app,来扫描对应的 QR 码。",
|
"settings_import_desc": "粘贴之前导出的 URI 到这里然后点击“导入”。或,使用拍摄 app 或者扫描 QR 码的 app,来扫描对应的 QR 码。",
|
||||||
|
"settings_import_button": "导入",
|
||||||
|
"settings_import_error_notice": "您输入的 URI 是空的或者不准确的!",
|
||||||
"settings_debug": "调试",
|
"settings_debug": "调试",
|
||||||
"settings_debuglevel": "修改终端输出的 level",
|
"settings_debuglevel": "修改同步提示信息",
|
||||||
"settings_debuglevel_desc": "默认值为 \"info\"。您可以改为 \"debug\" 从而在终端里获取更多信息。",
|
"settings_debuglevel_desc": "默认值为 \"info\"。您可以改为 \"debug\" 从而在同步时候里获取更多信息。",
|
||||||
"settings_outputsettingsconsole": "读取硬盘上的设置文件输出到终端",
|
"settings_outputsettingsconsole": "读取硬盘上的设置文件输出到终端",
|
||||||
"settings_outputsettingsconsole_desc": "硬盘上的设置文件是编码过的,点击这里从而解码并输出到终端。",
|
"settings_outputsettingsconsole_desc": "硬盘上的设置文件是编码过的,点击这里从而解码并输出到终端。",
|
||||||
"settings_outputsettingsconsole_button": "输出",
|
"settings_outputsettingsconsole_button": "输出",
|
||||||
"settings_outputsettingsconsole_notice": "已输出到终端",
|
"settings_outputsettingsconsole_notice": "已输出到终端",
|
||||||
|
"settings_obfuscatesettingfile": "是否混淆保存设置文件",
|
||||||
|
"settings_obfuscatesettingfile_desc": "设置文件(data.json)含有敏感信息。强烈建议混淆后保存它,从而避免出乎意料的读取和修改。如果您确认要手动查看和修改它,可以关闭混淆保存。",
|
||||||
|
"settings_viewconsolelog": "查看终端输出",
|
||||||
|
"settings_viewconsolelog_desc": "电脑上,输入“ctrl+shift+i”或“cmd+shift+i”来查看终端输出。手机上,安装第三方插件 <a href='https://obsidian.md/plugins?search=Logstravaganza'>Logstravaganza</a> 来导出终端输出到一篇笔记上。",
|
||||||
"settings_syncplans": "导出同步计划",
|
"settings_syncplans": "导出同步计划",
|
||||||
"settings_syncplans_desc": "每次您启动同步,并在实际上传下载前,插件会生成同步计划。它可以使您知道每次同步发生了什么。点击按钮可以导出同步计划。并且,您可以选择导出为内嵌在 Markdown 里的 json 格式或表格格式,它们是等价的。",
|
"settings_syncplans_desc": "每次您启动同步,并在实际上传下载前,插件会生成同步计划。它可以使您知道每次同步发生了什么。点击按钮可以导出同步计划。",
|
||||||
"settings_syncplans_button_json": "导出(json)",
|
"settings_syncplans_button_1": "导出最近 1 次",
|
||||||
"settings_syncplans_button_table": "导出(表格)",
|
"settings_syncplans_button_5": "导出最近 5 次",
|
||||||
|
"settings_syncplans_button_all": "导出所有",
|
||||||
"settings_syncplans_notice": "同步计划已导出",
|
"settings_syncplans_notice": "同步计划已导出",
|
||||||
"settings_delsyncplans": "删除数据库里的同步计划历史",
|
"settings_delsyncplans": "删除数据库里的同步计划历史",
|
||||||
"settings_delsyncplans_desc": "删除数据库里的同步计划历史。",
|
"settings_delsyncplans_desc": "删除数据库里的同步计划历史。",
|
||||||
"settings_delsyncplans_button": "删除同步计划历史",
|
"settings_delsyncplans_button": "删除同步计划历史",
|
||||||
"settings_delsyncplans_notice": "(数据库里的)同步计划已被删除。",
|
"settings_delsyncplans_notice": "(数据库里的)同步计划已被删除。",
|
||||||
"settings_logtodb": "保存终端日志到数据库",
|
"settings_delprevsync": "删除数据库里的上次同步明细",
|
||||||
"settings_logtodb_desc": "如果开启此设置,所有终端日志都会保存到数据,从而我们可以在之后导出和阅读它们。对于移动设备的调试较为有用。有性能成本,可能会影响电池用量。历史会保存最多 10000 条和 7 天时间。",
|
"settings_delprevsync_desc": "同步算法需要上次成功同步的信息来决定文件变更,这个信息保存在本地的数据库里。如果您想忽略这些信息从而所有文件都被视为新创建的话,可以在此删除之前的信息。",
|
||||||
"settings_logtodbexport": "从数据库导出终端日志",
|
"settings_delprevsync_button": "删除上次同步明细",
|
||||||
"settings_logtodbexport_desc": "从数据库导出本资料库的已保存的终端日志,输出到文件夹 {{&debugFolder}} 下的一个新文件内。",
|
"settings_delprevsync_notice": "(本地数据库里的)上次同步明细已被删除。",
|
||||||
"settings_logtodbexport_button": "导出",
|
"settings_profiler_results": "导出性能数据记录",
|
||||||
"settings_logtodbexport_notice": "数据库内的本资料库的终端日志已导出!",
|
"settings_profiler_results_desc": "插件记录了每次同步每一步的耗时。这里可以导出记录得知哪一步最慢。",
|
||||||
"settings_logtodbclear": "删除数据库的所有终端日志",
|
"settings_profiler_results_notice": "性能数据已导出",
|
||||||
"settings_logtodbclear_desc": "删除数据库内的所有已保存的终端日志。本设置会影响本设备下所有资料库的所有已保存日志。",
|
"settings_profiler_results_button_all": "导出所有",
|
||||||
"settings_logtodbclear_button": "删除",
|
|
||||||
"settings_logtodbclear_notice": "数据库内的所有终端日志已删除!",
|
|
||||||
"settings_logtohttpserver": "临时设定终端日志实时转发到 HTTP 服务器。",
|
|
||||||
"settings_logtohttpserver_desc": "非常危险,谨慎行动!!!!!临时设定终端日志实时转发到 HTTP 服务器。",
|
|
||||||
"settings_logtohttpserver_reset_notice": "移除了终端日志转发到 HTTP 服务器的设定。",
|
|
||||||
"settings_delsyncmap": "删除数据库里的同步映射历史",
|
|
||||||
"settings_delsyncmap_desc": "同步映射历史存储了本地真正的最后修改时间和远程文件时间的映射。删除之可能会导致下一次同步时发生不必要的数据交换。点击按钮删除数据库里的同步映射历史。",
|
|
||||||
"settings_delsyncmap_button": "删除同步映射历史",
|
|
||||||
"settings_delsyncmap_notice": "(本地数据库里的)同步映射历史已被删除。",
|
|
||||||
"settings_outputbasepathvaultid": "输出资料库对应的位置和随机分配的 ID",
|
"settings_outputbasepathvaultid": "输出资料库对应的位置和随机分配的 ID",
|
||||||
"settings_outputbasepathvaultid_desc": "用于调试。",
|
"settings_outputbasepathvaultid_desc": "用于调试。",
|
||||||
"settings_outputbasepathvaultid_button": "输出",
|
"settings_outputbasepathvaultid_button": "输出",
|
||||||
@@ -292,10 +322,11 @@
|
|||||||
"settings_resetcache_desc": "(出于调试原因)重设本地缓存和数据库。您需要在重设之后重新载入此插件。本重设不会删除 s3,密码……等设定。",
|
"settings_resetcache_desc": "(出于调试原因)重设本地缓存和数据库。您需要在重设之后重新载入此插件。本重设不会删除 s3,密码……等设定。",
|
||||||
"settings_resetcache_button": "重设",
|
"settings_resetcache_button": "重设",
|
||||||
"settings_resetcache_notice": "本地同步缓存和数据库已被删除。请手动重新载入此插件。",
|
"settings_resetcache_notice": "本地同步缓存和数据库已被删除。请手动重新载入此插件。",
|
||||||
"syncalgov2_title": "Remotely Save 的同步算法得到优化",
|
|
||||||
"syncalgov2_texts": "欢迎使用 Remotely Save!\n从版本 0.3.0 开始,它带来了新的同步算法,但是,除了您的笔记之外,它还需要上传额外的带有元信息的文件 _remotely-save-metadata-on-remote.{json,bin} 到您的云服务目的地上。\n从而,比如说,通过读取这些信息,另一台设备可以知道什么文件或文件夹在第一台设备上被删除了。\n如果您同意此策略,请点击按钮 \"同意\",然后开始享用此插件!且特别要注意:使用插件之前,请首先备份好您的库(Vault)!\n如果您不同意此策略,您应该停止使用此版本和之后版本的 Remotely Save。您可以考虑手动安装旧版 0.2.14,它使用旧的同步算法,并不上传额外元信息文件。点击 \"不同意\" 之后,插件会自动停止运行(unload),然后您需要 Obsidian 设置里手动停用(disable)此插件。",
|
|
||||||
"syncalgov2_button_agree": "同意",
|
|
||||||
"syncalgov2_button_disagree": "不同意",
|
|
||||||
|
|
||||||
"official_notice_2024_first_party": "插件 Remotely-Save 回来了,更新了一大堆功能!🎉🎉🎉请自行使用,或参阅更新文档: https://github.com/remotely-save/remotely-save/releases 。"
|
"syncalgov3_title": "Remotely Save 的同步算法有重大更新",
|
||||||
|
"syncalgov3_texts": "欢迎使用 Remotely Save!\n从这个版本开始,插件更新了同步算法:\n<ul><li>更稳健的删除同步</li><li>引入冲突处理</li><li>避免上传元数据</li><li>修改删除保护</li><li>备份模式</li><li>新的加密方式</li><li>……</li></ul>\n敬请期待更多更新!详细介绍请参阅<a href='https://github.com/remotely-save/remotely-save/tree/master/docs/sync_algorithm/v3/intro.md'>文档网站</a>。\n如果您同意使用新版本,请阅读和勾选两个勾选框,然后点击“同意”按钮,开始使用插件吧!\n如果您不同意,请点击“不同意”按钮,插件将自动停止运行(unload)。\n此外,请考虑<a href='https://github.com/remotely-save/remotely-save'>访问 GitHub 页面然后点赞 ⭐</a>!您的支持对我十分重要!谢谢!",
|
||||||
|
"syncalgov3_checkbox_manual_backup": "我将会首先手动备份我的库(Vault)。",
|
||||||
|
"syncalgov3_checkbox_requiremultidevupdate": "我理解,我需要在所有设备上都更新此插件使之正常运行。",
|
||||||
|
"syncalgov3_button_agree": "同意",
|
||||||
|
"syncalgov3_button_disagree": "不同意"
|
||||||
}
|
}
|
||||||
|
|||||||
+94
-63
@@ -5,15 +5,15 @@
|
|||||||
"goback": "返回",
|
"goback": "返回",
|
||||||
"submit": "提交",
|
"submit": "提交",
|
||||||
"sometext": "這裡有一段文字。",
|
"sometext": "這裡有一段文字。",
|
||||||
"syncrun_alreadyrunning": "{{pluginName}} 正處於此階段:{{syncStatus}}!",
|
"syncrun_alreadyrunning": "{{pluginName}} 正處於此階段:{{syncStatus}}! 中斷觸發 {{newTriggerSource}}。",
|
||||||
"syncrun_syncingribbon": "{{pluginName}}:正在由 {{triggerSource}} 觸發執行",
|
"syncrun_syncingribbon": "{{pluginName}}:正在由 {{triggerSource}} 觸發執行",
|
||||||
"syncrun_step0": "0/8 Remotely Save 在空跑(dry run)模式,不會發生實際的檔案交換。",
|
"syncrun_step0": "0/8 Remotely Save 在空跑(dry run)模式,不會發生實際的檔案交換。",
|
||||||
"syncrun_step1": "1/8 Remotely Save 準備同步({{serviceType}})",
|
"syncrun_step1": "1/8 Remotely Save 準備同步({{serviceType}})",
|
||||||
"syncrun_step2": "2/8 正在獲取遠端的元資料。",
|
"syncrun_step2": "2/8 正在獲取遠端的元資料。",
|
||||||
"syncrun_step3": "3/8 正在檢查密碼正確與否。",
|
"syncrun_step3": "3/8 正在檢查密碼正確與否。",
|
||||||
"syncrun_passworderr": "檢查密碼時候出錯。",
|
"syncrun_passworderr": "檢查密碼時候出錯。",
|
||||||
"syncrun_step4": "4/8 正在獲取遠端的額外的元資料。",
|
"syncrun_step4": "4/8 正在獲取本地的元資料。",
|
||||||
"syncrun_step5": "5/8 正在獲取本地的元資料。",
|
"syncrun_step5": "5/8 正在獲取本地上一次同步的元資料。",
|
||||||
"syncrun_step6": "6/8 正在生成同步計劃。",
|
"syncrun_step6": "6/8 正在生成同步計劃。",
|
||||||
"syncrun_step7": "7/8 Remotely Save 開始發生資料交換!",
|
"syncrun_step7": "7/8 Remotely Save 開始發生資料交換!",
|
||||||
"syncrun_step7skip": "7/8 Remotely Save 在空跑模式,跳過實際資料交換步驟。",
|
"syncrun_step7skip": "7/8 Remotely Save 在空跑模式,跳過實際資料交換步驟。",
|
||||||
@@ -23,35 +23,39 @@
|
|||||||
"syncrun_shortstep2skip": "2/2 Remotely Save 在空跑模式,跳過實際資料交換步驟。",
|
"syncrun_shortstep2skip": "2/2 Remotely Save 在空跑模式,跳過實際資料交換步驟。",
|
||||||
"syncrun_shortstep2": "2/2 Remotely Save 已完成同步!",
|
"syncrun_shortstep2": "2/2 Remotely Save 已完成同步!",
|
||||||
"syncrun_abort": "{{manifestID}}-{{theDate}}:中斷同步,同步來源={{triggerSource}},出錯階段={{syncStatus}}",
|
"syncrun_abort": "{{manifestID}}-{{theDate}}:中斷同步,同步來源={{triggerSource}},出錯階段={{syncStatus}}",
|
||||||
"protocol_saveqr": " {{manifestName}} 新的非 oauth2 設定儲存完成。請重啟外掛設定頁使之生效。",
|
"syncrun_abort_protectmodifypercentage": "中斷同步!您設定了不允許 >= {{protectModifyPercentage}}% 的變更,但是現在 {{realModifyDeleteCount}}/{{allFilesCount}}={{percent}}% 的檔案會被修改或刪除!如果您確認這次同步是您想要的,那麼請在設定裡修改允許比例。",
|
||||||
|
"protocol_saveqr": " {{manifestName}} 的新設定匯入完成。請重啟外掛設定頁使之生效。",
|
||||||
"protocol_callbacknotsupported": "您的 uri callback 暫不支援: {{params}}",
|
"protocol_callbacknotsupported": "您的 uri callback 暫不支援: {{params}}",
|
||||||
"protocol_dropbox_connecting": "正在連線 Dropbox……\n請不要關閉此彈窗。",
|
"protocol_dropbox_connecting": "正在連線 Dropbox……\n請不要關閉此彈窗。",
|
||||||
"protocol_dropbox_connect_succ": "好!我們作為使用者 {{username}} 連線上了 Dropbox!",
|
"protocol_dropbox_connect_succ": "好!我們作為使用者 {{username}} 連線上了 Dropbox!",
|
||||||
"protocol_dropbox_connect_succ_revoke": "您已作為使用者 {{username}} 連線上了。如果您想取消連線,請點選此按鈕。",
|
"protocol_dropbox_connect_succ_revoke": "您已作為使用者 {{username}} 連線上了。如果您想取消連線,請點選此按鈕。",
|
||||||
"protocol_dropbox_connect_fail": "Dropbox 的回撥請求有點異常。您是否拒絕了鑑權?",
|
"protocol_dropbox_connect_fail": "Dropbox 的回撥請求有點異常。您是否網路不順暢?您是否拒絕了鑑權?",
|
||||||
"protocol_dropbox_connect_unknown": "不知道如何處理此 callback:{{params}}",
|
"protocol_dropbox_connect_unknown": "不知道如何處理此 callback:{{params}}",
|
||||||
|
"protocol_dropbox_no_modal": "您不是在設定頁的彈窗裡觸發 Dropbox 的。中止操作。",
|
||||||
"protocol_onedrive_connecting": "正在連線 OneDrive……\n請不要關閉此彈窗。",
|
"protocol_onedrive_connecting": "正在連線 OneDrive……\n請不要關閉此彈窗。",
|
||||||
"protocol_onedrive_connect_succ_revoke": "您已作為使用者 {{username}} 連線上了。如果您想取消連線,請點選此按鈕。",
|
"protocol_onedrive_connect_succ_revoke": "您已作為使用者 {{username}} 連線上了。如果您想取消連線,請點選此按鈕。",
|
||||||
"protocol_onedrive_connect_fail": "OneDrive 的回撥請求有點異常。您是否拒絕了鑑權?",
|
"protocol_onedrive_connect_fail": "OneDrive 的回撥請求有點異常。您是否拒絕了鑑權?",
|
||||||
"protocol_onedrive_connect_unknown": "不知道如何處理此 callback:{{params}}",
|
"protocol_onedrive_connect_unknown": "不知道如何處理此 callback:{{params}}",
|
||||||
"command_startsync": "開始同步(start sync)",
|
"command_startsync": "開始同步(start sync)",
|
||||||
"command_drynrun": "開始同步(空跑模式)(start sync (dry run only))",
|
"command_drynrun": "開始同步(空跑模式)(start sync (dry run only))",
|
||||||
"command_exportsyncplans_json": "匯出同步計劃為 json 格式(export sync plans in json format)",
|
"command_exportsyncplans_1": "匯出同步計劃(最近 1 次)(export sync plans (latest 1))",
|
||||||
"command_exportsyncplans_table": "匯出同步計劃為表格格式(export sync plans in table format)",
|
"command_exportsyncplans_5": "匯出同步計劃(最近 5 次)(export sync plans (latest 5))",
|
||||||
|
"command_exportsyncplans_all": "匯出同步計劃(所有)(export sync plans (all))",
|
||||||
"command_exportlogsindb": "從資料庫匯出終端日誌(export logs saved in db)",
|
"command_exportlogsindb": "從資料庫匯出終端日誌(export logs saved in db)",
|
||||||
|
|
||||||
"statusbar_time_years": "{{time}} 年前",
|
"statusbar_time_years": "{{time}} 年前同步",
|
||||||
"statusbar_time_months": "{{time}} 月前",
|
"statusbar_time_months": "{{time}} 月前同步",
|
||||||
"statusbar_time_weeks": "{{time}} 周前",
|
"statusbar_time_weeks": "{{time}} 周前同步",
|
||||||
"statusbar_time_days": "{{time}} 天前",
|
"statusbar_time_days": "{{time}} 天前同步",
|
||||||
"statusbar_time_hours": "{{time}} 小時前",
|
"statusbar_time_hours": "{{time}} 小時前同步",
|
||||||
"statusbar_time_minutes": "{{time}} 分鐘前",
|
"statusbar_time_minutes": "{{time}} 分鐘前同步",
|
||||||
"statusbar_time_lessminute": "一分鐘之內",
|
"statusbar_time_lessminute": "一分鐘之內同步",
|
||||||
"statusbar_lastsync": "上一次同步於:{{time}}",
|
"statusbar_lastsync": "上一次同步於:{{time}}",
|
||||||
|
"statusbar_syncing": "正在同步",
|
||||||
|
"statusbar_now": "剛同步完",
|
||||||
"statusbar_lastsync_label": "上一次同步於:{{date}}",
|
"statusbar_lastsync_label": "上一次同步於:{{date}}",
|
||||||
"statusbar_lastsync_never": "沒觸發過同步",
|
"statusbar_lastsync_never": "沒觸發過同步",
|
||||||
"statusbar_lastsync_never_label": "沒觸發過同步",
|
"statusbar_lastsync_never_label": "沒觸發過同步",
|
||||||
|
|
||||||
"modal_password_title": "稍等一下,請閱讀下文:",
|
"modal_password_title": "稍等一下,請閱讀下文:",
|
||||||
"modal_password_shortdesc": "如果密碼不是空的,那麼檔案會在上傳之前,在本地先用此密碼加密。\n如果密碼是空的,那麼檔案會被非加密地上傳。",
|
"modal_password_shortdesc": "如果密碼不是空的,那麼檔案會在上傳之前,在本地先用此密碼加密。\n如果密碼是空的,那麼檔案會被非加密地上傳。",
|
||||||
"modal_password_attn1": "注意 1/5:儲存庫(Vault)名字是不會加密的!本外掛會在一些遠端儲存裡建立一個和庫名字有著同名的資料夾。",
|
"modal_password_attn1": "注意 1/5:儲存庫(Vault)名字是不會加密的!本外掛會在一些遠端儲存裡建立一個和庫名字有著同名的資料夾。",
|
||||||
@@ -61,6 +65,8 @@
|
|||||||
"modal_password_attn5": "注意 5/5:密碼越長越好。",
|
"modal_password_attn5": "注意 5/5:密碼越長越好。",
|
||||||
"modal_password_secondconfirm": "再次確認儲存新密碼",
|
"modal_password_secondconfirm": "再次確認儲存新密碼",
|
||||||
"modal_password_notice": "新密碼已儲存!",
|
"modal_password_notice": "新密碼已儲存!",
|
||||||
|
"modal_encryptionmethod_title": "稍等一下,請閱讀下文:",
|
||||||
|
"modal_encryptionmethod_shortdesc": "您正在修改加密方式,但是您已經設定了密碼。\n修改加密方式之後,您需要<b>手動</b>和<b>完全</b>刪除在遠端的之前加密過的庫檔案,然後重新同步(從而重新上傳)新的加密檔案。",
|
||||||
"modal_remotebasedir_title": "您正在修改遠端基資料夾設定",
|
"modal_remotebasedir_title": "您正在修改遠端基資料夾設定",
|
||||||
"modal_remotebasedir_shortdesc": "1. 本外掛並不會自動在遠端把內容從舊資料夾移動到新資料夾。所有內容都會重新同步。\n2. 如果你使得文字輸入框為空,那麼本設定會被重設回庫的資料夾名(預設設定)。\n3. 即使您設定了端對端加密的密碼,遠端資料夾名稱本身也不會被加密。\n4. 某些特殊字元,如“?”、“/”、“\\”是不允許的。文字前後的空格也會被自動刪去。",
|
"modal_remotebasedir_shortdesc": "1. 本外掛並不會自動在遠端把內容從舊資料夾移動到新資料夾。所有內容都會重新同步。\n2. 如果你使得文字輸入框為空,那麼本設定會被重設回庫的資料夾名(預設設定)。\n3. 即使您設定了端對端加密的密碼,遠端資料夾名稱本身也不會被加密。\n4. 某些特殊字元,如“?”、“/”、“\\”是不允許的。文字前後的空格也會被自動刪去。",
|
||||||
"modal_remotebasedir_invaliddirhint": "您所輸入的內容含有某些特殊字元,如“?”、“/”、“\\”,它們是不允許的。",
|
"modal_remotebasedir_invaliddirhint": "您所輸入的內容含有某些特殊字元,如“?”、“/”、“\\”,它們是不允許的。",
|
||||||
@@ -85,6 +91,7 @@
|
|||||||
"modal_dropboxauth_maualinput_conn_succ_revoke": "您已作為使用者 {{username}} 連線到 Dropbox。如果您想斷開連線,點選此按鈕。",
|
"modal_dropboxauth_maualinput_conn_succ_revoke": "您已作為使用者 {{username}} 連線到 Dropbox。如果您想斷開連線,點選此按鈕。",
|
||||||
"modal_dropboxauth_maualinput_conn_fail": "連線 Dropbox 途中出錯了。",
|
"modal_dropboxauth_maualinput_conn_fail": "連線 Dropbox 途中出錯了。",
|
||||||
"modal_onedriveauth_shortdesc": "現在只支援個人版 OneDrive,(暫)不支援企業版。\n在瀏覽器中訪問以下地址,然後按照網頁提示操作。\n到了最後,您應該會被自動重定向回來 Obsidian。",
|
"modal_onedriveauth_shortdesc": "現在只支援個人版 OneDrive,(暫)不支援企業版。\n在瀏覽器中訪問以下地址,然後按照網頁提示操作。\n到了最後,您應該會被自動重定向回來 Obsidian。",
|
||||||
|
"modal_onedriveauth_shortdesc_linux": "您正在用 Linux,有可能無法跳轉回來。請考慮<a href=\"https://github.com/remotely-save/remotely-save/issues/415\">使用</a> flatpack 版本的 Obsidian,或建立 <a href=\"https://github.com/remotely-save/remotely-save/blob/master/docs/linux.md\"><code>obsidian.desktop</code> 檔案</a>。",
|
||||||
"modal_onedriveauth_copybutton": "點選此按鈕從而複製鑑權 url",
|
"modal_onedriveauth_copybutton": "點選此按鈕從而複製鑑權 url",
|
||||||
"modal_onedriveauth_copynotice": "鑑權 url 已複製到剪貼簿!",
|
"modal_onedriveauth_copynotice": "鑑權 url 已複製到剪貼簿!",
|
||||||
"modal_onedriverevokeauth_step1": "第 1 步:用瀏覽器開啟以下地址,點選本外掛對應的“Edit”按鈕,點選“Remove these permissions”按鈕。",
|
"modal_onedriverevokeauth_step1": "第 1 步:用瀏覽器開啟以下地址,點選本外掛對應的“Edit”按鈕,點選“Remove these permissions”按鈕。",
|
||||||
@@ -97,20 +104,20 @@
|
|||||||
"modal_syncconfig_attn": "注意 1/2:此設定只同步(複製)整個 Obsidian 的配置資料夾,但是不會同步其它 . 開頭的資料夾或檔案。除了會忽略 .git 和 node_modules 資料夾之外,它也並不理解配置資料夾的裡各個子檔案或子資料夾的含義。\n注意 2/2:配置資料夾被同步之後,各外掛的設定或許會出錯,且 Obsidian 或許需要重啟來過載各外掛的新配置。\n如果您同意自行承受以上風險,您可以點選以下再次確認按鈕。",
|
"modal_syncconfig_attn": "注意 1/2:此設定只同步(複製)整個 Obsidian 的配置資料夾,但是不會同步其它 . 開頭的資料夾或檔案。除了會忽略 .git 和 node_modules 資料夾之外,它也並不理解配置資料夾的裡各個子檔案或子資料夾的含義。\n注意 2/2:配置資料夾被同步之後,各外掛的設定或許會出錯,且 Obsidian 或許需要重啟來過載各外掛的新配置。\n如果您同意自行承受以上風險,您可以點選以下再次確認按鈕。",
|
||||||
"modal_syncconfig_secondconfirm": "再次確認開啟",
|
"modal_syncconfig_secondconfirm": "再次確認開啟",
|
||||||
"modal_syncconfig_notice": "您已開啟配置資料夾的同步!",
|
"modal_syncconfig_notice": "您已開啟配置資料夾的同步!",
|
||||||
"modal_qr_shortdesc": "這裡可匯出非 oauth2 設定。(意味著:Dropbox 和 OneDrive 資訊不會被匯出。)\n您可以使用另一個裝置來掃描此 QR 碼。\n又或者,您可以點選以下按鈕複製此特殊 URI。",
|
"modal_qr_shortdesc": "這裡可匯出(部分)設定。\n您可以使用另一個裝置來掃描此 QR 碼。\n又或者,您可以點選以下按鈕複製此特殊 URI,然後貼上到另一臺裝置的網路瀏覽器或 Remotely Save 設定裡的匯入部分。",
|
||||||
"modal_qr_button": "點選此按鈕複製特殊 URI",
|
"modal_qr_button": "點選此按鈕複製特殊 URI",
|
||||||
"modal_qr_button_notice": "特殊 URI 已被複制到剪貼簿!",
|
"modal_qr_button_notice": "特殊 URI 已被複制到剪貼簿!",
|
||||||
"modal_sizesconflict_title": "Remotely Save:跳過大檔案的時候出現了一些衝突",
|
"modal_sizesconflict_title": "Remotely Save:跳過大檔案的時候出現了一些衝突",
|
||||||
"modal_sizesconflict_desc": "您設定了跳過同步大於 {{thresholdMB}} MB({{thresholdBytes}} bytes)的檔案。\n但是以下檔案的大小,在一端大於閾值,在另一端則小於閾值。\n為了避免意外的覆蓋或刪除,外掛停止了運作,您需要手動處理至少一端的檔案。",
|
"modal_sizesconflict_desc": "您設定了跳過同步大於 {{thresholdMB}} MB({{thresholdBytes}} bytes)的檔案。\n但是以下檔案的大小,在一端大於閾值,在另一端則小於閾值。\n為了避免意外的覆蓋或刪除,外掛停止了運作,您需要手動處理至少一端的檔案。",
|
||||||
"modal_sizesconflict_copybutton": "點選以複製以下所有檔案大小衝突資訊",
|
"modal_sizesconflict_copybutton": "點選以複製以下所有檔案大小衝突資訊",
|
||||||
"modal_sizesconflict_copynotice": "所有的檔案大小衝突資訊,已被複制到剪貼簿!",
|
"modal_sizesconflict_copynotice": "所有的檔案大小衝突資訊,已被複制到剪貼簿!",
|
||||||
"modal_logtohttpserver_title": "轉發終端日誌到 HTTP 伺服器,此操作很危險!",
|
|
||||||
"modal_logtohttpserver_desc": "所有您的帶敏感資訊的終端日誌,都會被轉發到 HTTP(S) 伺服器,沒有任何鑑權!!!!!\n請確保您信任對應的伺服器,最好設定為 HTTPS 而不是 HTTP。\n僅僅用於 debug 用途,例如手機上的 debug。",
|
|
||||||
"modal_logtohttpserver_secondconfirm": "我知道很危險,堅持要設定,願意承擔所有可能損失。",
|
|
||||||
"modal_logtohttpserver_notice": "已設定。",
|
|
||||||
"settings_basic": "基本設定",
|
"settings_basic": "基本設定",
|
||||||
"settings_password": "密碼",
|
"settings_password": "密碼",
|
||||||
"settings_password_desc": "端到端加密的密碼。不填寫則代表沒密碼。您需要點選“確認”來修改。注意:密碼和其它資訊都會在本地儲存。",
|
"settings_password_desc": "端到端加密的密碼。不填寫則代表沒密碼。您需要點選“確認”來修改。注意:密碼和其它資訊都會在本地儲存。如果您修改了密碼,您需要手動刪除遠端的所有檔案,重新同步(從而上傳)加密檔案。",
|
||||||
|
"settings_encryptionmethod": "加密方法",
|
||||||
|
"settings_encryptionmethod_desc": "端到端加密的方法。推薦選用 RClone Crypt 方法,但是它沒有加密檔案路徑結構。OpenSSL enc 是本外掛一開始就支援的方式。<b>兩種方法都和 RClone、OpenSSL 官方產品和社群無利益相關。</b>如果您修改了加密方法,您需要手動刪除遠端的所有檔案,重新同步(從而上傳)加密檔案。更多詳細說明見<a href='https://github.com/remotely-save/remotely-save/tree/master/docs/encryption'>線上文件</a>。",
|
||||||
|
"settings_encryptionmethod_rclone": "RClone Crypt(推薦)",
|
||||||
|
"settings_encryptionmethod_openssl": "OpenSSL enc(舊方法)",
|
||||||
"settings_autorun": "自動執行",
|
"settings_autorun": "自動執行",
|
||||||
"settings_autorun_desc": "每隔一段時間,此外掛嘗試自動同步。會影響到電池用量。",
|
"settings_autorun_desc": "每隔一段時間,此外掛嘗試自動同步。會影響到電池用量。",
|
||||||
"settings_autorun_notset": "(不設定)",
|
"settings_autorun_notset": "(不設定)",
|
||||||
@@ -124,17 +131,13 @@
|
|||||||
"settings_runoncestartup_1sec": "啟動後第 1 秒執行一次",
|
"settings_runoncestartup_1sec": "啟動後第 1 秒執行一次",
|
||||||
"settings_runoncestartup_10sec": "啟動後第 10 秒執行一次",
|
"settings_runoncestartup_10sec": "啟動後第 10 秒執行一次",
|
||||||
"settings_runoncestartup_30sec": "啟動後第 30 秒執行一次",
|
"settings_runoncestartup_30sec": "啟動後第 30 秒執行一次",
|
||||||
"settings_saverun": "儲存時同步(實驗性質)",
|
"settings_synconsave": "儲存時同步(實驗性質)",
|
||||||
"settings_saverun_desc": "外掛如果檢查到當前檔案在最近一段時間有修改儲存過,則嘗試同步。請注意,同步是一個很重的操作,因此會影響到耗電量。(修改設定後可能需要過載外掛或重啟。)",
|
"settings_synconsave_desc": "外掛如果檢查到當前檔案在最近一段時間有修改儲存過,則嘗試同步。請注意,同步是一個很重的操作,因此會影響到耗電量。(修改設定後可能需要過載外掛或重啟。)",
|
||||||
"settings_saverun_notset": "(不設定)",
|
"settings_synconsave_disable": "關閉(預設)",
|
||||||
"settings_saverun_1sec": "隔 1 秒檢查一次",
|
"settings_synconsave_enable": "開啟",
|
||||||
"settings_saverun_5sec": "隔 5 秒檢查一次",
|
|
||||||
"settings_saverun_10sec": "隔 10 秒檢查一次(推薦)",
|
|
||||||
"settings_saverun_1min": "隔 1 分鐘檢查一次",
|
|
||||||
"settings_skiplargefiles": "跳過大檔案",
|
"settings_skiplargefiles": "跳過大檔案",
|
||||||
"settings_skiplargefiles_desc": "跳過大於某一個閾值的檔案。這裡 1 MB = 10^6 bytes。",
|
"settings_skiplargefiles_desc": "跳過大於某一個閾值的檔案。這裡 1 MB = 10^6 bytes。",
|
||||||
"settings_skiplargefiles_notset": "(不設定)",
|
"settings_skiplargefiles_notset": "(不設定)",
|
||||||
|
|
||||||
"settings_ignorepaths": "忽略的檔案或資料夾的正則表示式",
|
"settings_ignorepaths": "忽略的檔案或資料夾的正則表示式",
|
||||||
"settings_ignorepaths_desc": "忽略的檔案或資料夾的正則表示式。每行一條。路徑是相對於庫(Vault)根目錄的,沒有前置 / 符號。",
|
"settings_ignorepaths_desc": "忽略的檔案或資料夾的正則表示式。每行一條。路徑是相對於庫(Vault)根目錄的,沒有前置 / 符號。",
|
||||||
"settings_enablestatusbar_info": "在狀態列顯示上一次成功的同步",
|
"settings_enablestatusbar_info": "在狀態列顯示上一次成功的同步",
|
||||||
@@ -144,7 +147,6 @@
|
|||||||
"settings_resetstatusbar_time_desc": "重設上一次成功同步的時間記錄。",
|
"settings_resetstatusbar_time_desc": "重設上一次成功同步的時間記錄。",
|
||||||
"settings_resetstatusbar_button": "重設",
|
"settings_resetstatusbar_button": "重設",
|
||||||
"settings_resetstatusbar_notice": "重設完畢!",
|
"settings_resetstatusbar_notice": "重設完畢!",
|
||||||
|
|
||||||
"settings_checkonnectivity": "檢查可否連線",
|
"settings_checkonnectivity": "檢查可否連線",
|
||||||
"settings_checkonnectivity_desc": "檢查可否連線。",
|
"settings_checkonnectivity_desc": "檢查可否連線。",
|
||||||
"settings_checkonnectivity_button": "檢查",
|
"settings_checkonnectivity_button": "檢查",
|
||||||
@@ -163,7 +165,7 @@
|
|||||||
"settings_s3_prod3": "設定 CORS",
|
"settings_s3_prod3": "設定 CORS",
|
||||||
"settings_s3_endpoint": "服務地址(Endpoint)",
|
"settings_s3_endpoint": "服務地址(Endpoint)",
|
||||||
"settings_s3_region": "區域(Region)",
|
"settings_s3_region": "區域(Region)",
|
||||||
"settings_s3_region_desc": "如果您不知道這個值填什麼,可以嘗試填寫此預設值:us-east-1",
|
"settings_s3_region_desc": "如果您不知道這個值填什麼,可以嘗試填寫:us-east-1。",
|
||||||
"settings_s3_accesskeyid": "Access Key ID",
|
"settings_s3_accesskeyid": "Access Key ID",
|
||||||
"settings_s3_accesskeyid_desc": "Access key ID。注意:access key ID 和其它資訊都會在本地儲存。",
|
"settings_s3_accesskeyid_desc": "Access key ID。注意:access key ID 和其它資訊都會在本地儲存。",
|
||||||
"settings_s3_secretaccesskey": "Secret Access Key",
|
"settings_s3_secretaccesskey": "Secret Access Key",
|
||||||
@@ -173,8 +175,16 @@
|
|||||||
"settings_s3_bypasscorslocally_desc": "對於 Obsidian 新版本(桌面版>=0.13.25 或 iOS>=1.1.1 或 Android>=1.2.1),本外掛可以跳過伺服器設定 CORS 的步驟。如果您遇到任意問題,可以關閉此設定,並在服務端設定 CORS(允許來自 app://obsidian.md 和 capacitor://localhost 和 http://localhost 的請求且增加 ETag 到暴露 headers 裡)。",
|
"settings_s3_bypasscorslocally_desc": "對於 Obsidian 新版本(桌面版>=0.13.25 或 iOS>=1.1.1 或 Android>=1.2.1),本外掛可以跳過伺服器設定 CORS 的步驟。如果您遇到任意問題,可以關閉此設定,並在服務端設定 CORS(允許來自 app://obsidian.md 和 capacitor://localhost 和 http://localhost 的請求且增加 ETag 到暴露 headers 裡)。",
|
||||||
"settings_s3_parts": "分塊並行度",
|
"settings_s3_parts": "分塊並行度",
|
||||||
"settings_s3_parts_desc": "在 S3 裡,大檔案會被分塊上傳。您希望同一時間最多有多少個分塊被上傳?",
|
"settings_s3_parts_desc": "在 S3 裡,大檔案會被分塊上傳。您希望同一時間最多有多少個分塊被上傳?",
|
||||||
|
"settings_s3_accuratemtime": "使用準確的檔案修改時間",
|
||||||
|
"settings_s3_accuratemtime_desc": "讀取(已上傳的)準確的檔案修改時間,有助於同步演算法更加準確和穩定。但是它也會導致額外的 api 請求、時間、金錢花費。",
|
||||||
"settings_s3_urlstyle": "S3 URL style",
|
"settings_s3_urlstyle": "S3 URL style",
|
||||||
"settings_s3_urlstyle_desc": "是否對 S3 物件強制使用 path style URL(例如使用 https://s3.amazonaws.com/*/ 而不是 https://*.s3.amazonaws.com/)。",
|
"settings_s3_urlstyle_desc": "是否對 S3 物件強制使用 path style URL(例如使用 https://s3.amazonaws.com/*/ 而不是 https://*.s3.amazonaws.com/)。",
|
||||||
|
"settings_s3_reverse_proxy_no_sign_url": "S3 反向代理(不簽名)地址(實驗性質)",
|
||||||
|
"settings_s3_reverse_proxy_no_sign_url_desc": "不會參與到簽名的 S3 反向代理地址。如果您有一個反向代理,但是不想修改原始鑑權簽名,這裡就可以填寫。沒有 http(s):// 字首。如果您不知道這是什麼,留空即可。",
|
||||||
|
"settings_s3_generatefolderobject": "是否生成文件夾 Object",
|
||||||
|
"settings_s3_generatefolderobject_desc": "S3 不存在“真正”的文件夾。如果您設置了“生成”(或用了舊版本),那麼插件會上傳 0 字節的以“/”結尾的 Object 來代表文件夾。新版本插件會默認跳過生成這種文件夾 Object。",
|
||||||
|
"settings_s3_generatefolderobject_notgenerate": "不生成(默認)",
|
||||||
|
"settings_s3_generatefolderobject_generate": "生成",
|
||||||
"settings_s3_connect_succ": "很好!可以訪問到對應儲存桶。",
|
"settings_s3_connect_succ": "很好!可以訪問到對應儲存桶。",
|
||||||
"settings_s3_connect_fail": "無法訪問到對應儲存桶。",
|
"settings_s3_connect_fail": "無法訪問到對應儲存桶。",
|
||||||
"settings_dropbox": "Dropbox 設定",
|
"settings_dropbox": "Dropbox 設定",
|
||||||
@@ -222,8 +232,7 @@
|
|||||||
"settings_webdav_auth": "鑑權型別",
|
"settings_webdav_auth": "鑑權型別",
|
||||||
"settings_webdav_auth_desc": "如果不填寫密碼,本設定會被忽略。",
|
"settings_webdav_auth_desc": "如果不填寫密碼,本設定會被忽略。",
|
||||||
"settings_webdav_depth": "傳送到伺服器的 Depth header",
|
"settings_webdav_depth": "傳送到伺服器的 Depth header",
|
||||||
"settings_webdav_depth_desc": "Webdav 伺服器會被設為接收 Depth header 為“1”或“Infinity”請求。本外掛需要得知此資訊。如果您不清楚如何設定,可以選擇“自動檢測”",
|
"settings_webdav_depth_desc": "Webdav 伺服器會被設為接收 Depth header 為“1”或“Infinity”請求。如果您不清楚如何設定,可以選擇 depth='1'。如果那你確認伺服器支援 depth='infinity',請選擇它,會獲得更加好的效能。",
|
||||||
"settings_webdav_depth_auto": "自動檢測",
|
|
||||||
"settings_webdav_depth_1": "只支援 depth='1'",
|
"settings_webdav_depth_1": "只支援 depth='1'",
|
||||||
"settings_webdav_depth_inf": "支援 depth='infinity'",
|
"settings_webdav_depth_inf": "支援 depth='infinity'",
|
||||||
"settings_webdav_connect_succ": "很好!可以連線上 Webdav 伺服器。",
|
"settings_webdav_connect_succ": "很好!可以連線上 Webdav 伺服器。",
|
||||||
@@ -246,45 +255,65 @@
|
|||||||
"settings_deletetowhere_desc": "外掛觸發刪除操作時候,刪除到哪裡?",
|
"settings_deletetowhere_desc": "外掛觸發刪除操作時候,刪除到哪裡?",
|
||||||
"settings_deletetowhere_system_trash": "系統回收站(預設)",
|
"settings_deletetowhere_system_trash": "系統回收站(預設)",
|
||||||
"settings_deletetowhere_obsidian_trash": "Obsidian .trash 資料夾",
|
"settings_deletetowhere_obsidian_trash": "Obsidian .trash 資料夾",
|
||||||
|
"settings_conflictaction": "處理衝突",
|
||||||
|
"settings_conflictaction_desc": "如果一個檔案,在本地和伺服器都被建立或者修改了,那麼這就是一個“衝突”情況。如何處理?這個設定只在雙向同步時候生效。",
|
||||||
|
"settings_conflictaction_keep_newer": "保留最後修改的版本(預設)",
|
||||||
|
"settings_conflictaction_keep_larger": "保留檔案體積較大的版本",
|
||||||
|
"settings_cleanemptyfolder": "處理空資料夾",
|
||||||
|
"settings_cleanemptyfolder_desc": "同步演算法主要是針對檔案處理的,您需要手動指定空資料夾如何處理。",
|
||||||
|
"settings_cleanemptyfolder_skip": "跳過處理空資料夾(預設)",
|
||||||
|
"settings_cleanemptyfolder_clean_both": "刪除本地和伺服器的空資料夾",
|
||||||
|
"settings_protectmodifypercentage": "如果修改超過百分比則中止同步",
|
||||||
|
"settings_protectmodifypercentage_desc": "如果演算法檢測到超過 n% 的檔案會被修改或刪除,則中止同步。從而可以保護使用者的檔案免受預料之外的修改。您可以設定為 100 而去除此保護,也可以設定為 0 總是強制中止所有同步。",
|
||||||
|
"settings_protectmodifypercentage_000_desc": "0(總是強制中止)",
|
||||||
|
"settings_protectmodifypercentage_050_desc": "50(預設值)",
|
||||||
|
"settings_protectmodifypercentage_100_desc": "100(去除此保護)",
|
||||||
|
"setting_syncdirection": "同步方向",
|
||||||
|
"setting_syncdirection_desc": "外掛應該向哪裡同步?注意每個選項都是隻有修改了的檔案(基於修改時間和大小判斷)才會觸發同步動作。",
|
||||||
|
"setting_syncdirection_bidirectional_desc": "雙向同步(預設)",
|
||||||
|
"setting_syncdirection_incremental_push_only_desc": "只增量推送(也即:備份模式)",
|
||||||
|
"setting_syncdirection_incremental_pull_only_desc": "只增量拉取",
|
||||||
|
"settings_enablemobilestatusbar": "手機的狀態列(實驗性質)",
|
||||||
|
"settings_enablemobilestatusbar_desc": "Obsidian 手機版預設隱藏了狀態列。有些使用者希望展示它。這裡提供了設定選項。",
|
||||||
"settings_importexport": "匯入匯出部分設定",
|
"settings_importexport": "匯入匯出部分設定",
|
||||||
"settings_export": "匯出",
|
"settings_export": "匯出",
|
||||||
"settings_export_desc": "用 QR 碼匯出非 oauth2 的設定資訊。",
|
"settings_export_desc": "用 QR 碼或 URI 匯出設定資訊。",
|
||||||
"settings_export_desc_button": "生成 QR 碼",
|
"settings_export_all_but_oauth2_button": "匯出非 Oauth2 部分",
|
||||||
|
"settings_export_dropbox_button": "匯出 Dropbox 部分",
|
||||||
|
"settings_export_onedrive_button": "匯出 OneDrive 部分",
|
||||||
"settings_import": "匯入",
|
"settings_import": "匯入",
|
||||||
"settings_import_desc": "您需要使用系統拍攝 app 或者掃描 QR 碼的app,來掃描對應的 QR 碼。",
|
"settings_import_desc": "貼上之前匯出的 URI 到這裡然後點選“匯入”。或,使用拍攝 app 或者掃描 QR 碼的 app,來掃描對應的 QR 碼。",
|
||||||
|
"settings_import_button": "匯入",
|
||||||
|
"settings_import_error_notice": "您輸入的 URI 是空的或者不準確的!",
|
||||||
"settings_debug": "除錯",
|
"settings_debug": "除錯",
|
||||||
"settings_debuglevel": "修改終端輸出的 level",
|
"settings_debuglevel": "修改同步提示資訊",
|
||||||
"settings_debuglevel_desc": "預設值為 \"info\"。您可以改為 \"debug\" 從而在終端裡獲取更多資訊。",
|
"settings_debuglevel_desc": "預設值為 \"info\"。您可以改為 \"debug\" 從而在同步時候裡獲取更多資訊。",
|
||||||
"settings_outputsettingsconsole": "讀取硬碟上的設定檔案輸出到終端",
|
"settings_outputsettingsconsole": "讀取硬碟上的設定檔案輸出到終端",
|
||||||
"settings_outputsettingsconsole_desc": "硬碟上的設定檔案是編碼過的,點選這裡從而解碼並輸出到終端。",
|
"settings_outputsettingsconsole_desc": "硬碟上的設定檔案是編碼過的,點選這裡從而解碼並輸出到終端。",
|
||||||
"settings_outputsettingsconsole_button": "輸出",
|
"settings_outputsettingsconsole_button": "輸出",
|
||||||
"settings_outputsettingsconsole_notice": "已輸出到終端",
|
"settings_outputsettingsconsole_notice": "已輸出到終端",
|
||||||
|
"settings_obfuscatesettingfile": "是否混淆儲存設定檔案",
|
||||||
|
"settings_obfuscatesettingfile_desc": "設定檔案(data.json)含有敏感資訊。強烈建議混淆後儲存它,從而避免出乎意料的讀取和修改。如果您確認要手動檢視和修改它,可以關閉混淆儲存。",
|
||||||
|
"settings_viewconsolelog": "檢視終端輸出",
|
||||||
|
"settings_viewconsolelog_desc": "電腦上,輸入“ctrl+shift+i”或“cmd+shift+i”來檢視終端輸出。手機上,安裝第三方外掛 <a href='https://obsidian.md/plugins?search=Logstravaganza'>Logstravaganza</a> 來匯出終端輸出到一篇筆記上。",
|
||||||
"settings_syncplans": "匯出同步計劃",
|
"settings_syncplans": "匯出同步計劃",
|
||||||
"settings_syncplans_desc": "每次您啟動同步,並在實際上傳下載前,外掛會生成同步計劃。它可以使您知道每次同步發生了什麼。點選按鈕可以匯出同步計劃。並且,您可以選擇匯出為內嵌在 Markdown 裡的 json 格式或表格格式,它們是等價的。",
|
"settings_syncplans_desc": "每次您啟動同步,並在實際上傳下載前,外掛會生成同步計劃。它可以使您知道每次同步發生了什麼。點選按鈕可以匯出同步計劃。",
|
||||||
"settings_syncplans_button_json": "匯出(json)",
|
"settings_syncplans_button_1": "匯出最近 1 次",
|
||||||
"settings_syncplans_button_table": "匯出(表格)",
|
"settings_syncplans_button_5": "匯出最近 5 次",
|
||||||
|
"settings_syncplans_button_all": "匯出所有",
|
||||||
"settings_syncplans_notice": "同步計劃已匯出",
|
"settings_syncplans_notice": "同步計劃已匯出",
|
||||||
"settings_delsyncplans": "刪除資料庫裡的同步計劃歷史",
|
"settings_delsyncplans": "刪除資料庫裡的同步計劃歷史",
|
||||||
"settings_delsyncplans_desc": "刪除資料庫裡的同步計劃歷史。",
|
"settings_delsyncplans_desc": "刪除資料庫裡的同步計劃歷史。",
|
||||||
"settings_delsyncplans_button": "刪除同步計劃歷史",
|
"settings_delsyncplans_button": "刪除同步計劃歷史",
|
||||||
"settings_delsyncplans_notice": "(資料庫裡的)同步計劃已被刪除。",
|
"settings_delsyncplans_notice": "(資料庫裡的)同步計劃已被刪除。",
|
||||||
"settings_logtodb": "儲存終端日誌到資料庫",
|
"settings_delprevsync": "刪除資料庫裡的上次同步明細",
|
||||||
"settings_logtodb_desc": "如果開啟此設定,所有終端日誌都會儲存到資料,從而我們可以在之後匯出和閱讀它們。對於移動裝置的除錯較為有用。有效能成本,可能會影響電池用量。歷史會儲存最多 10000 條和 7 天時間。",
|
"settings_delprevsync_desc": "同步演算法需要上次成功同步的資訊來決定檔案變更,這個資訊儲存在本地的資料庫裡。如果您想忽略這些資訊從而所有檔案都被視為新建立的話,可以在此刪除之前的資訊。",
|
||||||
"settings_logtodbexport": "從資料庫匯出終端日誌",
|
"settings_delprevsync_button": "刪除上次同步明細",
|
||||||
"settings_logtodbexport_desc": "從資料庫匯出本資料庫的已儲存的終端日誌,輸出到資料夾 {{&debugFolder}} 下的一個新檔案內。",
|
"settings_delprevsync_notice": "(本地資料庫裡的)上次同步明細已被刪除。",
|
||||||
"settings_logtodbexport_button": "匯出",
|
"settings_profiler_results": "匯出效能資料記錄",
|
||||||
"settings_logtodbexport_notice": "資料庫內的本資料庫的終端日誌已匯出!",
|
"settings_profiler_results_desc": "外掛記錄了每次同步每一步的耗時。這裡可以匯出記錄得知哪一步最慢。",
|
||||||
"settings_logtodbclear": "刪除資料庫的所有終端日誌",
|
"settings_profiler_results_notice": "效能資料已匯出",
|
||||||
"settings_logtodbclear_desc": "刪除資料庫內的所有已儲存的終端日誌。本設定會影響本裝置下所有資料庫的所有已儲存日誌。",
|
"settings_profiler_results_button_all": "匯出所有",
|
||||||
"settings_logtodbclear_button": "刪除",
|
|
||||||
"settings_logtodbclear_notice": "資料庫內的所有終端日誌已刪除!",
|
|
||||||
"settings_logtohttpserver": "臨時設定終端日誌實時轉發到 HTTP 伺服器。",
|
|
||||||
"settings_logtohttpserver_desc": "非常危險,謹慎行動!!!!!臨時設定終端日誌實時轉發到 HTTP 伺服器。",
|
|
||||||
"settings_logtohttpserver_reset_notice": "移除了終端日誌轉發到 HTTP 伺服器的設定。",
|
|
||||||
"settings_delsyncmap": "刪除資料庫裡的同步對映歷史",
|
|
||||||
"settings_delsyncmap_desc": "同步對映歷史儲存了本地真正的最後修改時間和遠端檔案時間的對映。刪除之可能會導致下一次同步時發生不必要的資料交換。點選按鈕刪除資料庫裡的同步對映歷史。",
|
|
||||||
"settings_delsyncmap_button": "刪除同步對映歷史",
|
|
||||||
"settings_delsyncmap_notice": "(本地資料庫裡的)同步對映歷史已被刪除。",
|
|
||||||
"settings_outputbasepathvaultid": "輸出資料庫對應的位置和隨機分配的 ID",
|
"settings_outputbasepathvaultid": "輸出資料庫對應的位置和隨機分配的 ID",
|
||||||
"settings_outputbasepathvaultid_desc": "用於除錯。",
|
"settings_outputbasepathvaultid_desc": "用於除錯。",
|
||||||
"settings_outputbasepathvaultid_button": "輸出",
|
"settings_outputbasepathvaultid_button": "輸出",
|
||||||
@@ -292,9 +321,11 @@
|
|||||||
"settings_resetcache_desc": "(出於除錯原因)重設本地快取和資料庫。您需要在重設之後重新載入此外掛。本重設不會刪除 s3,密碼……等設定。",
|
"settings_resetcache_desc": "(出於除錯原因)重設本地快取和資料庫。您需要在重設之後重新載入此外掛。本重設不會刪除 s3,密碼……等設定。",
|
||||||
"settings_resetcache_button": "重設",
|
"settings_resetcache_button": "重設",
|
||||||
"settings_resetcache_notice": "本地同步快取和資料庫已被刪除。請手動重新載入此外掛。",
|
"settings_resetcache_notice": "本地同步快取和資料庫已被刪除。請手動重新載入此外掛。",
|
||||||
"syncalgov2_title": "Remotely Save 的同步演算法得到最佳化",
|
|
||||||
"syncalgov2_texts": "歡迎使用 Remotely Save!\n從版本 0.3.0 開始,它帶來了新的同步演算法,但是,除了您的筆記之外,它還需要上傳額外的帶有元資訊的檔案 _remotely-save-metadata-on-remote.{json,bin} 到您的雲服務目的地上。\n從而,比如說,透過讀取這些資訊,另一臺裝置可以知道什麼檔案或資料夾在第一臺裝置上被刪除了。\n如果您同意此策略,請點選按鈕 \"同意\",然後開始享用此外掛!且特別要注意:使用外掛之前,請首先備份好您的儲存庫(Vault)!\n如果您不同意此策略,您應該停止使用此版本和之後版本的 Remotely Save。您可以考慮手動安裝舊版 0.2.14,它使用舊的同步演算法,並不上傳額外元資訊檔案。點選 \"不同意\" 之後,外掛會自動停止執行(unload),然後您需要 Obsidian 設定裡手動停用(disable)此外掛。",
|
"syncalgov3_title": "Remotely Save 的同步演算法有重大更新",
|
||||||
"syncalgov2_button_agree": "同意",
|
"syncalgov3_texts": "歡迎使用 Remotely Save!\n從這個版本開始,外掛更新了同步演算法:\n<ul><li>更穩健的刪除同步</li><li>引入衝突處理</li><li>避免上傳元資料</li><li>修改刪除保護</li><li>備份模式</li><li>新的加密方式</li><li>……</li></ul>\n敬請期待更多更新!詳細介紹請參閱<a href='https://github.com/remotely-save/remotely-save/tree/master/docs/sync_algorithm/v3/intro.md'>文件網站</a>。\n如果您同意使用新版本,請閱讀和勾選兩個勾選框,然後點選“同意”按鈕,開始使用外掛吧!\n如果您不同意,請點選“不同意”按鈕,外掛將自動停止執行(unload)。\n此外,請考慮<a href='https://github.com/remotely-save/remotely-save'>訪問 GitHub 頁面然後點贊 ⭐</a>!您的支援對我十分重要!謝謝!",
|
||||||
"syncalgov2_button_disagree": "不同意",
|
"syncalgov3_checkbox_manual_backup": "我將會首先手動備份我的庫(Vault)。",
|
||||||
"official_notice_2024_first_party": "外掛 Remotely-Save 回來了,更新了一大堆功能!🎉🎉🎉請自行使用,或參閱更新文件: https://github.com/remotely-save/remotely-save/releases 。"
|
"syncalgov3_checkbox_requiremultidevupdate": "我理解,我需要在所有裝置上都更新此外掛使之正常執行。",
|
||||||
|
"syncalgov3_button_agree": "同意",
|
||||||
|
"syncalgov3_button_disagree": "不同意"
|
||||||
}
|
}
|
||||||
|
|||||||
+263
-470
@@ -1,37 +1,37 @@
|
|||||||
import localforage from "localforage";
|
import localforage from "localforage";
|
||||||
|
import { extendPrototype } from "localforage-getitems";
|
||||||
|
extendPrototype(localforage);
|
||||||
export type LocalForage = typeof localforage;
|
export type LocalForage = typeof localforage;
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { requireApiVersion, TAbstractFile, TFile, TFolder } from "obsidian";
|
|
||||||
|
|
||||||
import { API_VER_STAT_FOLDER, SUPPORTED_SERVICES_TYPE } from "./baseTypes";
|
import type { Entity, MixedEntity, SUPPORTED_SERVICES_TYPE } from "./baseTypes";
|
||||||
import type { SyncPlanType } from "./sync";
|
import type { SyncPlanType } from "./sync";
|
||||||
import { statFix, toText, unixTimeToStr } from "./misc";
|
import { unixTimeToStr } from "./misc";
|
||||||
|
|
||||||
import { log } from "./moreOnLog";
|
const DB_VERSION_NUMBER_IN_HISTORY = [20211114, 20220108, 20220326, 20240220];
|
||||||
|
export const DEFAULT_DB_VERSION_NUMBER: number = 20240220;
|
||||||
const DB_VERSION_NUMBER_IN_HISTORY = [20211114, 20220108, 20220326];
|
|
||||||
export const DEFAULT_DB_VERSION_NUMBER: number = 20220326;
|
|
||||||
export const DEFAULT_DB_NAME = "remotelysavedb";
|
export const DEFAULT_DB_NAME = "remotelysavedb";
|
||||||
export const DEFAULT_TBL_VERSION = "schemaversion";
|
export const DEFAULT_TBL_VERSION = "schemaversion";
|
||||||
export const DEFAULT_TBL_FILE_HISTORY = "filefolderoperationhistory";
|
|
||||||
export const DEFAULT_TBL_SYNC_MAPPING = "syncmetadatahistory";
|
|
||||||
export const DEFAULT_SYNC_PLANS_HISTORY = "syncplanshistory";
|
export const DEFAULT_SYNC_PLANS_HISTORY = "syncplanshistory";
|
||||||
export const DEFAULT_TBL_VAULT_RANDOM_ID_MAPPING = "vaultrandomidmapping";
|
export const DEFAULT_TBL_VAULT_RANDOM_ID_MAPPING = "vaultrandomidmapping";
|
||||||
export const DEFAULT_TBL_LOGGER_OUTPUT = "loggeroutput";
|
export const DEFAULT_TBL_LOGGER_OUTPUT = "loggeroutput";
|
||||||
export const DEFAULT_TBL_SIMPLE_KV_FOR_MISC = "simplekvformisc";
|
export const DEFAULT_TBL_SIMPLE_KV_FOR_MISC = "simplekvformisc";
|
||||||
|
export const DEFAULT_TBL_PREV_SYNC_RECORDS = "prevsyncrecords";
|
||||||
|
export const DEFAULT_TBL_PROFILER_RESULTS = "profilerresults";
|
||||||
|
|
||||||
export interface FileFolderHistoryRecord {
|
/**
|
||||||
key: string;
|
* @deprecated
|
||||||
ctime: number;
|
*/
|
||||||
mtime: number;
|
export const DEFAULT_TBL_FILE_HISTORY = "filefolderoperationhistory";
|
||||||
size: number;
|
/**
|
||||||
actionWhen: number;
|
* @deprecated
|
||||||
actionType: "delete" | "rename" | "renameDestination";
|
*/
|
||||||
keyType: "folder" | "file";
|
export const DEFAULT_TBL_SYNC_MAPPING = "syncmetadatahistory";
|
||||||
renameTo: string;
|
|
||||||
vaultRandomID: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
* But we cannot remove it. Because we want to migrate the old data.
|
||||||
|
*/
|
||||||
interface SyncMetaMappingRecord {
|
interface SyncMetaMappingRecord {
|
||||||
localKey: string;
|
localKey: string;
|
||||||
remoteKey: string;
|
remoteKey: string;
|
||||||
@@ -54,132 +54,119 @@ interface SyncPlanRecord {
|
|||||||
|
|
||||||
export interface InternalDBs {
|
export interface InternalDBs {
|
||||||
versionTbl: LocalForage;
|
versionTbl: LocalForage;
|
||||||
fileHistoryTbl: LocalForage;
|
|
||||||
syncMappingTbl: LocalForage;
|
|
||||||
syncPlansTbl: LocalForage;
|
syncPlansTbl: LocalForage;
|
||||||
vaultRandomIDMappingTbl: LocalForage;
|
vaultRandomIDMappingTbl: LocalForage;
|
||||||
loggerOutputTbl: LocalForage;
|
loggerOutputTbl: LocalForage;
|
||||||
simpleKVForMiscTbl: LocalForage;
|
simpleKVForMiscTbl: LocalForage;
|
||||||
|
prevSyncRecordsTbl: LocalForage;
|
||||||
|
profilerResultsTbl: LocalForage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
* But we cannot remove it. Because we want to migrate the old data.
|
||||||
|
*/
|
||||||
|
fileHistoryTbl: LocalForage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
* But we cannot remove it. Because we want to migrate the old data.
|
||||||
|
*/
|
||||||
|
syncMappingTbl: LocalForage;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This migration mainly aims to assign vault name or vault id into all tables.
|
* TODO
|
||||||
* @param db
|
* @param syncMappings
|
||||||
* @param vaultRandomID
|
* @returns
|
||||||
*/
|
*/
|
||||||
const migrateDBsFrom20211114To20220108 = async (
|
const fromSyncMappingsToPrevSyncRecords = (
|
||||||
db: InternalDBs,
|
oldSyncMappings: SyncMetaMappingRecord[]
|
||||||
vaultRandomID: string
|
): Entity[] => {
|
||||||
) => {
|
const res: Entity[] = [];
|
||||||
const oldVer = 20211114;
|
for (const oldMapping of oldSyncMappings) {
|
||||||
const newVer = 20220108;
|
const newEntity: Entity = {
|
||||||
log.debug(`start upgrading internal db from ${oldVer} to ${newVer}`);
|
key: oldMapping.localKey,
|
||||||
|
keyEnc: oldMapping.remoteKey,
|
||||||
|
keyRaw:
|
||||||
|
oldMapping.remoteKey !== undefined && oldMapping.remoteKey !== ""
|
||||||
|
? oldMapping.remoteKey
|
||||||
|
: oldMapping.localKey,
|
||||||
|
mtimeCli: oldMapping.localMtime,
|
||||||
|
mtimeSvr: oldMapping.remoteMtime,
|
||||||
|
size: oldMapping.localSize,
|
||||||
|
sizeEnc: oldMapping.remoteSize,
|
||||||
|
sizeRaw:
|
||||||
|
oldMapping.remoteKey !== undefined && oldMapping.remoteKey !== ""
|
||||||
|
? oldMapping.remoteSize
|
||||||
|
: oldMapping.localSize,
|
||||||
|
etag: oldMapping.remoteExtraKey,
|
||||||
|
};
|
||||||
|
|
||||||
const allPromisesToWait: Promise<any>[] = [];
|
res.push(newEntity);
|
||||||
|
|
||||||
log.debug("assign vault id to any delete history");
|
|
||||||
const keysInDeleteHistoryTbl = await db.fileHistoryTbl.keys();
|
|
||||||
for (const key of keysInDeleteHistoryTbl) {
|
|
||||||
if (key.startsWith(vaultRandomID)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const value = (await db.fileHistoryTbl.getItem(
|
|
||||||
key
|
|
||||||
)) as FileFolderHistoryRecord;
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (value.vaultRandomID === undefined || value.vaultRandomID === "") {
|
|
||||||
value.vaultRandomID = vaultRandomID;
|
|
||||||
}
|
|
||||||
const newKey = `${vaultRandomID}\t${key}`;
|
|
||||||
allPromisesToWait.push(db.fileHistoryTbl.setItem(newKey, value));
|
|
||||||
allPromisesToWait.push(db.fileHistoryTbl.removeItem(key));
|
|
||||||
}
|
}
|
||||||
|
return res;
|
||||||
log.debug("assign vault id to any sync mapping");
|
|
||||||
const keysInSyncMappingTbl = await db.syncMappingTbl.keys();
|
|
||||||
for (const key of keysInSyncMappingTbl) {
|
|
||||||
if (key.startsWith(vaultRandomID)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const value = (await db.syncMappingTbl.getItem(
|
|
||||||
key
|
|
||||||
)) as SyncMetaMappingRecord;
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (value.vaultRandomID === undefined || value.vaultRandomID === "") {
|
|
||||||
value.vaultRandomID = vaultRandomID;
|
|
||||||
}
|
|
||||||
const newKey = `${vaultRandomID}\t${key}`;
|
|
||||||
allPromisesToWait.push(db.syncMappingTbl.setItem(newKey, value));
|
|
||||||
allPromisesToWait.push(db.syncMappingTbl.removeItem(key));
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug("assign vault id to any sync plan records");
|
|
||||||
const keysInSyncPlansTbl = await db.syncPlansTbl.keys();
|
|
||||||
for (const key of keysInSyncPlansTbl) {
|
|
||||||
if (key.startsWith(vaultRandomID)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const value = (await db.syncPlansTbl.getItem(key)) as SyncPlanRecord;
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (value.vaultRandomID === undefined || value.vaultRandomID === "") {
|
|
||||||
value.vaultRandomID = vaultRandomID;
|
|
||||||
}
|
|
||||||
const newKey = `${vaultRandomID}\t${key}`;
|
|
||||||
allPromisesToWait.push(db.syncPlansTbl.setItem(newKey, value));
|
|
||||||
allPromisesToWait.push(db.syncPlansTbl.removeItem(key));
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug("finally update version if everything is ok");
|
|
||||||
await Promise.all(allPromisesToWait);
|
|
||||||
await db.versionTbl.setItem("version", newVer);
|
|
||||||
|
|
||||||
log.debug(`finish upgrading internal db from ${oldVer} to ${newVer}`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* no need to do anything except changing version
|
*
|
||||||
* we just add more file operations in db, and no schema is changed.
|
|
||||||
* @param db
|
* @param db
|
||||||
* @param vaultRandomID
|
* @param vaultRandomID
|
||||||
|
* Migrate the sync mapping record to sync Entity.
|
||||||
*/
|
*/
|
||||||
const migrateDBsFrom20220108To20220326 = async (
|
const migrateDBsFrom20220326To20240220 = async (
|
||||||
db: InternalDBs,
|
db: InternalDBs,
|
||||||
vaultRandomID: string
|
vaultRandomID: string,
|
||||||
|
profileID: string
|
||||||
) => {
|
) => {
|
||||||
const oldVer = 20220108;
|
const oldVer = 20220326;
|
||||||
const newVer = 20220326;
|
const newVer = 20240220;
|
||||||
log.debug(`start upgrading internal db from ${oldVer} to ${newVer}`);
|
console.debug(`start upgrading internal db from ${oldVer} to ${newVer}`);
|
||||||
await db.versionTbl.setItem("version", newVer);
|
|
||||||
log.debug(`finish upgrading internal db from ${oldVer} to ${newVer}`);
|
// from sync mapping to prev sync
|
||||||
|
const syncMappings = await getAllSyncMetaMappingByVault(db, vaultRandomID);
|
||||||
|
const prevSyncRecords = fromSyncMappingsToPrevSyncRecords(syncMappings);
|
||||||
|
for (const prevSyncRecord of prevSyncRecords) {
|
||||||
|
await upsertPrevSyncRecordByVaultAndProfile(
|
||||||
|
db,
|
||||||
|
vaultRandomID,
|
||||||
|
profileID,
|
||||||
|
prevSyncRecord
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// // clear not used data
|
||||||
|
// // as of 20240220, we don't call them,
|
||||||
|
// // for the opportunity for users to downgrade
|
||||||
|
// await clearFileHistoryOfEverythingByVault(db, vaultRandomID);
|
||||||
|
// await clearAllSyncMetaMappingByVault(db, vaultRandomID);
|
||||||
|
|
||||||
|
await db.versionTbl.setItem(`${vaultRandomID}\tversion`, newVer);
|
||||||
|
console.debug(`finish upgrading internal db from ${oldVer} to ${newVer}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const migrateDBs = async (
|
const migrateDBs = async (
|
||||||
db: InternalDBs,
|
db: InternalDBs,
|
||||||
oldVer: number,
|
oldVer: number,
|
||||||
newVer: number,
|
newVer: number,
|
||||||
vaultRandomID: string
|
vaultRandomID: string,
|
||||||
|
profileID: string
|
||||||
) => {
|
) => {
|
||||||
if (oldVer === newVer) {
|
if (oldVer === newVer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (oldVer === 20211114 && newVer === 20220108) {
|
|
||||||
return await migrateDBsFrom20211114To20220108(db, vaultRandomID);
|
// as of 20240220, we assume everyone is using 20220326 already
|
||||||
|
// drop any old code to reduce the verbose
|
||||||
|
if (oldVer < 20220326) {
|
||||||
|
throw Error(
|
||||||
|
"You are using a very old version of Remotely Save. No way to auto update internal DB. Please install and enable 0.3.40 firstly, then install a later version."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (oldVer === 20220108 && newVer === 20220326) {
|
|
||||||
return await migrateDBsFrom20220108To20220326(db, vaultRandomID);
|
if (oldVer === 20220326 && newVer === 20240220) {
|
||||||
}
|
return await migrateDBsFrom20220326To20240220(db, vaultRandomID, profileID);
|
||||||
if (oldVer === 20211114 && newVer === 20220326) {
|
|
||||||
// TODO: more steps with more versions in the future
|
|
||||||
await migrateDBsFrom20211114To20220108(db, vaultRandomID);
|
|
||||||
await migrateDBsFrom20220108To20220326(db, vaultRandomID);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newVer < oldVer) {
|
if (newVer < oldVer) {
|
||||||
throw Error(
|
throw Error(
|
||||||
"You've installed a new version, but then downgrade to an old version. Stop working!"
|
"You've installed a new version, but then downgrade to an old version. Stop working!"
|
||||||
@@ -191,21 +178,14 @@ const migrateDBs = async (
|
|||||||
|
|
||||||
export const prepareDBs = async (
|
export const prepareDBs = async (
|
||||||
vaultBasePath: string,
|
vaultBasePath: string,
|
||||||
vaultRandomIDFromOldConfigFile: string
|
vaultRandomIDFromOldConfigFile: string,
|
||||||
|
profileID: string
|
||||||
) => {
|
) => {
|
||||||
const db = {
|
const db = {
|
||||||
versionTbl: localforage.createInstance({
|
versionTbl: localforage.createInstance({
|
||||||
name: DEFAULT_DB_NAME,
|
name: DEFAULT_DB_NAME,
|
||||||
storeName: DEFAULT_TBL_VERSION,
|
storeName: DEFAULT_TBL_VERSION,
|
||||||
}),
|
}),
|
||||||
fileHistoryTbl: localforage.createInstance({
|
|
||||||
name: DEFAULT_DB_NAME,
|
|
||||||
storeName: DEFAULT_TBL_FILE_HISTORY,
|
|
||||||
}),
|
|
||||||
syncMappingTbl: localforage.createInstance({
|
|
||||||
name: DEFAULT_DB_NAME,
|
|
||||||
storeName: DEFAULT_TBL_SYNC_MAPPING,
|
|
||||||
}),
|
|
||||||
syncPlansTbl: localforage.createInstance({
|
syncPlansTbl: localforage.createInstance({
|
||||||
name: DEFAULT_DB_NAME,
|
name: DEFAULT_DB_NAME,
|
||||||
storeName: DEFAULT_SYNC_PLANS_HISTORY,
|
storeName: DEFAULT_SYNC_PLANS_HISTORY,
|
||||||
@@ -222,6 +202,23 @@ export const prepareDBs = async (
|
|||||||
name: DEFAULT_DB_NAME,
|
name: DEFAULT_DB_NAME,
|
||||||
storeName: DEFAULT_TBL_SIMPLE_KV_FOR_MISC,
|
storeName: DEFAULT_TBL_SIMPLE_KV_FOR_MISC,
|
||||||
}),
|
}),
|
||||||
|
prevSyncRecordsTbl: localforage.createInstance({
|
||||||
|
name: DEFAULT_DB_NAME,
|
||||||
|
storeName: DEFAULT_TBL_PREV_SYNC_RECORDS,
|
||||||
|
}),
|
||||||
|
profilerResultsTbl: localforage.createInstance({
|
||||||
|
name: DEFAULT_DB_NAME,
|
||||||
|
storeName: DEFAULT_TBL_PROFILER_RESULTS,
|
||||||
|
}),
|
||||||
|
|
||||||
|
fileHistoryTbl: localforage.createInstance({
|
||||||
|
name: DEFAULT_DB_NAME,
|
||||||
|
storeName: DEFAULT_TBL_FILE_HISTORY,
|
||||||
|
}),
|
||||||
|
syncMappingTbl: localforage.createInstance({
|
||||||
|
name: DEFAULT_DB_NAME,
|
||||||
|
storeName: DEFAULT_TBL_SYNC_MAPPING,
|
||||||
|
}),
|
||||||
} as InternalDBs;
|
} as InternalDBs;
|
||||||
|
|
||||||
// try to get vaultRandomID firstly
|
// try to get vaultRandomID firstly
|
||||||
@@ -253,27 +250,35 @@ export const prepareDBs = async (
|
|||||||
throw Error("no vaultRandomID found or generated");
|
throw Error("no vaultRandomID found or generated");
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalVersion: number | null = await db.versionTbl.getItem("version");
|
// as of 20240220, we set the version per vault, instead of global "version"
|
||||||
|
const originalVersion: number | null =
|
||||||
|
(await db.versionTbl.getItem(`${vaultRandomID}\tversion`)) ??
|
||||||
|
(await db.versionTbl.getItem("version"));
|
||||||
if (originalVersion === null) {
|
if (originalVersion === null) {
|
||||||
log.debug(
|
console.debug(
|
||||||
`no internal db version, setting it to ${DEFAULT_DB_VERSION_NUMBER}`
|
`no internal db version, setting it to ${DEFAULT_DB_VERSION_NUMBER}`
|
||||||
);
|
);
|
||||||
await db.versionTbl.setItem("version", DEFAULT_DB_VERSION_NUMBER);
|
// as of 20240220, we set the version per vault, instead of global "version"
|
||||||
|
await db.versionTbl.setItem(
|
||||||
|
`${vaultRandomID}\tversion`,
|
||||||
|
DEFAULT_DB_VERSION_NUMBER
|
||||||
|
);
|
||||||
} else if (originalVersion === DEFAULT_DB_VERSION_NUMBER) {
|
} else if (originalVersion === DEFAULT_DB_VERSION_NUMBER) {
|
||||||
// do nothing
|
// do nothing
|
||||||
} else {
|
} else {
|
||||||
log.debug(
|
console.debug(
|
||||||
`trying to upgrade db version from ${originalVersion} to ${DEFAULT_DB_VERSION_NUMBER}`
|
`trying to upgrade db version from ${originalVersion} to ${DEFAULT_DB_VERSION_NUMBER}`
|
||||||
);
|
);
|
||||||
await migrateDBs(
|
await migrateDBs(
|
||||||
db,
|
db,
|
||||||
originalVersion,
|
originalVersion,
|
||||||
DEFAULT_DB_VERSION_NUMBER,
|
DEFAULT_DB_VERSION_NUMBER,
|
||||||
vaultRandomID
|
vaultRandomID,
|
||||||
|
profileID
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("db connected");
|
console.info("db connected");
|
||||||
return {
|
return {
|
||||||
db: db,
|
db: db,
|
||||||
vaultRandomID: vaultRandomID,
|
vaultRandomID: vaultRandomID,
|
||||||
@@ -284,304 +289,79 @@ export const destroyDBs = async () => {
|
|||||||
// await localforage.dropInstance({
|
// await localforage.dropInstance({
|
||||||
// name: DEFAULT_DB_NAME,
|
// name: DEFAULT_DB_NAME,
|
||||||
// });
|
// });
|
||||||
// log.info("db deleted");
|
// console.info("db deleted");
|
||||||
const req = indexedDB.deleteDatabase(DEFAULT_DB_NAME);
|
const req = indexedDB.deleteDatabase(DEFAULT_DB_NAME);
|
||||||
req.onsuccess = (event) => {
|
req.onsuccess = (event) => {
|
||||||
log.info("db deleted");
|
console.info("db deleted");
|
||||||
};
|
};
|
||||||
req.onblocked = (event) => {
|
req.onblocked = (event) => {
|
||||||
log.warn("trying to delete db but it was blocked");
|
console.warn("trying to delete db but it was blocked");
|
||||||
};
|
};
|
||||||
req.onerror = (event) => {
|
req.onerror = (event) => {
|
||||||
log.error("tried to delete db but something goes wrong!");
|
console.error("tried to delete db but something goes wrong!");
|
||||||
log.error(event);
|
console.error(event);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadFileHistoryTableByVault = async (
|
export const clearFileHistoryOfEverythingByVault = async (
|
||||||
db: InternalDBs,
|
db: InternalDBs,
|
||||||
vaultRandomID: string
|
vaultRandomID: string
|
||||||
) => {
|
) => {
|
||||||
const records = [] as FileFolderHistoryRecord[];
|
const keys = await db.fileHistoryTbl.keys();
|
||||||
await db.fileHistoryTbl.iterate((value, key, iterationNumber) => {
|
for (const key of keys) {
|
||||||
if (key.startsWith(`${vaultRandomID}\t`)) {
|
if (key.startsWith(`${vaultRandomID}\t`)) {
|
||||||
records.push(value as FileFolderHistoryRecord);
|
await db.fileHistoryTbl.removeItem(key);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
records.sort((a, b) => a.actionWhen - b.actionWhen); // ascending
|
|
||||||
return records;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const clearDeleteRenameHistoryOfKeyAndVault = async (
|
|
||||||
db: InternalDBs,
|
|
||||||
key: string,
|
|
||||||
vaultRandomID: string
|
|
||||||
) => {
|
|
||||||
const fullKey = `${vaultRandomID}\t${key}`;
|
|
||||||
const item: FileFolderHistoryRecord | null =
|
|
||||||
await db.fileHistoryTbl.getItem(fullKey);
|
|
||||||
if (
|
|
||||||
item !== null &&
|
|
||||||
(item.actionType === "delete" || item.actionType === "rename")
|
|
||||||
) {
|
|
||||||
await db.fileHistoryTbl.removeItem(fullKey);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const insertDeleteRecordByVault = async (
|
|
||||||
db: InternalDBs,
|
|
||||||
fileOrFolder: TAbstractFile | string,
|
|
||||||
vaultRandomID: string
|
|
||||||
) => {
|
|
||||||
// log.info(fileOrFolder);
|
|
||||||
let k: FileFolderHistoryRecord;
|
|
||||||
if (fileOrFolder instanceof TFile) {
|
|
||||||
k = {
|
|
||||||
key: fileOrFolder.path,
|
|
||||||
ctime: fileOrFolder.stat.ctime,
|
|
||||||
mtime: fileOrFolder.stat.mtime,
|
|
||||||
size: fileOrFolder.stat.size,
|
|
||||||
actionWhen: Date.now(),
|
|
||||||
actionType: "delete",
|
|
||||||
keyType: "file",
|
|
||||||
renameTo: "",
|
|
||||||
vaultRandomID: vaultRandomID,
|
|
||||||
};
|
|
||||||
await db.fileHistoryTbl.setItem(`${vaultRandomID}\t${k.key}`, k);
|
|
||||||
} else if (fileOrFolder instanceof TFolder) {
|
|
||||||
// key should endswith "/"
|
|
||||||
const key = fileOrFolder.path.endsWith("/")
|
|
||||||
? fileOrFolder.path
|
|
||||||
: `${fileOrFolder.path}/`;
|
|
||||||
const ctime = 0; // they are deleted, so no way to get ctime, mtime
|
|
||||||
const mtime = 0; // they are deleted, so no way to get ctime, mtime
|
|
||||||
k = {
|
|
||||||
key: key,
|
|
||||||
ctime: ctime,
|
|
||||||
mtime: mtime,
|
|
||||||
size: 0,
|
|
||||||
actionWhen: Date.now(),
|
|
||||||
actionType: "delete",
|
|
||||||
keyType: "folder",
|
|
||||||
renameTo: "",
|
|
||||||
vaultRandomID: vaultRandomID,
|
|
||||||
};
|
|
||||||
await db.fileHistoryTbl.setItem(`${vaultRandomID}\t${k.key}`, k);
|
|
||||||
} else if (typeof fileOrFolder === "string") {
|
|
||||||
// always the deletions in .obsidian folder
|
|
||||||
// so annoying that the path doesn't exists
|
|
||||||
// and we have to guess whether the path is folder or file
|
|
||||||
k = {
|
|
||||||
key: fileOrFolder,
|
|
||||||
ctime: 0,
|
|
||||||
mtime: 0,
|
|
||||||
size: 0,
|
|
||||||
actionWhen: Date.now(),
|
|
||||||
actionType: "delete",
|
|
||||||
keyType: "file",
|
|
||||||
renameTo: "",
|
|
||||||
vaultRandomID: vaultRandomID,
|
|
||||||
};
|
|
||||||
await db.fileHistoryTbl.setItem(`${vaultRandomID}\t${k.key}`, k);
|
|
||||||
for (const ext of [
|
|
||||||
"json",
|
|
||||||
"js",
|
|
||||||
"mjs",
|
|
||||||
"ts",
|
|
||||||
"md",
|
|
||||||
"txt",
|
|
||||||
"css",
|
|
||||||
"png",
|
|
||||||
"gif",
|
|
||||||
"jpg",
|
|
||||||
"jpeg",
|
|
||||||
"gitignore",
|
|
||||||
"gitkeep",
|
|
||||||
]) {
|
|
||||||
if (fileOrFolder.endsWith(`.${ext}`)) {
|
|
||||||
// stop here, no more need to insert the folder record later
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// also add a deletion record as folder if not ending with special exts
|
|
||||||
k = {
|
|
||||||
key: `${fileOrFolder}/`,
|
|
||||||
ctime: 0,
|
|
||||||
mtime: 0,
|
|
||||||
size: 0,
|
|
||||||
actionWhen: Date.now(),
|
|
||||||
actionType: "delete",
|
|
||||||
keyType: "folder",
|
|
||||||
renameTo: "",
|
|
||||||
vaultRandomID: vaultRandomID,
|
|
||||||
};
|
|
||||||
await db.fileHistoryTbl.setItem(`${vaultRandomID}\t${k.key}`, k);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A file/folder is renamed from A to B
|
* @deprecated But we cannot remove it. Because we want to migrate the old data.
|
||||||
* We insert two records:
|
|
||||||
* A with actionType="rename"
|
|
||||||
* B with actionType="renameDestination"
|
|
||||||
* @param db
|
* @param db
|
||||||
* @param fileOrFolder
|
|
||||||
* @param oldPath
|
|
||||||
* @param vaultRandomID
|
* @param vaultRandomID
|
||||||
|
* @returns
|
||||||
*/
|
*/
|
||||||
export const insertRenameRecordByVault = async (
|
export const getAllSyncMetaMappingByVault = async (
|
||||||
db: InternalDBs,
|
db: InternalDBs,
|
||||||
fileOrFolder: TAbstractFile,
|
|
||||||
oldPath: string,
|
|
||||||
vaultRandomID: string
|
vaultRandomID: string
|
||||||
) => {
|
) => {
|
||||||
// log.info(fileOrFolder);
|
return await Promise.all(
|
||||||
let k1: FileFolderHistoryRecord;
|
((await db.syncMappingTbl.keys()) ?? [])
|
||||||
let k2: FileFolderHistoryRecord;
|
.filter((key) => key.startsWith(`${vaultRandomID}\t`))
|
||||||
const actionWhen = Date.now();
|
.map(
|
||||||
if (fileOrFolder instanceof TFile) {
|
async (key) =>
|
||||||
k1 = {
|
(await db.syncMappingTbl.getItem(key)) as SyncMetaMappingRecord
|
||||||
key: oldPath,
|
)
|
||||||
ctime: fileOrFolder.stat.ctime,
|
|
||||||
mtime: fileOrFolder.stat.mtime,
|
|
||||||
size: fileOrFolder.stat.size,
|
|
||||||
actionWhen: actionWhen,
|
|
||||||
actionType: "rename",
|
|
||||||
keyType: "file",
|
|
||||||
renameTo: fileOrFolder.path,
|
|
||||||
vaultRandomID: vaultRandomID,
|
|
||||||
};
|
|
||||||
k2 = {
|
|
||||||
key: fileOrFolder.path,
|
|
||||||
ctime: fileOrFolder.stat.ctime,
|
|
||||||
mtime: fileOrFolder.stat.mtime,
|
|
||||||
size: fileOrFolder.stat.size,
|
|
||||||
actionWhen: actionWhen,
|
|
||||||
actionType: "renameDestination",
|
|
||||||
keyType: "file",
|
|
||||||
renameTo: "", // itself is the destination, so no need to set this field
|
|
||||||
vaultRandomID: vaultRandomID,
|
|
||||||
};
|
|
||||||
} else if (fileOrFolder instanceof TFolder) {
|
|
||||||
const key = oldPath.endsWith("/") ? oldPath : `${oldPath}/`;
|
|
||||||
const renameTo = fileOrFolder.path.endsWith("/")
|
|
||||||
? fileOrFolder.path
|
|
||||||
: `${fileOrFolder.path}/`;
|
|
||||||
let ctime = 0;
|
|
||||||
let mtime = 0;
|
|
||||||
if (requireApiVersion(API_VER_STAT_FOLDER)) {
|
|
||||||
// TAbstractFile does not contain these info
|
|
||||||
// but from API_VER_STAT_FOLDER we can manually stat them by path.
|
|
||||||
const s = await statFix(fileOrFolder.vault, fileOrFolder.path);
|
|
||||||
ctime = s.ctime;
|
|
||||||
mtime = s.mtime;
|
|
||||||
}
|
|
||||||
k1 = {
|
|
||||||
key: key,
|
|
||||||
ctime: ctime,
|
|
||||||
mtime: mtime,
|
|
||||||
size: 0,
|
|
||||||
actionWhen: actionWhen,
|
|
||||||
actionType: "rename",
|
|
||||||
keyType: "folder",
|
|
||||||
renameTo: renameTo,
|
|
||||||
vaultRandomID: vaultRandomID,
|
|
||||||
};
|
|
||||||
k2 = {
|
|
||||||
key: renameTo,
|
|
||||||
ctime: ctime,
|
|
||||||
mtime: mtime,
|
|
||||||
size: 0,
|
|
||||||
actionWhen: actionWhen,
|
|
||||||
actionType: "renameDestination",
|
|
||||||
keyType: "folder",
|
|
||||||
renameTo: "", // itself is the destination, so no need to set this field
|
|
||||||
vaultRandomID: vaultRandomID,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
await Promise.all([
|
|
||||||
db.fileHistoryTbl.setItem(`${vaultRandomID}\t${k1.key}`, k1),
|
|
||||||
db.fileHistoryTbl.setItem(`${vaultRandomID}\t${k2.key}`, k2),
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const upsertSyncMetaMappingDataByVault = async (
|
|
||||||
serviceType: SUPPORTED_SERVICES_TYPE,
|
|
||||||
db: InternalDBs,
|
|
||||||
localKey: string,
|
|
||||||
localMTime: number,
|
|
||||||
localSize: number,
|
|
||||||
remoteKey: string,
|
|
||||||
remoteMTime: number,
|
|
||||||
remoteSize: number,
|
|
||||||
remoteExtraKey: string,
|
|
||||||
vaultRandomID: string
|
|
||||||
) => {
|
|
||||||
const aggregratedInfo: SyncMetaMappingRecord = {
|
|
||||||
localKey: localKey,
|
|
||||||
localMtime: localMTime,
|
|
||||||
localSize: localSize,
|
|
||||||
remoteKey: remoteKey,
|
|
||||||
remoteMtime: remoteMTime,
|
|
||||||
remoteSize: remoteSize,
|
|
||||||
remoteExtraKey: remoteExtraKey,
|
|
||||||
remoteType: serviceType,
|
|
||||||
keyType: localKey.endsWith("/") ? "folder" : "file",
|
|
||||||
vaultRandomID: vaultRandomID,
|
|
||||||
};
|
|
||||||
await db.syncMappingTbl.setItem(
|
|
||||||
`${vaultRandomID}\t${remoteKey}`,
|
|
||||||
aggregratedInfo
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSyncMetaMappingByRemoteKeyAndVault = async (
|
export const clearAllSyncMetaMappingByVault = async (
|
||||||
serviceType: SUPPORTED_SERVICES_TYPE,
|
|
||||||
db: InternalDBs,
|
db: InternalDBs,
|
||||||
remoteKey: string,
|
|
||||||
remoteMTime: number,
|
|
||||||
remoteExtraKey: string,
|
|
||||||
vaultRandomID: string
|
vaultRandomID: string
|
||||||
) => {
|
) => {
|
||||||
const potentialItem = (await db.syncMappingTbl.getItem(
|
const keys = await db.syncMappingTbl.keys();
|
||||||
`${vaultRandomID}\t${remoteKey}`
|
for (const key of keys) {
|
||||||
)) as SyncMetaMappingRecord;
|
if (key.startsWith(`${vaultRandomID}\t`)) {
|
||||||
|
await db.syncMappingTbl.removeItem(key);
|
||||||
if (potentialItem === null) {
|
}
|
||||||
// no result was found
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
potentialItem.remoteKey === remoteKey &&
|
|
||||||
potentialItem.remoteMtime === remoteMTime &&
|
|
||||||
potentialItem.remoteExtraKey === remoteExtraKey &&
|
|
||||||
potentialItem.remoteType === serviceType
|
|
||||||
) {
|
|
||||||
// the result was found
|
|
||||||
return potentialItem;
|
|
||||||
} else {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const clearAllSyncMetaMapping = async (db: InternalDBs) => {
|
|
||||||
await db.syncMappingTbl.clear();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const insertSyncPlanRecordByVault = async (
|
export const insertSyncPlanRecordByVault = async (
|
||||||
db: InternalDBs,
|
db: InternalDBs,
|
||||||
syncPlan: SyncPlanType,
|
syncPlan: SyncPlanType,
|
||||||
vaultRandomID: string
|
vaultRandomID: string,
|
||||||
|
remoteType: SUPPORTED_SERVICES_TYPE
|
||||||
) => {
|
) => {
|
||||||
|
const now = Date.now();
|
||||||
const record = {
|
const record = {
|
||||||
ts: syncPlan.ts,
|
ts: now,
|
||||||
tsFmt: syncPlan.tsFmt,
|
tsFmt: unixTimeToStr(now),
|
||||||
vaultRandomID: vaultRandomID,
|
vaultRandomID: vaultRandomID,
|
||||||
remoteType: syncPlan.remoteType,
|
remoteType: remoteType,
|
||||||
syncPlan: JSON.stringify(syncPlan /* directly stringify */, null, 2),
|
syncPlan: JSON.stringify(syncPlan /* directly stringify */, null, 2),
|
||||||
} as SyncPlanRecord;
|
} as SyncPlanRecord;
|
||||||
await db.syncPlansTbl.setItem(`${vaultRandomID}\t${syncPlan.ts}`, record);
|
await db.syncPlansTbl.setItem(`${vaultRandomID}\t${now}`, record);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clearAllSyncPlanRecords = async (db: InternalDBs) => {
|
export const clearAllSyncPlanRecords = async (db: InternalDBs) => {
|
||||||
@@ -608,13 +388,13 @@ export const readAllSyncPlanRecordTextsByVault = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* We remove records that are older than 7 days or 10000 records.
|
* We remove records that are older than 1 days or 20 records.
|
||||||
* It's a heavy operation, so we shall not place it in the start up.
|
* It's a heavy operation, so we shall not place it in the start up.
|
||||||
* @param db
|
* @param db
|
||||||
*/
|
*/
|
||||||
export const clearExpiredSyncPlanRecords = async (db: InternalDBs) => {
|
export const clearExpiredSyncPlanRecords = async (db: InternalDBs) => {
|
||||||
const MILLISECONDS_OLD = 1000 * 60 * 60 * 24 * 7; // 7 days
|
const MILLISECONDS_OLD = 1000 * 60 * 60 * 24 * 1; // 1 days
|
||||||
const COUNT_TO_MANY = 10000;
|
const COUNT_TO_MANY = 20;
|
||||||
|
|
||||||
const currTs = Date.now();
|
const currTs = Date.now();
|
||||||
const expiredTs = currTs - MILLISECONDS_OLD;
|
const expiredTs = currTs - MILLISECONDS_OLD;
|
||||||
@@ -634,7 +414,7 @@ export const clearExpiredSyncPlanRecords = async (db: InternalDBs) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (records.length - keysToRemove.size > COUNT_TO_MANY) {
|
if (records.length - keysToRemove.size > COUNT_TO_MANY) {
|
||||||
// we need to find out records beyond 10000 records
|
// we need to find out records beyond 100 records
|
||||||
records = records.filter((x) => !x.expired); // shrink the array
|
records = records.filter((x) => !x.expired); // shrink the array
|
||||||
records.sort((a, b) => -(a.ts - b.ts)); // descending
|
records.sort((a, b) => -(a.ts - b.ts)); // descending
|
||||||
records.slice(COUNT_TO_MANY).forEach((element) => {
|
records.slice(COUNT_TO_MANY).forEach((element) => {
|
||||||
@@ -649,95 +429,66 @@ export const clearExpiredSyncPlanRecords = async (db: InternalDBs) => {
|
|||||||
await Promise.all(ps);
|
await Promise.all(ps);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const readAllLogRecordTextsByVault = async (
|
export const getAllPrevSyncRecordsByVaultAndProfile = async (
|
||||||
|
db: InternalDBs,
|
||||||
|
vaultRandomID: string,
|
||||||
|
profileID: string
|
||||||
|
) => {
|
||||||
|
const res: Entity[] = [];
|
||||||
|
const kv: Record<string, Entity | null> =
|
||||||
|
await db.prevSyncRecordsTbl.getItems();
|
||||||
|
for (const key of Object.getOwnPropertyNames(kv)) {
|
||||||
|
if (key.startsWith(`${vaultRandomID}\t${profileID}\t`)) {
|
||||||
|
const val = kv[key];
|
||||||
|
if (val !== null) {
|
||||||
|
res.push(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertPrevSyncRecordByVaultAndProfile = async (
|
||||||
|
db: InternalDBs,
|
||||||
|
vaultRandomID: string,
|
||||||
|
profileID: string,
|
||||||
|
prevSync: Entity
|
||||||
|
) => {
|
||||||
|
await db.prevSyncRecordsTbl.setItem(
|
||||||
|
`${vaultRandomID}\t${profileID}\t${prevSync.key}`,
|
||||||
|
prevSync
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearPrevSyncRecordByVaultAndProfile = async (
|
||||||
|
db: InternalDBs,
|
||||||
|
vaultRandomID: string,
|
||||||
|
profileID: string,
|
||||||
|
key: string
|
||||||
|
) => {
|
||||||
|
await db.prevSyncRecordsTbl.removeItem(
|
||||||
|
`${vaultRandomID}\t${profileID}\t${key}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearAllPrevSyncRecordByVault = async (
|
||||||
db: InternalDBs,
|
db: InternalDBs,
|
||||||
vaultRandomID: string
|
vaultRandomID: string
|
||||||
) => {
|
) => {
|
||||||
const records = [] as { ts: number; r: string }[];
|
const keys = await db.prevSyncRecordsTbl.keys();
|
||||||
await db.loggerOutputTbl.iterate((value, key, iterationNumber) => {
|
for (const key of keys) {
|
||||||
if (key.startsWith(`${vaultRandomID}\t`)) {
|
if (key.startsWith(`${vaultRandomID}\t`)) {
|
||||||
const item = {
|
await db.prevSyncRecordsTbl.removeItem(key);
|
||||||
ts: parseInt(key.split("\t")[1]),
|
|
||||||
r: value as string,
|
|
||||||
};
|
|
||||||
records.push(item);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// while reading the logs, we want it to be ascending
|
|
||||||
records.sort((a, b) => a.ts - b.ts);
|
|
||||||
|
|
||||||
if (records === undefined) {
|
|
||||||
return [] as string[];
|
|
||||||
} else {
|
|
||||||
return records.map((x) => x.r);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const insertLoggerOutputByVault = async (
|
|
||||||
db: InternalDBs,
|
|
||||||
vaultRandomID: string,
|
|
||||||
...msg: any[]
|
|
||||||
) => {
|
|
||||||
const ts = Date.now();
|
|
||||||
const tsFmt = unixTimeToStr(ts);
|
|
||||||
const key = `${vaultRandomID}\t${ts}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const val = [`[${tsFmt}]`, ...msg.map((x) => toText(x))].join(" ");
|
|
||||||
db.loggerOutputTbl.setItem(key, val);
|
|
||||||
} catch (err) {
|
|
||||||
// give up, and let it pass
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clearAllLoggerOutputRecords = async (db: InternalDBs) => {
|
export const clearAllLoggerOutputRecords = async (db: InternalDBs) => {
|
||||||
await db.loggerOutputTbl.clear();
|
await db.loggerOutputTbl.clear();
|
||||||
|
console.debug(`successfully clearAllLoggerOutputRecords`);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export const upsertLastSuccessSyncTimeByVault = async (
|
||||||
* We remove records that are older than 7 days or 10000 records.
|
|
||||||
* It's a heavy operation, so we shall not place it in the start up.
|
|
||||||
* @param db
|
|
||||||
*/
|
|
||||||
export const clearExpiredLoggerOutputRecords = async (db: InternalDBs) => {
|
|
||||||
const MILLISECONDS_OLD = 1000 * 60 * 60 * 24 * 7; // 7 days
|
|
||||||
const COUNT_TO_MANY = 10000;
|
|
||||||
|
|
||||||
const currTs = Date.now();
|
|
||||||
const expiredTs = currTs - MILLISECONDS_OLD;
|
|
||||||
|
|
||||||
let records = (await db.loggerOutputTbl.keys()).map((key) => {
|
|
||||||
const ts = parseInt(key.split("\t")[1]);
|
|
||||||
const expired = ts <= expiredTs;
|
|
||||||
return {
|
|
||||||
ts: ts,
|
|
||||||
key: key,
|
|
||||||
expired: expired,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const keysToRemove = new Set(
|
|
||||||
records.filter((x) => x.expired).map((x) => x.key)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (records.length - keysToRemove.size > COUNT_TO_MANY) {
|
|
||||||
// we need to find out records beyond 10000 records
|
|
||||||
records = records.filter((x) => !x.expired); // shrink the array
|
|
||||||
records.sort((a, b) => -(a.ts - b.ts)); // descending
|
|
||||||
records.slice(COUNT_TO_MANY).forEach((element) => {
|
|
||||||
keysToRemove.add(element.key);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const ps = [] as Promise<void>[];
|
|
||||||
keysToRemove.forEach((element) => {
|
|
||||||
ps.push(db.loggerOutputTbl.removeItem(element));
|
|
||||||
});
|
|
||||||
await Promise.all(ps);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const upsertLastSuccessSyncByVault = async (
|
|
||||||
db: InternalDBs,
|
db: InternalDBs,
|
||||||
vaultRandomID: string,
|
vaultRandomID: string,
|
||||||
millis: number
|
millis: number
|
||||||
@@ -748,7 +499,7 @@ export const upsertLastSuccessSyncByVault = async (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getLastSuccessSyncByVault = async (
|
export const getLastSuccessSyncTimeByVault = async (
|
||||||
db: InternalDBs,
|
db: InternalDBs,
|
||||||
vaultRandomID: string
|
vaultRandomID: string
|
||||||
) => {
|
) => {
|
||||||
@@ -778,3 +529,45 @@ export const upsertPluginVersionByVault = async (
|
|||||||
newVersion: newVersion,
|
newVersion: newVersion,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const insertProfilerResultByVault = async (
|
||||||
|
db: InternalDBs,
|
||||||
|
profilerStr: string,
|
||||||
|
vaultRandomID: string,
|
||||||
|
remoteType: SUPPORTED_SERVICES_TYPE
|
||||||
|
) => {
|
||||||
|
const now = Date.now();
|
||||||
|
await db.profilerResultsTbl.setItem(`${vaultRandomID}\t${now}`, profilerStr);
|
||||||
|
|
||||||
|
// clear older one while writing
|
||||||
|
const records = (await db.profilerResultsTbl.keys())
|
||||||
|
.filter((x) => x.startsWith(`${vaultRandomID}\t`))
|
||||||
|
.map((x) => parseInt(x.split("\t")[1]));
|
||||||
|
records.sort((a, b) => -(a - b)); // descending
|
||||||
|
while (records.length > 5) {
|
||||||
|
const ts = records.pop()!;
|
||||||
|
await db.profilerResultsTbl.removeItem(`${vaultRandomID}\t${ts}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readAllProfilerResultsByVault = async (
|
||||||
|
db: InternalDBs,
|
||||||
|
vaultRandomID: string
|
||||||
|
) => {
|
||||||
|
const records = [] as { val: string; ts: number }[];
|
||||||
|
await db.profilerResultsTbl.iterate((value, key, iterationNumber) => {
|
||||||
|
if (key.startsWith(`${vaultRandomID}\t`)) {
|
||||||
|
records.push({
|
||||||
|
val: value as string,
|
||||||
|
ts: parseInt(key.split("\t")[1]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
records.sort((a, b) => -(a.ts - b.ts)); // descending
|
||||||
|
|
||||||
|
if (records === undefined) {
|
||||||
|
return [] as string[];
|
||||||
|
} else {
|
||||||
|
return records.map((x) => x.val);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
+564
-507
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
|||||||
import isEqual from "lodash/isEqual";
|
import isEqual from "lodash/isEqual";
|
||||||
import { base64url } from "rfc4648";
|
import { base64url } from "rfc4648";
|
||||||
import { reverseString } from "./misc";
|
import { reverseString } from "./misc";
|
||||||
import { log } from "./moreOnLog";
|
|
||||||
|
|
||||||
const DEFAULT_README_FOR_METADATAONREMOTE =
|
const DEFAULT_README_FOR_METADATAONREMOTE =
|
||||||
"Do NOT edit or delete the file manually. This file is for the plugin remotely-save to store some necessary meta data on the remote services. Its content is slightly obfuscated.";
|
"Do NOT edit or delete the file manually. This file is for the plugin remotely-save to store some necessary meta data on the remote services. Its content is slightly obfuscated.";
|
||||||
@@ -26,8 +25,8 @@ export interface MetadataOnRemote {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const isEqualMetadataOnRemote = (
|
export const isEqualMetadataOnRemote = (
|
||||||
a: MetadataOnRemote,
|
a: MetadataOnRemote | undefined,
|
||||||
b: MetadataOnRemote
|
b: MetadataOnRemote | undefined
|
||||||
) => {
|
) => {
|
||||||
const m1 = a === undefined ? { deletions: [] } : a;
|
const m1 = a === undefined ? { deletions: [] } : a;
|
||||||
const m2 = b === undefined ? { deletions: [] } : b;
|
const m2 = b === undefined ? { deletions: [] } : b;
|
||||||
|
|||||||
+182
-14
@@ -1,12 +1,10 @@
|
|||||||
import { Vault } from "obsidian";
|
import { Platform, Vault } from "obsidian";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
|
||||||
import { base32, base64url } from "rfc4648";
|
import { base32, base64url } from "rfc4648";
|
||||||
import XRegExp from "xregexp";
|
import XRegExp from "xregexp";
|
||||||
import emojiRegex from "emoji-regex";
|
import emojiRegex from "emoji-regex";
|
||||||
|
|
||||||
import { log } from "./moreOnLog";
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
moment: (...data: any) => any;
|
moment: (...data: any) => any;
|
||||||
@@ -30,7 +28,7 @@ export const isHiddenPath = (
|
|||||||
}
|
}
|
||||||
const k = path.posix.normalize(item); // TODO: only unix path now
|
const k = path.posix.normalize(item); // TODO: only unix path now
|
||||||
const k2 = k.split("/"); // TODO: only unix path now
|
const k2 = k.split("/"); // TODO: only unix path now
|
||||||
// log.info(k2)
|
// console.info(k2)
|
||||||
for (const singlePart of k2) {
|
for (const singlePart of k2) {
|
||||||
if (singlePart === "." || singlePart === ".." || singlePart === "") {
|
if (singlePart === "." || singlePart === ".." || singlePart === "") {
|
||||||
continue;
|
continue;
|
||||||
@@ -75,14 +73,14 @@ export const getFolderLevels = (x: string, addEndingSlash: boolean = false) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const mkdirpInVault = async (thePath: string, vault: Vault) => {
|
export const mkdirpInVault = async (thePath: string, vault: Vault) => {
|
||||||
// log.info(thePath);
|
// console.info(thePath);
|
||||||
const foldersToBuild = getFolderLevels(thePath);
|
const foldersToBuild = getFolderLevels(thePath);
|
||||||
// log.info(foldersToBuild);
|
// console.info(foldersToBuild);
|
||||||
for (const folder of foldersToBuild) {
|
for (const folder of foldersToBuild) {
|
||||||
const r = await vault.adapter.exists(folder);
|
const r = await vault.adapter.exists(folder);
|
||||||
// log.info(r);
|
// console.info(r);
|
||||||
if (!r) {
|
if (!r) {
|
||||||
log.info(`mkdir ${folder}`);
|
console.info(`mkdir ${folder}`);
|
||||||
await vault.adapter.mkdir(folder);
|
await vault.adapter.mkdir(folder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,14 +118,24 @@ export const base64ToArrayBuffer = (b64text: string) => {
|
|||||||
return bufferToArrayBuffer(Buffer.from(b64text, "base64"));
|
return bufferToArrayBuffer(Buffer.from(b64text, "base64"));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const copyArrayBuffer = (src: ArrayBuffer) => {
|
||||||
|
var dst = new ArrayBuffer(src.byteLength);
|
||||||
|
new Uint8Array(dst).set(new Uint8Array(src));
|
||||||
|
return dst;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* https://stackoverflow.com/questions/43131242
|
* https://stackoverflow.com/questions/43131242
|
||||||
* @param hex
|
* @param hex
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export const hexStringToTypedArray = (hex: string) => {
|
export const hexStringToTypedArray = (hex: string) => {
|
||||||
|
const f = hex.match(/[\da-f]{2}/gi);
|
||||||
|
if (f === null) {
|
||||||
|
throw Error(`input ${hex} is not hex, no way to transform`);
|
||||||
|
}
|
||||||
return new Uint8Array(
|
return new Uint8Array(
|
||||||
hex.match(/[\da-f]{2}/gi).map(function (h) {
|
f.map(function (h) {
|
||||||
return parseInt(h, 16);
|
return parseInt(h, 16);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -157,6 +165,9 @@ export const base64ToBase64url = (a: string, pad: boolean = false) => {
|
|||||||
* @param a
|
* @param a
|
||||||
*/
|
*/
|
||||||
export const isVaildText = (a: string) => {
|
export const isVaildText = (a: string) => {
|
||||||
|
if (a === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// If the regex matches, the string is invalid.
|
// If the regex matches, the string is invalid.
|
||||||
return !XRegExp("\\p{Cc}|\\p{Cf}|\\p{Co}|\\p{Cn}|\\p{Zl}|\\p{Zp}", "A").test(
|
return !XRegExp("\\p{Cc}|\\p{Cf}|\\p{Co}|\\p{Cn}|\\p{Zl}|\\p{Zp}", "A").test(
|
||||||
a
|
a
|
||||||
@@ -236,7 +247,7 @@ export const setToString = (a: Set<string>, delimiter: string = ",") => {
|
|||||||
export const extractSvgSub = (x: string, subEl: string = "rect") => {
|
export const extractSvgSub = (x: string, subEl: string = "rect") => {
|
||||||
const parser = new window.DOMParser();
|
const parser = new window.DOMParser();
|
||||||
const dom = parser.parseFromString(x, "image/svg+xml");
|
const dom = parser.parseFromString(x, "image/svg+xml");
|
||||||
const svg = dom.querySelector("svg");
|
const svg = dom.querySelector("svg")!;
|
||||||
svg.setAttribute("viewbox", "0 0 10 10");
|
svg.setAttribute("viewbox", "0 0 10 10");
|
||||||
return svg.innerHTML;
|
return svg.innerHTML;
|
||||||
};
|
};
|
||||||
@@ -317,7 +328,7 @@ export const getTypeName = (obj: any) => {
|
|||||||
* @param x
|
* @param x
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export const atWhichLevel = (x: string) => {
|
export const atWhichLevel = (x: string | undefined) => {
|
||||||
if (
|
if (
|
||||||
x === undefined ||
|
x === undefined ||
|
||||||
x === "" ||
|
x === "" ||
|
||||||
@@ -413,11 +424,14 @@ export const toText = (x: any) => {
|
|||||||
*/
|
*/
|
||||||
export const statFix = async (vault: Vault, path: string) => {
|
export const statFix = async (vault: Vault, path: string) => {
|
||||||
const s = await vault.adapter.stat(path);
|
const s = await vault.adapter.stat(path);
|
||||||
|
if (s === undefined || s === null) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
if (s.ctime === undefined || s.ctime === null || Number.isNaN(s.ctime)) {
|
if (s.ctime === undefined || s.ctime === null || Number.isNaN(s.ctime)) {
|
||||||
s.ctime = undefined;
|
s.ctime = undefined as any; // force assignment
|
||||||
}
|
}
|
||||||
if (s.mtime === undefined || s.mtime === null || Number.isNaN(s.mtime)) {
|
if (s.mtime === undefined || s.mtime === null || Number.isNaN(s.mtime)) {
|
||||||
s.mtime = undefined;
|
s.mtime = undefined as any; // force assignment
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
(s.size === undefined || s.size === null || Number.isNaN(s.size)) &&
|
(s.size === undefined || s.size === null || Number.isNaN(s.size)) &&
|
||||||
@@ -428,7 +442,10 @@ export const statFix = async (vault: Vault, path: string) => {
|
|||||||
return s;
|
return s;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isFolderToSkip = (x: string, more: string[] | undefined) => {
|
export const isSpecialFolderNameToSkip = (
|
||||||
|
x: string,
|
||||||
|
more: string[] | undefined
|
||||||
|
) => {
|
||||||
let specialFolders = [
|
let specialFolders = [
|
||||||
".git",
|
".git",
|
||||||
".github",
|
".github",
|
||||||
@@ -483,3 +500,154 @@ export const compareVersion = (x: string | null, y: string | null) => {
|
|||||||
}
|
}
|
||||||
return -1;
|
return -1;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://stackoverflow.com/questions/19929641/how-to-append-an-html-string-to-a-documentfragment
|
||||||
|
* To introduce some advanced html fragments.
|
||||||
|
* @param string
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const stringToFragment = (string: string) => {
|
||||||
|
const wrapper = document.createElement("template");
|
||||||
|
wrapper.innerHTML = string;
|
||||||
|
return wrapper.content;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://stackoverflow.com/questions/39538473/using-settimeout-on-promise-chain
|
||||||
|
* @param ms
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const delay = (ms: number) =>
|
||||||
|
new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://forum.obsidian.md/t/css-to-show-status-bar-on-mobile-devices/77185
|
||||||
|
* @param op
|
||||||
|
*/
|
||||||
|
export const changeMobileStatusBar = (
|
||||||
|
op: "enable" | "disable",
|
||||||
|
oldAppContainerObserver?: MutationObserver
|
||||||
|
) => {
|
||||||
|
const appContainer = document.getElementsByClassName("app-container")[0] as
|
||||||
|
| HTMLElement
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
const statusbar = document.querySelector(
|
||||||
|
".is-mobile .app-container .status-bar"
|
||||||
|
) as HTMLElement | undefined;
|
||||||
|
|
||||||
|
if (appContainer === undefined || statusbar === undefined) {
|
||||||
|
// give up, exit
|
||||||
|
console.warn(`give up watching appContainer for statusbar`);
|
||||||
|
console.warn(`appContainer=${appContainer}, statusbar=${statusbar}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (op === "enable") {
|
||||||
|
const callback = async (
|
||||||
|
mutationList: MutationRecord[],
|
||||||
|
observer: MutationObserver
|
||||||
|
) => {
|
||||||
|
for (const mutation of mutationList) {
|
||||||
|
// console.debug(mutation);
|
||||||
|
if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
|
||||||
|
const k = mutation.addedNodes[0] as Element;
|
||||||
|
if (
|
||||||
|
k.className.contains("mobile-navbar") ||
|
||||||
|
k.className.contains("mobile-toolbar")
|
||||||
|
) {
|
||||||
|
// have to wait, otherwise the height is not correct??
|
||||||
|
await delay(300);
|
||||||
|
const height = window
|
||||||
|
.getComputedStyle(k as Element)
|
||||||
|
.getPropertyValue("height");
|
||||||
|
|
||||||
|
statusbar.style.setProperty("display", "flex");
|
||||||
|
statusbar.style.setProperty("margin-bottom", height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const observer = new MutationObserver(callback);
|
||||||
|
observer.observe(appContainer, {
|
||||||
|
attributes: false,
|
||||||
|
childList: true,
|
||||||
|
characterData: false,
|
||||||
|
subtree: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// init, manual call
|
||||||
|
const navBar = document.getElementsByClassName(
|
||||||
|
"mobile-navbar"
|
||||||
|
)[0] as HTMLElement;
|
||||||
|
// thanks to community's solution
|
||||||
|
const height = window.getComputedStyle(navBar).getPropertyValue("height");
|
||||||
|
statusbar.style.setProperty("display", "flex");
|
||||||
|
statusbar.style.setProperty("margin-bottom", height);
|
||||||
|
} catch (e) {
|
||||||
|
// skip
|
||||||
|
}
|
||||||
|
|
||||||
|
return observer;
|
||||||
|
} else {
|
||||||
|
if (oldAppContainerObserver !== undefined) {
|
||||||
|
console.debug(`disconnect oldAppContainerObserver`);
|
||||||
|
oldAppContainerObserver.disconnect();
|
||||||
|
oldAppContainerObserver = undefined;
|
||||||
|
}
|
||||||
|
statusbar.style.removeProperty("display");
|
||||||
|
statusbar.style.removeProperty("margin-bottom");
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://github.com/remotely-save/remotely-save/issues/567
|
||||||
|
* https://www.dropboxforum.com/t5/Dropbox-API-Support-Feedback/Case-Sensitivity-in-API-2/td-p/191279
|
||||||
|
* @param entities
|
||||||
|
*/
|
||||||
|
export const fixEntityListCasesInplace = (entities: { keyRaw: string }[]) => {
|
||||||
|
entities.sort((a, b) => a.keyRaw.length - b.keyRaw.length);
|
||||||
|
// console.log(JSON.stringify(entities,null,2));
|
||||||
|
|
||||||
|
const caseMapping: Record<string, string> = { "": "" };
|
||||||
|
for (const e of entities) {
|
||||||
|
// console.log(`looking for: ${JSON.stringify(e, null, 2)}`);
|
||||||
|
|
||||||
|
let parentFolder = getParentFolder(e.keyRaw);
|
||||||
|
if (parentFolder === "/") {
|
||||||
|
parentFolder = "";
|
||||||
|
}
|
||||||
|
const parentFolderLower = parentFolder.toLocaleLowerCase();
|
||||||
|
const segs = e.keyRaw.split("/");
|
||||||
|
if (e.keyRaw.endsWith("/")) {
|
||||||
|
// folder
|
||||||
|
if (caseMapping.hasOwnProperty(parentFolderLower)) {
|
||||||
|
const newKeyRaw = `${caseMapping[parentFolderLower]}${segs
|
||||||
|
.slice(-2)
|
||||||
|
.join("/")}`;
|
||||||
|
caseMapping[newKeyRaw.toLocaleLowerCase()] = newKeyRaw;
|
||||||
|
e.keyRaw = newKeyRaw;
|
||||||
|
// console.log(JSON.stringify(caseMapping,null,2));
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
throw Error(`${parentFolder} doesn't have cases record??`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// file
|
||||||
|
if (caseMapping.hasOwnProperty(parentFolderLower)) {
|
||||||
|
const newKeyRaw = `${caseMapping[parentFolderLower]}${segs
|
||||||
|
.slice(-1)
|
||||||
|
.join("/")}`;
|
||||||
|
e.keyRaw = newKeyRaw;
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
throw Error(`${parentFolder} doesn't have cases record??`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
// It's very dangerous for this file to depend on other files in the same project.
|
|
||||||
// We should avoid this situation as much as possible.
|
|
||||||
|
|
||||||
import { TAbstractFile, TFolder, TFile, Vault } from "obsidian";
|
|
||||||
|
|
||||||
import * as origLog from "loglevel";
|
|
||||||
import type {
|
|
||||||
LogLevelNumbers,
|
|
||||||
Logger,
|
|
||||||
LogLevel,
|
|
||||||
LogLevelDesc,
|
|
||||||
LogLevelNames,
|
|
||||||
} from "loglevel";
|
|
||||||
const log2 = origLog.getLogger("rs-default");
|
|
||||||
|
|
||||||
const originalFactory = log2.methodFactory;
|
|
||||||
|
|
||||||
export const applyLogWriterInplace = function (writer: (...msg: any[]) => any) {
|
|
||||||
log2.methodFactory = function (
|
|
||||||
methodName: LogLevelNames,
|
|
||||||
logLevel: LogLevelNumbers,
|
|
||||||
loggerName: string | symbol
|
|
||||||
) {
|
|
||||||
const rawMethod = originalFactory(methodName, logLevel, loggerName);
|
|
||||||
|
|
||||||
return function (...msg: any[]) {
|
|
||||||
rawMethod.apply(undefined, msg);
|
|
||||||
writer(...msg);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
log2.setLevel(log2.getLevel());
|
|
||||||
};
|
|
||||||
|
|
||||||
export const restoreLogWritterInplace = () => {
|
|
||||||
log2.methodFactory = originalFactory;
|
|
||||||
log2.setLevel(log2.getLevel());
|
|
||||||
};
|
|
||||||
|
|
||||||
export const log = log2;
|
|
||||||
+36
-27
@@ -1,16 +1,10 @@
|
|||||||
import { Vault, Stat, ListedFiles } from "obsidian";
|
import type { Vault, Stat, ListedFiles } from "obsidian";
|
||||||
|
import type { Entity, MixedEntity } from "./baseTypes";
|
||||||
|
|
||||||
import { Queue } from "@fyears/tsqueue";
|
import { Queue } from "@fyears/tsqueue";
|
||||||
import chunk from "lodash/chunk";
|
import chunk from "lodash/chunk";
|
||||||
import flatten from "lodash/flatten";
|
import flatten from "lodash/flatten";
|
||||||
import { statFix, isFolderToSkip } from "./misc";
|
import { statFix, isSpecialFolderNameToSkip } from "./misc";
|
||||||
|
|
||||||
export interface ObsConfigDirFileType {
|
|
||||||
key: string;
|
|
||||||
ctime: number;
|
|
||||||
mtime: number;
|
|
||||||
size: number;
|
|
||||||
type: "folder" | "file";
|
|
||||||
}
|
|
||||||
|
|
||||||
const isPluginDirItself = (x: string, pluginId: string) => {
|
const isPluginDirItself = (x: string, pluginId: string) => {
|
||||||
return (
|
return (
|
||||||
@@ -37,25 +31,18 @@ const isLikelyPluginSubFiles = (x: string) => {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isInsideObsFolder = (x: string, configDir: string) => {
|
|
||||||
if (!configDir.startsWith(".")) {
|
|
||||||
throw Error(`configDir should starts with . but we get ${configDir}`);
|
|
||||||
}
|
|
||||||
return x === configDir || x.startsWith(`${configDir}/`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const listFilesInObsFolder = async (
|
export const listFilesInObsFolder = async (
|
||||||
configDir: string,
|
configDir: string,
|
||||||
vault: Vault,
|
vault: Vault,
|
||||||
pluginId: string
|
pluginId: string
|
||||||
) => {
|
): Promise<Entity[]> => {
|
||||||
const q = new Queue([configDir]);
|
const q = new Queue([configDir]);
|
||||||
const CHUNK_SIZE = 10;
|
const CHUNK_SIZE = 10;
|
||||||
const contents: ObsConfigDirFileType[] = [];
|
const contents: Entity[] = [];
|
||||||
while (q.length > 0) {
|
while (q.length > 0) {
|
||||||
const itemsToFetch = [];
|
const itemsToFetch: string[] = [];
|
||||||
while (q.length > 0) {
|
while (q.length > 0) {
|
||||||
itemsToFetch.push(q.pop());
|
itemsToFetch.push(q.pop()!);
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemsToFetchChunks = chunk(itemsToFetch, CHUNK_SIZE);
|
const itemsToFetchChunks = chunk(itemsToFetch, CHUNK_SIZE);
|
||||||
@@ -63,17 +50,35 @@ export const listFilesInObsFolder = async (
|
|||||||
const r = singleChunk.map(async (x) => {
|
const r = singleChunk.map(async (x) => {
|
||||||
const statRes = await statFix(vault, x);
|
const statRes = await statFix(vault, x);
|
||||||
|
|
||||||
|
if (statRes === undefined || statRes === null) {
|
||||||
|
throw Error("something goes wrong while listing hidden folder");
|
||||||
|
}
|
||||||
const isFolder = statRes.type === "folder";
|
const isFolder = statRes.type === "folder";
|
||||||
let children: ListedFiles = undefined;
|
let children: ListedFiles | undefined = undefined;
|
||||||
if (isFolder) {
|
if (isFolder) {
|
||||||
children = await vault.adapter.list(x);
|
children = await vault.adapter.list(x);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isFolder &&
|
||||||
|
(statRes.mtime === undefined ||
|
||||||
|
statRes.mtime === null ||
|
||||||
|
statRes.mtime === 0)
|
||||||
|
) {
|
||||||
|
throw Error(
|
||||||
|
`File in Obsidian ${configDir} has last modified time 0: ${x}, don't know how to deal with it.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
itself: {
|
itself: {
|
||||||
key: isFolder ? `${x}/` : x,
|
key: isFolder ? `${x}/` : x, // local always unencrypted
|
||||||
...statRes,
|
keyRaw: isFolder ? `${x}/` : x,
|
||||||
} as ObsConfigDirFileType,
|
mtimeCli: statRes.mtime,
|
||||||
|
mtimeSvr: statRes.mtime,
|
||||||
|
size: statRes.size, // local always unencrypted
|
||||||
|
sizeRaw: statRes.size,
|
||||||
|
},
|
||||||
children: children,
|
children: children,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -84,7 +89,9 @@ export const listFilesInObsFolder = async (
|
|||||||
const isInsideSelfPlugin = isPluginDirItself(iter.itself.key, pluginId);
|
const isInsideSelfPlugin = isPluginDirItself(iter.itself.key, pluginId);
|
||||||
if (iter.children !== undefined) {
|
if (iter.children !== undefined) {
|
||||||
for (const iter2 of iter.children.folders) {
|
for (const iter2 of iter.children.folders) {
|
||||||
if (isFolderToSkip(iter2, ["workspace", "workspace.json"])) {
|
if (
|
||||||
|
isSpecialFolderNameToSkip(iter2, ["workspace", "workspace.json"])
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (isInsideSelfPlugin && !isLikelyPluginSubFiles(iter2)) {
|
if (isInsideSelfPlugin && !isLikelyPluginSubFiles(iter2)) {
|
||||||
@@ -94,7 +101,9 @@ export const listFilesInObsFolder = async (
|
|||||||
q.push(iter2);
|
q.push(iter2);
|
||||||
}
|
}
|
||||||
for (const iter2 of iter.children.files) {
|
for (const iter2 of iter.children.files) {
|
||||||
if (isFolderToSkip(iter2, ["workspace", "workspace.json"])) {
|
if (
|
||||||
|
isSpecialFolderNameToSkip(iter2, ["workspace", "workspace.json"])
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (isInsideSelfPlugin && !isLikelyPluginSubFiles(iter2)) {
|
if (isInsideSelfPlugin && !isLikelyPluginSubFiles(iter2)) {
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
import type {
|
|
||||||
RemotelySavePluginSettings,
|
|
||||||
WebdavConfig,
|
|
||||||
WebdavDepthType,
|
|
||||||
} from "./baseTypes";
|
|
||||||
|
|
||||||
const RULES = {
|
|
||||||
webdav: {
|
|
||||||
depth: [
|
|
||||||
{
|
|
||||||
url: "^https://(.+).teracloud.jp/.+",
|
|
||||||
depth: "auto_1",
|
|
||||||
manualRecursive: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "^https://dav.jianguoyun.com/dav/",
|
|
||||||
depth: "auto_1",
|
|
||||||
manualRecursive: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const applyWebdavPresetRulesInplace = (
|
|
||||||
webdav: Partial<WebdavConfig> | undefined
|
|
||||||
) => {
|
|
||||||
if (webdav === undefined) {
|
|
||||||
return {
|
|
||||||
changed: false,
|
|
||||||
webdav: webdav,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
for (const { url, depth, manualRecursive } of RULES.webdav.depth) {
|
|
||||||
if (
|
|
||||||
webdav.address !== undefined &&
|
|
||||||
new RegExp(url).test(webdav.address) &&
|
|
||||||
webdav.depth !== undefined &&
|
|
||||||
webdav.depth.startsWith("auto_") &&
|
|
||||||
webdav.depth !== depth
|
|
||||||
) {
|
|
||||||
webdav.depth = depth as WebdavDepthType;
|
|
||||||
webdav.manualRecursive = manualRecursive;
|
|
||||||
return {
|
|
||||||
changed: true,
|
|
||||||
webdav: webdav,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
changed: false,
|
|
||||||
webdav: webdav,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const applyPresetRulesInplace = (
|
|
||||||
settings: RemotelySavePluginSettings | undefined
|
|
||||||
) => {
|
|
||||||
if (settings === undefined) {
|
|
||||||
return {
|
|
||||||
changed: false,
|
|
||||||
settings: settings,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const webdavRes = applyWebdavPresetRulesInplace(settings.webdav);
|
|
||||||
return {
|
|
||||||
changed: webdavRes.changed,
|
|
||||||
settings: settings,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { SUPPORTED_SERVICES_TYPE } from "./baseTypes";
|
||||||
|
import { InternalDBs, insertProfilerResultByVault } from "./localdb";
|
||||||
|
import { unixTimeToStr } from "./misc";
|
||||||
|
|
||||||
|
interface BreakPoint {
|
||||||
|
label: string;
|
||||||
|
fakeTimeMilli: number; // it's NOT a unix timestamp
|
||||||
|
indent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Profiler {
|
||||||
|
startTime: number;
|
||||||
|
breakPoints: BreakPoint[];
|
||||||
|
indent: number;
|
||||||
|
constructor(label?: string) {
|
||||||
|
this.breakPoints = [];
|
||||||
|
this.indent = 0;
|
||||||
|
this.startTime = 0;
|
||||||
|
|
||||||
|
if (label !== undefined) {
|
||||||
|
this.startTime = Date.now();
|
||||||
|
this.breakPoints.push({
|
||||||
|
label: label,
|
||||||
|
fakeTimeMilli: performance.now(),
|
||||||
|
indent: this.indent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
insert(label: string) {
|
||||||
|
if (this.breakPoints.length === 0) {
|
||||||
|
this.startTime = Date.now();
|
||||||
|
}
|
||||||
|
this.breakPoints.push({
|
||||||
|
label: label,
|
||||||
|
fakeTimeMilli: performance.now(),
|
||||||
|
indent: this.indent,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
addIndent() {
|
||||||
|
this.indent += 2;
|
||||||
|
}
|
||||||
|
removeIndent() {
|
||||||
|
this.indent -= 2;
|
||||||
|
if (this.indent < 0) {
|
||||||
|
this.indent = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.breakPoints = [];
|
||||||
|
this.indent = 0;
|
||||||
|
this.startTime = 0;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
if (this.breakPoints.length === 0) {
|
||||||
|
return "nothing in profiler";
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = `[startTime]: ${unixTimeToStr(this.startTime)}`;
|
||||||
|
for (let i = 0; i < this.breakPoints.length; ++i) {
|
||||||
|
if (i === 0) {
|
||||||
|
res += `\n[${this.breakPoints[i]["label"]}]: start`;
|
||||||
|
} else {
|
||||||
|
const label = this.breakPoints[i]["label"];
|
||||||
|
const indent = this.breakPoints[i]["indent"];
|
||||||
|
const millsec =
|
||||||
|
Math.round(
|
||||||
|
(this.breakPoints[i]["fakeTimeMilli"] -
|
||||||
|
this.breakPoints[i - 1]["fakeTimeMilli"]) *
|
||||||
|
10
|
||||||
|
) / 10.0;
|
||||||
|
res += `\n${" ".repeat(indent)}[${label}]: ${millsec}ms`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(
|
||||||
|
db: InternalDBs,
|
||||||
|
vaultRandomID: string,
|
||||||
|
remoteType: SUPPORTED_SERVICES_TYPE
|
||||||
|
) {
|
||||||
|
await insertProfilerResultByVault(
|
||||||
|
db,
|
||||||
|
this.toString(),
|
||||||
|
vaultRandomID,
|
||||||
|
remoteType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
-315
@@ -1,315 +0,0 @@
|
|||||||
import { Vault } from "obsidian";
|
|
||||||
import type {
|
|
||||||
DropboxConfig,
|
|
||||||
OnedriveConfig,
|
|
||||||
S3Config,
|
|
||||||
SUPPORTED_SERVICES_TYPE,
|
|
||||||
WebdavConfig,
|
|
||||||
} from "./baseTypes";
|
|
||||||
import * as dropbox from "./remoteForDropbox";
|
|
||||||
import * as onedrive from "./remoteForOnedrive";
|
|
||||||
import * as s3 from "./remoteForS3";
|
|
||||||
import * as webdav from "./remoteForWebdav";
|
|
||||||
|
|
||||||
import { log } from "./moreOnLog";
|
|
||||||
|
|
||||||
export class RemoteClient {
|
|
||||||
readonly serviceType: SUPPORTED_SERVICES_TYPE;
|
|
||||||
readonly s3Config?: S3Config;
|
|
||||||
readonly webdavClient?: webdav.WrappedWebdavClient;
|
|
||||||
readonly webdavConfig?: WebdavConfig;
|
|
||||||
readonly dropboxClient?: dropbox.WrappedDropboxClient;
|
|
||||||
readonly dropboxConfig?: DropboxConfig;
|
|
||||||
readonly onedriveClient?: onedrive.WrappedOnedriveClient;
|
|
||||||
readonly onedriveConfig?: OnedriveConfig;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
serviceType: SUPPORTED_SERVICES_TYPE,
|
|
||||||
s3Config?: S3Config,
|
|
||||||
webdavConfig?: WebdavConfig,
|
|
||||||
dropboxConfig?: DropboxConfig,
|
|
||||||
onedriveConfig?: OnedriveConfig,
|
|
||||||
vaultName?: string,
|
|
||||||
saveUpdatedConfigFunc?: () => Promise<any>
|
|
||||||
) {
|
|
||||||
this.serviceType = serviceType;
|
|
||||||
// the client may modify the config inplace,
|
|
||||||
// so we use a ref not copy of config here
|
|
||||||
if (serviceType === "s3") {
|
|
||||||
this.s3Config = s3Config;
|
|
||||||
} else if (serviceType === "webdav") {
|
|
||||||
if (vaultName === undefined || saveUpdatedConfigFunc === undefined) {
|
|
||||||
throw Error(
|
|
||||||
"remember to provide vault name and callback while init webdav client"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const remoteBaseDir = webdavConfig.remoteBaseDir || vaultName;
|
|
||||||
this.webdavConfig = webdavConfig;
|
|
||||||
this.webdavClient = webdav.getWebdavClient(
|
|
||||||
this.webdavConfig,
|
|
||||||
remoteBaseDir,
|
|
||||||
saveUpdatedConfigFunc
|
|
||||||
);
|
|
||||||
} else if (serviceType === "dropbox") {
|
|
||||||
if (vaultName === undefined || saveUpdatedConfigFunc === undefined) {
|
|
||||||
throw Error(
|
|
||||||
"remember to provide vault name and callback while init dropbox client"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const remoteBaseDir = dropboxConfig.remoteBaseDir || vaultName;
|
|
||||||
this.dropboxConfig = dropboxConfig;
|
|
||||||
this.dropboxClient = dropbox.getDropboxClient(
|
|
||||||
this.dropboxConfig,
|
|
||||||
remoteBaseDir,
|
|
||||||
saveUpdatedConfigFunc
|
|
||||||
);
|
|
||||||
} else if (serviceType === "onedrive") {
|
|
||||||
if (vaultName === undefined || saveUpdatedConfigFunc === undefined) {
|
|
||||||
throw Error(
|
|
||||||
"remember to provide vault name and callback while init onedrive client"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const remoteBaseDir = onedriveConfig.remoteBaseDir || vaultName;
|
|
||||||
this.onedriveConfig = onedriveConfig;
|
|
||||||
this.onedriveClient = onedrive.getOnedriveClient(
|
|
||||||
this.onedriveConfig,
|
|
||||||
remoteBaseDir,
|
|
||||||
saveUpdatedConfigFunc
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
throw Error(`not supported service type ${this.serviceType}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getRemoteMeta = async (fileOrFolderPath: string) => {
|
|
||||||
if (this.serviceType === "s3") {
|
|
||||||
return await s3.getRemoteMeta(
|
|
||||||
s3.getS3Client(this.s3Config),
|
|
||||||
this.s3Config,
|
|
||||||
fileOrFolderPath
|
|
||||||
);
|
|
||||||
} else if (this.serviceType === "webdav") {
|
|
||||||
return await webdav.getRemoteMeta(this.webdavClient, fileOrFolderPath);
|
|
||||||
} else if (this.serviceType === "dropbox") {
|
|
||||||
return await dropbox.getRemoteMeta(this.dropboxClient, fileOrFolderPath);
|
|
||||||
} else if (this.serviceType === "onedrive") {
|
|
||||||
return await onedrive.getRemoteMeta(
|
|
||||||
this.onedriveClient,
|
|
||||||
fileOrFolderPath
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
throw Error(`not supported service type ${this.serviceType}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
uploadToRemote = async (
|
|
||||||
fileOrFolderPath: string,
|
|
||||||
vault: Vault,
|
|
||||||
isRecursively: boolean = false,
|
|
||||||
password: string = "",
|
|
||||||
remoteEncryptedKey: string = "",
|
|
||||||
foldersCreatedBefore: Set<string> | undefined = undefined,
|
|
||||||
uploadRaw: boolean = false,
|
|
||||||
rawContent: string | ArrayBuffer = ""
|
|
||||||
) => {
|
|
||||||
if (this.serviceType === "s3") {
|
|
||||||
return await s3.uploadToRemote(
|
|
||||||
s3.getS3Client(this.s3Config),
|
|
||||||
this.s3Config,
|
|
||||||
fileOrFolderPath,
|
|
||||||
vault,
|
|
||||||
isRecursively,
|
|
||||||
password,
|
|
||||||
remoteEncryptedKey,
|
|
||||||
uploadRaw,
|
|
||||||
rawContent
|
|
||||||
);
|
|
||||||
} else if (this.serviceType === "webdav") {
|
|
||||||
return await webdav.uploadToRemote(
|
|
||||||
this.webdavClient,
|
|
||||||
fileOrFolderPath,
|
|
||||||
vault,
|
|
||||||
isRecursively,
|
|
||||||
password,
|
|
||||||
remoteEncryptedKey,
|
|
||||||
uploadRaw,
|
|
||||||
rawContent
|
|
||||||
);
|
|
||||||
} else if (this.serviceType === "dropbox") {
|
|
||||||
return await dropbox.uploadToRemote(
|
|
||||||
this.dropboxClient,
|
|
||||||
fileOrFolderPath,
|
|
||||||
vault,
|
|
||||||
isRecursively,
|
|
||||||
password,
|
|
||||||
remoteEncryptedKey,
|
|
||||||
foldersCreatedBefore,
|
|
||||||
uploadRaw,
|
|
||||||
rawContent
|
|
||||||
);
|
|
||||||
} else if (this.serviceType === "onedrive") {
|
|
||||||
return await onedrive.uploadToRemote(
|
|
||||||
this.onedriveClient,
|
|
||||||
fileOrFolderPath,
|
|
||||||
vault,
|
|
||||||
isRecursively,
|
|
||||||
password,
|
|
||||||
remoteEncryptedKey,
|
|
||||||
foldersCreatedBefore,
|
|
||||||
uploadRaw,
|
|
||||||
rawContent
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
throw Error(`not supported service type ${this.serviceType}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
listAllFromRemote = async () => {
|
|
||||||
if (this.serviceType === "s3") {
|
|
||||||
return await s3.listAllFromRemote(
|
|
||||||
s3.getS3Client(this.s3Config),
|
|
||||||
this.s3Config
|
|
||||||
);
|
|
||||||
} else if (this.serviceType === "webdav") {
|
|
||||||
return await webdav.listAllFromRemote(this.webdavClient);
|
|
||||||
} else if (this.serviceType === "dropbox") {
|
|
||||||
return await dropbox.listAllFromRemote(this.dropboxClient);
|
|
||||||
} else if (this.serviceType === "onedrive") {
|
|
||||||
return await onedrive.listAllFromRemote(this.onedriveClient);
|
|
||||||
} else {
|
|
||||||
throw Error(`not supported service type ${this.serviceType}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
downloadFromRemote = async (
|
|
||||||
fileOrFolderPath: string,
|
|
||||||
vault: Vault,
|
|
||||||
mtime: number,
|
|
||||||
password: string = "",
|
|
||||||
remoteEncryptedKey: string = "",
|
|
||||||
skipSaving: boolean = false
|
|
||||||
) => {
|
|
||||||
if (this.serviceType === "s3") {
|
|
||||||
return await s3.downloadFromRemote(
|
|
||||||
s3.getS3Client(this.s3Config),
|
|
||||||
this.s3Config,
|
|
||||||
fileOrFolderPath,
|
|
||||||
vault,
|
|
||||||
mtime,
|
|
||||||
password,
|
|
||||||
remoteEncryptedKey,
|
|
||||||
skipSaving
|
|
||||||
);
|
|
||||||
} else if (this.serviceType === "webdav") {
|
|
||||||
return await webdav.downloadFromRemote(
|
|
||||||
this.webdavClient,
|
|
||||||
fileOrFolderPath,
|
|
||||||
vault,
|
|
||||||
mtime,
|
|
||||||
password,
|
|
||||||
remoteEncryptedKey,
|
|
||||||
skipSaving
|
|
||||||
);
|
|
||||||
} else if (this.serviceType === "dropbox") {
|
|
||||||
return await dropbox.downloadFromRemote(
|
|
||||||
this.dropboxClient,
|
|
||||||
fileOrFolderPath,
|
|
||||||
vault,
|
|
||||||
mtime,
|
|
||||||
password,
|
|
||||||
remoteEncryptedKey,
|
|
||||||
skipSaving
|
|
||||||
);
|
|
||||||
} else if (this.serviceType === "onedrive") {
|
|
||||||
return await onedrive.downloadFromRemote(
|
|
||||||
this.onedriveClient,
|
|
||||||
fileOrFolderPath,
|
|
||||||
vault,
|
|
||||||
mtime,
|
|
||||||
password,
|
|
||||||
remoteEncryptedKey,
|
|
||||||
skipSaving
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
throw Error(`not supported service type ${this.serviceType}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
deleteFromRemote = async (
|
|
||||||
fileOrFolderPath: string,
|
|
||||||
password: string = "",
|
|
||||||
remoteEncryptedKey: string = ""
|
|
||||||
) => {
|
|
||||||
if (this.serviceType === "s3") {
|
|
||||||
return await s3.deleteFromRemote(
|
|
||||||
s3.getS3Client(this.s3Config),
|
|
||||||
this.s3Config,
|
|
||||||
fileOrFolderPath,
|
|
||||||
password,
|
|
||||||
remoteEncryptedKey
|
|
||||||
);
|
|
||||||
} else if (this.serviceType === "webdav") {
|
|
||||||
return await webdav.deleteFromRemote(
|
|
||||||
this.webdavClient,
|
|
||||||
fileOrFolderPath,
|
|
||||||
password,
|
|
||||||
remoteEncryptedKey
|
|
||||||
);
|
|
||||||
} else if (this.serviceType === "dropbox") {
|
|
||||||
return await dropbox.deleteFromRemote(
|
|
||||||
this.dropboxClient,
|
|
||||||
fileOrFolderPath,
|
|
||||||
password,
|
|
||||||
remoteEncryptedKey
|
|
||||||
);
|
|
||||||
} else if (this.serviceType === "onedrive") {
|
|
||||||
return await onedrive.deleteFromRemote(
|
|
||||||
this.onedriveClient,
|
|
||||||
fileOrFolderPath,
|
|
||||||
password,
|
|
||||||
remoteEncryptedKey
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
throw Error(`not supported service type ${this.serviceType}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
checkConnectivity = async (callbackFunc?: any) => {
|
|
||||||
if (this.serviceType === "s3") {
|
|
||||||
return await s3.checkConnectivity(
|
|
||||||
s3.getS3Client(this.s3Config),
|
|
||||||
this.s3Config,
|
|
||||||
callbackFunc
|
|
||||||
);
|
|
||||||
} else if (this.serviceType === "webdav") {
|
|
||||||
return await webdav.checkConnectivity(this.webdavClient, callbackFunc);
|
|
||||||
} else if (this.serviceType === "dropbox") {
|
|
||||||
return await dropbox.checkConnectivity(this.dropboxClient, callbackFunc);
|
|
||||||
} else if (this.serviceType === "onedrive") {
|
|
||||||
return await onedrive.checkConnectivity(
|
|
||||||
this.onedriveClient,
|
|
||||||
callbackFunc
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
throw Error(`not supported service type ${this.serviceType}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
getUser = async () => {
|
|
||||||
if (this.serviceType === "dropbox") {
|
|
||||||
return await dropbox.getUserDisplayName(this.dropboxClient);
|
|
||||||
} else if (this.serviceType === "onedrive") {
|
|
||||||
return await onedrive.getUserDisplayName(this.onedriveClient);
|
|
||||||
} else {
|
|
||||||
throw Error(`not supported service type ${this.serviceType}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
revokeAuth = async () => {
|
|
||||||
if (this.serviceType === "dropbox") {
|
|
||||||
return await dropbox.revokeAuth(this.dropboxClient);
|
|
||||||
} else {
|
|
||||||
throw Error(`not supported service type ${this.serviceType}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,788 +0,0 @@
|
|||||||
import { rangeDelay } from "delay";
|
|
||||||
import { Dropbox, DropboxAuth } from "dropbox";
|
|
||||||
import type { files, DropboxResponseError, DropboxResponse } from "dropbox";
|
|
||||||
import { Vault } from "obsidian";
|
|
||||||
import * as path from "path";
|
|
||||||
import {
|
|
||||||
DropboxConfig,
|
|
||||||
RemoteItem,
|
|
||||||
COMMAND_CALLBACK_DROPBOX,
|
|
||||||
OAUTH2_FORCE_EXPIRE_MILLISECONDS,
|
|
||||||
} from "./baseTypes";
|
|
||||||
import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
|
|
||||||
import {
|
|
||||||
bufferToArrayBuffer,
|
|
||||||
getFolderLevels,
|
|
||||||
hasEmojiInText,
|
|
||||||
headersToRecord,
|
|
||||||
mkdirpInVault,
|
|
||||||
} from "./misc";
|
|
||||||
|
|
||||||
export { Dropbox } from "dropbox";
|
|
||||||
|
|
||||||
import { log } from "./moreOnLog";
|
|
||||||
|
|
||||||
export const DEFAULT_DROPBOX_CONFIG: DropboxConfig = {
|
|
||||||
accessToken: "",
|
|
||||||
clientID: process.env.DEFAULT_DROPBOX_APP_KEY,
|
|
||||||
refreshToken: "",
|
|
||||||
accessTokenExpiresInSeconds: 0,
|
|
||||||
accessTokenExpiresAtTime: 0,
|
|
||||||
accountID: "",
|
|
||||||
username: "",
|
|
||||||
credentialsShouldBeDeletedAtTime: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getDropboxPath = (
|
|
||||||
fileOrFolderPath: string,
|
|
||||||
remoteBaseDir: string
|
|
||||||
) => {
|
|
||||||
let key = fileOrFolderPath;
|
|
||||||
if (fileOrFolderPath === "/" || fileOrFolderPath === "") {
|
|
||||||
// special
|
|
||||||
key = `/${remoteBaseDir}`;
|
|
||||||
}
|
|
||||||
if (!fileOrFolderPath.startsWith("/")) {
|
|
||||||
// then this is original path in Obsidian
|
|
||||||
key = `/${remoteBaseDir}/${fileOrFolderPath}`;
|
|
||||||
}
|
|
||||||
if (key.endsWith("/")) {
|
|
||||||
key = key.slice(0, key.length - 1);
|
|
||||||
}
|
|
||||||
return key;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getNormPath = (fileOrFolderPath: string, remoteBaseDir: string) => {
|
|
||||||
if (
|
|
||||||
!(
|
|
||||||
fileOrFolderPath === `/${remoteBaseDir}` ||
|
|
||||||
fileOrFolderPath.startsWith(`/${remoteBaseDir}/`)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw Error(
|
|
||||||
`"${fileOrFolderPath}" doesn't starts with "/${remoteBaseDir}/"`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return fileOrFolderPath.slice(`/${remoteBaseDir}/`.length);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fromDropboxItemToRemoteItem = (
|
|
||||||
x:
|
|
||||||
| files.FileMetadataReference
|
|
||||||
| files.FolderMetadataReference
|
|
||||||
| files.DeletedMetadataReference,
|
|
||||||
remoteBaseDir: string
|
|
||||||
): RemoteItem => {
|
|
||||||
let key = getNormPath(x.path_display, remoteBaseDir);
|
|
||||||
if (x[".tag"] === "folder" && !key.endsWith("/")) {
|
|
||||||
key = `${key}/`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (x[".tag"] === "folder") {
|
|
||||||
return {
|
|
||||||
key: key,
|
|
||||||
lastModified: undefined,
|
|
||||||
size: 0,
|
|
||||||
remoteType: "dropbox",
|
|
||||||
etag: `${x.id}\t`,
|
|
||||||
} as RemoteItem;
|
|
||||||
} else if (x[".tag"] === "file") {
|
|
||||||
return {
|
|
||||||
key: key,
|
|
||||||
lastModified: Date.parse(x.server_modified).valueOf(),
|
|
||||||
size: x.size,
|
|
||||||
remoteType: "dropbox",
|
|
||||||
etag: `${x.id}\t${x.content_hash}`,
|
|
||||||
} as RemoteItem;
|
|
||||||
} else if (x[".tag"] === "deleted") {
|
|
||||||
throw Error("do not support deleted tag");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dropbox api doesn't return mtime for folders.
|
|
||||||
* This is a try to assign mtime by using files in folder.
|
|
||||||
* @param allFilesFolders
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
const fixLastModifiedTimeInplace = (allFilesFolders: RemoteItem[]) => {
|
|
||||||
if (allFilesFolders.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// sort by longer to shorter
|
|
||||||
allFilesFolders.sort((a, b) => b.key.length - a.key.length);
|
|
||||||
|
|
||||||
// a "map" from dir to mtime
|
|
||||||
let potentialMTime = {} as Record<string, number>;
|
|
||||||
|
|
||||||
// first sort pass, from buttom to up
|
|
||||||
for (const item of allFilesFolders) {
|
|
||||||
if (item.key.endsWith("/")) {
|
|
||||||
// itself is a folder, and initially doesn't have mtime
|
|
||||||
if (item.lastModified === undefined && item.key in potentialMTime) {
|
|
||||||
// previously we gathered all sub info of this folder
|
|
||||||
item.lastModified = potentialMTime[item.key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const parent = `${path.posix.dirname(item.key)}/`;
|
|
||||||
if (item.lastModified !== undefined) {
|
|
||||||
if (parent in potentialMTime) {
|
|
||||||
potentialMTime[parent] = Math.max(
|
|
||||||
potentialMTime[parent],
|
|
||||||
item.lastModified
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
potentialMTime[parent] = item.lastModified;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// second pass, from up to buttom.
|
|
||||||
// fill mtime by parent folder or Date.Now() if still not available.
|
|
||||||
// this is only possible if no any sub-folder-files recursively.
|
|
||||||
// we do not sort the array again, just iterate over it by reverse
|
|
||||||
// using good old for loop.
|
|
||||||
for (let i = allFilesFolders.length - 1; i >= 0; --i) {
|
|
||||||
const item = allFilesFolders[i];
|
|
||||||
if (!item.key.endsWith("/")) {
|
|
||||||
continue; // skip files
|
|
||||||
}
|
|
||||||
if (item.lastModified !== undefined) {
|
|
||||||
continue; // don't need to deal with it
|
|
||||||
}
|
|
||||||
const parent = `${path.posix.dirname(item.key)}/`;
|
|
||||||
if (parent in potentialMTime) {
|
|
||||||
item.lastModified = potentialMTime[parent];
|
|
||||||
} else {
|
|
||||||
item.lastModified = Date.now().valueOf();
|
|
||||||
potentialMTime[item.key] = item.lastModified;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return allFilesFolders;
|
|
||||||
};
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////////
|
|
||||||
// Dropbox authorization using PKCE
|
|
||||||
// see https://dropbox.tech/developers/pkce--what-and-why-
|
|
||||||
////////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
export const getAuthUrlAndVerifier = async (
|
|
||||||
appKey: string,
|
|
||||||
needManualPatse: boolean = false
|
|
||||||
) => {
|
|
||||||
const auth = new DropboxAuth({
|
|
||||||
clientId: appKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
const callback = needManualPatse
|
|
||||||
? undefined
|
|
||||||
: `obsidian://${COMMAND_CALLBACK_DROPBOX}`;
|
|
||||||
const authUrl = (
|
|
||||||
await auth.getAuthenticationUrl(
|
|
||||||
callback,
|
|
||||||
undefined,
|
|
||||||
"code",
|
|
||||||
"offline",
|
|
||||||
undefined,
|
|
||||||
"none",
|
|
||||||
true
|
|
||||||
)
|
|
||||||
).toString();
|
|
||||||
const verifier = auth.getCodeVerifier();
|
|
||||||
return {
|
|
||||||
authUrl: authUrl,
|
|
||||||
verifier: verifier,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface DropboxSuccessAuthRes {
|
|
||||||
access_token: string;
|
|
||||||
token_type: "bearer";
|
|
||||||
expires_in: string;
|
|
||||||
refresh_token?: string;
|
|
||||||
scope?: string;
|
|
||||||
uid?: string;
|
|
||||||
account_id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const sendAuthReq = async (
|
|
||||||
appKey: string,
|
|
||||||
verifier: string,
|
|
||||||
authCode: string
|
|
||||||
) => {
|
|
||||||
const resp1 = await fetch("https://api.dropboxapi.com/oauth2/token", {
|
|
||||||
method: "POST",
|
|
||||||
body: new URLSearchParams({
|
|
||||||
code: authCode,
|
|
||||||
grant_type: "authorization_code",
|
|
||||||
code_verifier: verifier,
|
|
||||||
client_id: appKey,
|
|
||||||
redirect_uri: `obsidian://${COMMAND_CALLBACK_DROPBOX}`,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const resp2 = (await resp1.json()) as DropboxSuccessAuthRes;
|
|
||||||
return resp2;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const sendRefreshTokenReq = async (
|
|
||||||
appKey: string,
|
|
||||||
refreshToken: string
|
|
||||||
) => {
|
|
||||||
log.info("start auto getting refreshed Dropbox access token.");
|
|
||||||
const resp1 = await fetch("https://api.dropboxapi.com/oauth2/token", {
|
|
||||||
method: "POST",
|
|
||||||
body: new URLSearchParams({
|
|
||||||
grant_type: "refresh_token",
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
client_id: appKey,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const resp2 = (await resp1.json()) as DropboxSuccessAuthRes;
|
|
||||||
log.info("finish auto getting refreshed Dropbox access token.");
|
|
||||||
return resp2;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setConfigBySuccessfullAuthInplace = async (
|
|
||||||
config: DropboxConfig,
|
|
||||||
authRes: DropboxSuccessAuthRes,
|
|
||||||
saveUpdatedConfigFunc: () => Promise<any> | undefined
|
|
||||||
) => {
|
|
||||||
log.info("start updating local info of Dropbox token");
|
|
||||||
|
|
||||||
config.accessToken = authRes.access_token;
|
|
||||||
config.accessTokenExpiresInSeconds = parseInt(authRes.expires_in);
|
|
||||||
config.accessTokenExpiresAtTime =
|
|
||||||
Date.now() + parseInt(authRes.expires_in) * 1000 - 10 * 1000;
|
|
||||||
|
|
||||||
// manually set it expired after 80 days;
|
|
||||||
config.credentialsShouldBeDeletedAtTime =
|
|
||||||
Date.now() + OAUTH2_FORCE_EXPIRE_MILLISECONDS;
|
|
||||||
|
|
||||||
if (authRes.refresh_token !== undefined) {
|
|
||||||
config.refreshToken = authRes.refresh_token;
|
|
||||||
}
|
|
||||||
if (authRes.refresh_token !== undefined) {
|
|
||||||
config.accountID = authRes.account_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (saveUpdatedConfigFunc !== undefined) {
|
|
||||||
await saveUpdatedConfigFunc();
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("finish updating local info of Dropbox token");
|
|
||||||
};
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////////
|
|
||||||
// Other usual common methods
|
|
||||||
////////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
interface ErrSubType {
|
|
||||||
error: {
|
|
||||||
retry_after: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function retryReq<T>(
|
|
||||||
reqFunc: () => Promise<DropboxResponse<T>>,
|
|
||||||
extraHint: string = ""
|
|
||||||
): Promise<DropboxResponse<T>> {
|
|
||||||
const waitSeconds = [1, 2, 4, 8]; // hard code exponential backoff
|
|
||||||
for (let idx = 0; idx < waitSeconds.length; ++idx) {
|
|
||||||
try {
|
|
||||||
if (idx !== 0) {
|
|
||||||
log.warn(
|
|
||||||
`${extraHint === "" ? "" : extraHint + ": "}The ${
|
|
||||||
idx + 1
|
|
||||||
}-th try starts at time ${Date.now()}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return await reqFunc();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const err = e as DropboxResponseError<ErrSubType>;
|
|
||||||
if (err.status === undefined) {
|
|
||||||
// then the err is not DropboxResponseError
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
if (err.status !== 429) {
|
|
||||||
// then the err is not "too many requests", give up
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (idx === waitSeconds.length - 1) {
|
|
||||||
// the last retry also failed, give up
|
|
||||||
throw new Error(
|
|
||||||
`${
|
|
||||||
extraHint === "" ? "" : extraHint + ": "
|
|
||||||
}"429 too many requests", after retrying for ${
|
|
||||||
idx + 1
|
|
||||||
} times still failed.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = headersToRecord(err.headers);
|
|
||||||
const svrSec =
|
|
||||||
err.error.error.retry_after ||
|
|
||||||
parseInt(headers["retry-after"] || "1") ||
|
|
||||||
1;
|
|
||||||
const fallbackSec = waitSeconds[idx];
|
|
||||||
const secMin = Math.max(svrSec, fallbackSec);
|
|
||||||
const secMax = Math.max(secMin * 1.8, 2);
|
|
||||||
log.warn(
|
|
||||||
`${
|
|
||||||
extraHint === "" ? "" : extraHint + ": "
|
|
||||||
}We have "429 too many requests" error of ${
|
|
||||||
idx + 1
|
|
||||||
}-th try, at time ${Date.now()}, and wait for ${secMin} ~ ${secMax} seconds to retry. Original info: ${JSON.stringify(
|
|
||||||
err.error,
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
await rangeDelay(secMin * 1000, secMax * 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class WrappedDropboxClient {
|
|
||||||
dropboxConfig: DropboxConfig;
|
|
||||||
remoteBaseDir: string;
|
|
||||||
saveUpdatedConfigFunc: () => Promise<any>;
|
|
||||||
dropbox: Dropbox;
|
|
||||||
vaultFolderExists: boolean;
|
|
||||||
constructor(
|
|
||||||
dropboxConfig: DropboxConfig,
|
|
||||||
remoteBaseDir: string,
|
|
||||||
saveUpdatedConfigFunc: () => Promise<any>
|
|
||||||
) {
|
|
||||||
this.dropboxConfig = dropboxConfig;
|
|
||||||
this.remoteBaseDir = remoteBaseDir;
|
|
||||||
this.saveUpdatedConfigFunc = saveUpdatedConfigFunc;
|
|
||||||
this.vaultFolderExists = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
init = async () => {
|
|
||||||
// check token
|
|
||||||
if (
|
|
||||||
this.dropboxConfig.accessToken === "" ||
|
|
||||||
this.dropboxConfig.refreshToken === ""
|
|
||||||
) {
|
|
||||||
throw Error("The user has not manually auth yet.");
|
|
||||||
}
|
|
||||||
const currentTs = Date.now();
|
|
||||||
const customHeaders = {
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
};
|
|
||||||
if (this.dropboxConfig.accessTokenExpiresAtTime > currentTs) {
|
|
||||||
this.dropbox = new Dropbox({
|
|
||||||
accessToken: this.dropboxConfig.accessToken,
|
|
||||||
customHeaders: customHeaders,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (this.dropboxConfig.refreshToken === "") {
|
|
||||||
throw Error(
|
|
||||||
"We need to automatically refresh token but none is stored."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const resp = await sendRefreshTokenReq(
|
|
||||||
this.dropboxConfig.clientID,
|
|
||||||
this.dropboxConfig.refreshToken
|
|
||||||
);
|
|
||||||
|
|
||||||
setConfigBySuccessfullAuthInplace(
|
|
||||||
this.dropboxConfig,
|
|
||||||
resp,
|
|
||||||
this.saveUpdatedConfigFunc
|
|
||||||
);
|
|
||||||
this.dropbox = new Dropbox({
|
|
||||||
accessToken: this.dropboxConfig.accessToken,
|
|
||||||
customHeaders: customHeaders,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// check vault folder
|
|
||||||
// log.info(`checking remote has folder /${this.remoteBaseDir}`);
|
|
||||||
if (this.vaultFolderExists) {
|
|
||||||
// log.info(`already checked, /${this.remoteBaseDir} exist before`)
|
|
||||||
} else {
|
|
||||||
const res = await this.dropbox.filesListFolder({
|
|
||||||
path: "",
|
|
||||||
recursive: false,
|
|
||||||
});
|
|
||||||
for (const item of res.result.entries) {
|
|
||||||
if (item.path_display === `/${this.remoteBaseDir}`) {
|
|
||||||
this.vaultFolderExists = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!this.vaultFolderExists) {
|
|
||||||
log.info(`remote does not have folder /${this.remoteBaseDir}`);
|
|
||||||
|
|
||||||
if (hasEmojiInText(`/${this.remoteBaseDir}`)) {
|
|
||||||
throw new Error(
|
|
||||||
`/${this.remoteBaseDir}: Error: Dropbox does not support emoji in folder names.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.dropbox.filesCreateFolderV2({
|
|
||||||
path: `/${this.remoteBaseDir}`,
|
|
||||||
});
|
|
||||||
log.info(`remote folder /${this.remoteBaseDir} created`);
|
|
||||||
this.vaultFolderExists = true;
|
|
||||||
} else {
|
|
||||||
// log.info(`remote folder /${this.remoteBaseDir} exists`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.dropbox;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param dropboxConfig
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
export const getDropboxClient = (
|
|
||||||
dropboxConfig: DropboxConfig,
|
|
||||||
remoteBaseDir: string,
|
|
||||||
saveUpdatedConfigFunc: () => Promise<any>
|
|
||||||
) => {
|
|
||||||
return new WrappedDropboxClient(
|
|
||||||
dropboxConfig,
|
|
||||||
remoteBaseDir,
|
|
||||||
saveUpdatedConfigFunc
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getRemoteMeta = async (
|
|
||||||
client: WrappedDropboxClient,
|
|
||||||
fileOrFolderPath: string
|
|
||||||
) => {
|
|
||||||
await client.init();
|
|
||||||
if (fileOrFolderPath === "" || fileOrFolderPath === "/") {
|
|
||||||
// filesGetMetadata doesn't support root folder
|
|
||||||
// we instead try to list files
|
|
||||||
// if no error occurs, we ensemble a fake result.
|
|
||||||
const rsp = await retryReq(() =>
|
|
||||||
client.dropbox.filesListFolder({
|
|
||||||
path: `/${client.remoteBaseDir}`,
|
|
||||||
recursive: false, // don't need to recursive here
|
|
||||||
})
|
|
||||||
);
|
|
||||||
if (rsp.status !== 200) {
|
|
||||||
throw Error(JSON.stringify(rsp));
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
key: fileOrFolderPath,
|
|
||||||
lastModified: undefined,
|
|
||||||
size: 0,
|
|
||||||
remoteType: "dropbox",
|
|
||||||
etag: undefined,
|
|
||||||
} as RemoteItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = getDropboxPath(fileOrFolderPath, client.remoteBaseDir);
|
|
||||||
|
|
||||||
const rsp = await retryReq(() =>
|
|
||||||
client.dropbox.filesGetMetadata({
|
|
||||||
path: key,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
if (rsp.status !== 200) {
|
|
||||||
throw Error(JSON.stringify(rsp));
|
|
||||||
}
|
|
||||||
return fromDropboxItemToRemoteItem(rsp.result, client.remoteBaseDir);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const uploadToRemote = async (
|
|
||||||
client: WrappedDropboxClient,
|
|
||||||
fileOrFolderPath: string,
|
|
||||||
vault: Vault,
|
|
||||||
isRecursively: boolean = false,
|
|
||||||
password: string = "",
|
|
||||||
remoteEncryptedKey: string = "",
|
|
||||||
foldersCreatedBefore: Set<string> | undefined = undefined,
|
|
||||||
uploadRaw: boolean = false,
|
|
||||||
rawContent: string | ArrayBuffer = ""
|
|
||||||
) => {
|
|
||||||
await client.init();
|
|
||||||
|
|
||||||
let uploadFile = fileOrFolderPath;
|
|
||||||
if (password !== "") {
|
|
||||||
uploadFile = remoteEncryptedKey;
|
|
||||||
}
|
|
||||||
uploadFile = getDropboxPath(uploadFile, client.remoteBaseDir);
|
|
||||||
|
|
||||||
if (hasEmojiInText(uploadFile)) {
|
|
||||||
throw new Error(
|
|
||||||
`${uploadFile}: Error: Dropbox does not support emoji in file / folder names.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isFolder = fileOrFolderPath.endsWith("/");
|
|
||||||
|
|
||||||
if (isFolder && isRecursively) {
|
|
||||||
throw Error("upload function doesn't implement recursive function yet!");
|
|
||||||
} else if (isFolder && !isRecursively) {
|
|
||||||
if (uploadRaw) {
|
|
||||||
throw Error(`you specify uploadRaw, but you also provide a folder key!`);
|
|
||||||
}
|
|
||||||
// folder
|
|
||||||
if (password === "") {
|
|
||||||
// if not encrypted, mkdir a remote folder
|
|
||||||
if (foldersCreatedBefore?.has(uploadFile)) {
|
|
||||||
// created, pass
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
await retryReq(
|
|
||||||
() =>
|
|
||||||
client.dropbox.filesCreateFolderV2({
|
|
||||||
path: uploadFile,
|
|
||||||
}),
|
|
||||||
fileOrFolderPath
|
|
||||||
);
|
|
||||||
foldersCreatedBefore?.add(uploadFile);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const err = e as DropboxResponseError<files.CreateFolderError>;
|
|
||||||
if (err.status === undefined) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
if (err.status === 409) {
|
|
||||||
// pass
|
|
||||||
foldersCreatedBefore?.add(uploadFile);
|
|
||||||
} else {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const res = await getRemoteMeta(client, uploadFile);
|
|
||||||
return res;
|
|
||||||
} else {
|
|
||||||
// if encrypted, upload a fake file with the encrypted file name
|
|
||||||
await retryReq(
|
|
||||||
() =>
|
|
||||||
client.dropbox.filesUpload({
|
|
||||||
path: uploadFile,
|
|
||||||
contents: "",
|
|
||||||
}),
|
|
||||||
fileOrFolderPath
|
|
||||||
);
|
|
||||||
return await getRemoteMeta(client, uploadFile);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// file
|
|
||||||
// we ignore isRecursively parameter here
|
|
||||||
let localContent = undefined;
|
|
||||||
if (uploadRaw) {
|
|
||||||
if (typeof rawContent === "string") {
|
|
||||||
localContent = new TextEncoder().encode(rawContent).buffer;
|
|
||||||
} else {
|
|
||||||
localContent = rawContent;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
localContent = await vault.adapter.readBinary(fileOrFolderPath);
|
|
||||||
}
|
|
||||||
let remoteContent = localContent;
|
|
||||||
if (password !== "") {
|
|
||||||
remoteContent = await encryptArrayBuffer(localContent, password);
|
|
||||||
}
|
|
||||||
// in dropbox, we don't need to create folders before uploading! cool!
|
|
||||||
// TODO: filesUploadSession for larger files (>=150 MB)
|
|
||||||
|
|
||||||
await retryReq(
|
|
||||||
() =>
|
|
||||||
client.dropbox.filesUpload({
|
|
||||||
path: uploadFile,
|
|
||||||
contents: remoteContent,
|
|
||||||
mode: {
|
|
||||||
".tag": "overwrite",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
fileOrFolderPath
|
|
||||||
);
|
|
||||||
|
|
||||||
// we want to mark that parent folders are created
|
|
||||||
if (foldersCreatedBefore !== undefined) {
|
|
||||||
const dirs = getFolderLevels(uploadFile).map((x) =>
|
|
||||||
getDropboxPath(x, client.remoteBaseDir)
|
|
||||||
);
|
|
||||||
for (const dir of dirs) {
|
|
||||||
foldersCreatedBefore?.add(dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return await getRemoteMeta(client, uploadFile);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const listAllFromRemote = async (client: WrappedDropboxClient) => {
|
|
||||||
await client.init();
|
|
||||||
let res = await client.dropbox.filesListFolder({
|
|
||||||
path: `/${client.remoteBaseDir}`,
|
|
||||||
recursive: true,
|
|
||||||
include_deleted: false,
|
|
||||||
limit: 1000,
|
|
||||||
});
|
|
||||||
if (res.status !== 200) {
|
|
||||||
throw Error(JSON.stringify(res));
|
|
||||||
}
|
|
||||||
// log.info(res);
|
|
||||||
|
|
||||||
const contents = res.result.entries;
|
|
||||||
const unifiedContents = contents
|
|
||||||
.filter((x) => x[".tag"] !== "deleted")
|
|
||||||
.filter((x) => x.path_display !== `/${client.remoteBaseDir}`)
|
|
||||||
.map((x) => fromDropboxItemToRemoteItem(x, client.remoteBaseDir));
|
|
||||||
|
|
||||||
while (res.result.has_more) {
|
|
||||||
res = await client.dropbox.filesListFolderContinue({
|
|
||||||
cursor: res.result.cursor,
|
|
||||||
});
|
|
||||||
if (res.status !== 200) {
|
|
||||||
throw Error(JSON.stringify(res));
|
|
||||||
}
|
|
||||||
|
|
||||||
const contents2 = res.result.entries;
|
|
||||||
const unifiedContents2 = contents2
|
|
||||||
.filter((x) => x[".tag"] !== "deleted")
|
|
||||||
.filter((x) => x.path_display !== `/${client.remoteBaseDir}`)
|
|
||||||
.map((x) => fromDropboxItemToRemoteItem(x, client.remoteBaseDir));
|
|
||||||
unifiedContents.push(...unifiedContents2);
|
|
||||||
}
|
|
||||||
|
|
||||||
fixLastModifiedTimeInplace(unifiedContents);
|
|
||||||
|
|
||||||
return {
|
|
||||||
Contents: unifiedContents,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadFromRemoteRaw = async (
|
|
||||||
client: WrappedDropboxClient,
|
|
||||||
fileOrFolderPath: string
|
|
||||||
) => {
|
|
||||||
await client.init();
|
|
||||||
const key = getDropboxPath(fileOrFolderPath, client.remoteBaseDir);
|
|
||||||
const rsp = await retryReq(
|
|
||||||
() =>
|
|
||||||
client.dropbox.filesDownload({
|
|
||||||
path: key,
|
|
||||||
}),
|
|
||||||
fileOrFolderPath
|
|
||||||
);
|
|
||||||
if ((rsp.result as any).fileBlob !== undefined) {
|
|
||||||
// we get a Blob
|
|
||||||
const content = (rsp.result as any).fileBlob as Blob;
|
|
||||||
return await content.arrayBuffer();
|
|
||||||
} else if ((rsp.result as any).fileBinary !== undefined) {
|
|
||||||
// we get a Buffer
|
|
||||||
const content = (rsp.result as any).fileBinary as Buffer;
|
|
||||||
return bufferToArrayBuffer(content);
|
|
||||||
} else {
|
|
||||||
throw Error(`unknown rsp from dropbox download: ${rsp}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const downloadFromRemote = async (
|
|
||||||
client: WrappedDropboxClient,
|
|
||||||
fileOrFolderPath: string,
|
|
||||||
vault: Vault,
|
|
||||||
mtime: number,
|
|
||||||
password: string = "",
|
|
||||||
remoteEncryptedKey: string = "",
|
|
||||||
skipSaving: boolean = false
|
|
||||||
) => {
|
|
||||||
await client.init();
|
|
||||||
|
|
||||||
const isFolder = fileOrFolderPath.endsWith("/");
|
|
||||||
|
|
||||||
if (!skipSaving) {
|
|
||||||
await mkdirpInVault(fileOrFolderPath, vault);
|
|
||||||
}
|
|
||||||
|
|
||||||
// the file is always local file
|
|
||||||
// we need to encrypt it
|
|
||||||
|
|
||||||
if (isFolder) {
|
|
||||||
// mkdirp locally is enough
|
|
||||||
// do nothing here
|
|
||||||
return new ArrayBuffer(0);
|
|
||||||
} else {
|
|
||||||
let downloadFile = fileOrFolderPath;
|
|
||||||
if (password !== "") {
|
|
||||||
downloadFile = remoteEncryptedKey;
|
|
||||||
}
|
|
||||||
downloadFile = getDropboxPath(downloadFile, client.remoteBaseDir);
|
|
||||||
const remoteContent = await downloadFromRemoteRaw(client, downloadFile);
|
|
||||||
let localContent = remoteContent;
|
|
||||||
if (password !== "") {
|
|
||||||
localContent = await decryptArrayBuffer(remoteContent, password);
|
|
||||||
}
|
|
||||||
if (!skipSaving) {
|
|
||||||
await vault.adapter.writeBinary(fileOrFolderPath, localContent, {
|
|
||||||
mtime: mtime,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return localContent;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteFromRemote = async (
|
|
||||||
client: WrappedDropboxClient,
|
|
||||||
fileOrFolderPath: string,
|
|
||||||
password: string = "",
|
|
||||||
remoteEncryptedKey: string = ""
|
|
||||||
) => {
|
|
||||||
if (fileOrFolderPath === "/") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let remoteFileName = fileOrFolderPath;
|
|
||||||
if (password !== "") {
|
|
||||||
remoteFileName = remoteEncryptedKey;
|
|
||||||
}
|
|
||||||
remoteFileName = getDropboxPath(remoteFileName, client.remoteBaseDir);
|
|
||||||
|
|
||||||
await client.init();
|
|
||||||
try {
|
|
||||||
await retryReq(
|
|
||||||
() =>
|
|
||||||
client.dropbox.filesDeleteV2({
|
|
||||||
path: remoteFileName,
|
|
||||||
}),
|
|
||||||
fileOrFolderPath
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("some error while deleting");
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const checkConnectivity = async (
|
|
||||||
client: WrappedDropboxClient,
|
|
||||||
callbackFunc?: any
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
const results = await getRemoteMeta(client, "/");
|
|
||||||
if (results === undefined) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
log.debug(err);
|
|
||||||
if (callbackFunc !== undefined) {
|
|
||||||
callbackFunc(err);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getUserDisplayName = async (client: WrappedDropboxClient) => {
|
|
||||||
await client.init();
|
|
||||||
const acct = await client.dropbox.usersGetCurrentAccount();
|
|
||||||
return acct.result.name.display_name;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const revokeAuth = async (client: WrappedDropboxClient) => {
|
|
||||||
await client.init();
|
|
||||||
await client.dropbox.authTokenRevoke();
|
|
||||||
};
|
|
||||||
@@ -1,652 +0,0 @@
|
|||||||
import type { _Object } from "@aws-sdk/client-s3";
|
|
||||||
import {
|
|
||||||
DeleteObjectCommand,
|
|
||||||
GetObjectCommand,
|
|
||||||
HeadBucketCommand,
|
|
||||||
HeadObjectCommand,
|
|
||||||
HeadObjectCommandOutput,
|
|
||||||
ListObjectsV2Command,
|
|
||||||
ListObjectsV2CommandInput,
|
|
||||||
PutObjectCommand,
|
|
||||||
S3Client,
|
|
||||||
} from "@aws-sdk/client-s3";
|
|
||||||
import { Upload } from "@aws-sdk/lib-storage";
|
|
||||||
import { HttpHandler, HttpRequest, HttpResponse } from "@smithy/protocol-http";
|
|
||||||
import {
|
|
||||||
FetchHttpHandler,
|
|
||||||
FetchHttpHandlerOptions,
|
|
||||||
} from "@smithy/fetch-http-handler";
|
|
||||||
// @ts-ignore
|
|
||||||
import { requestTimeout } from "@smithy/fetch-http-handler/dist-es/request-timeout";
|
|
||||||
import { buildQueryString } from "@smithy/querystring-builder";
|
|
||||||
import { HeaderBag, HttpHandlerOptions, Provider } from "@aws-sdk/types";
|
|
||||||
import { Buffer } from "buffer";
|
|
||||||
import * as mime from "mime-types";
|
|
||||||
import { Vault, requestUrl, RequestUrlParam } from "obsidian";
|
|
||||||
import { Readable } from "stream";
|
|
||||||
import * as path from "path";
|
|
||||||
import AggregateError from "aggregate-error";
|
|
||||||
import {
|
|
||||||
DEFAULT_CONTENT_TYPE,
|
|
||||||
RemoteItem,
|
|
||||||
S3Config,
|
|
||||||
VALID_REQURL,
|
|
||||||
} from "./baseTypes";
|
|
||||||
import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
|
|
||||||
import {
|
|
||||||
arrayBufferToBuffer,
|
|
||||||
bufferToArrayBuffer,
|
|
||||||
mkdirpInVault,
|
|
||||||
} from "./misc";
|
|
||||||
|
|
||||||
export { S3Client } from "@aws-sdk/client-s3";
|
|
||||||
|
|
||||||
import { log } from "./moreOnLog";
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////////
|
|
||||||
// special handler using Obsidian requestUrl
|
|
||||||
////////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is close to origin implementation of FetchHttpHandler
|
|
||||||
* https://github.com/aws/aws-sdk-js-v3/blob/main/packages/fetch-http-handler/src/fetch-http-handler.ts
|
|
||||||
* that is released under Apache 2 License.
|
|
||||||
* But this uses Obsidian requestUrl instead.
|
|
||||||
*/
|
|
||||||
class ObsHttpHandler extends FetchHttpHandler {
|
|
||||||
requestTimeoutInMs: number;
|
|
||||||
constructor(options?: FetchHttpHandlerOptions) {
|
|
||||||
super(options);
|
|
||||||
this.requestTimeoutInMs =
|
|
||||||
options === undefined ? undefined : options.requestTimeout;
|
|
||||||
}
|
|
||||||
async handle(
|
|
||||||
request: HttpRequest,
|
|
||||||
{ abortSignal }: HttpHandlerOptions = {}
|
|
||||||
): Promise<{ response: HttpResponse }> {
|
|
||||||
if (abortSignal?.aborted) {
|
|
||||||
const abortError = new Error("Request aborted");
|
|
||||||
abortError.name = "AbortError";
|
|
||||||
return Promise.reject(abortError);
|
|
||||||
}
|
|
||||||
|
|
||||||
let path = request.path;
|
|
||||||
if (request.query) {
|
|
||||||
const queryString = buildQueryString(request.query);
|
|
||||||
if (queryString) {
|
|
||||||
path += `?${queryString}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { port, method } = request;
|
|
||||||
const url = `${request.protocol}//${request.hostname}${
|
|
||||||
port ? `:${port}` : ""
|
|
||||||
}${path}`;
|
|
||||||
const body =
|
|
||||||
method === "GET" || method === "HEAD" ? undefined : request.body;
|
|
||||||
|
|
||||||
const transformedHeaders: Record<string, string> = {};
|
|
||||||
for (const key of Object.keys(request.headers)) {
|
|
||||||
const keyLower = key.toLowerCase();
|
|
||||||
if (keyLower === "host" || keyLower === "content-length") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
transformedHeaders[keyLower] = request.headers[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
let contentType: string = undefined;
|
|
||||||
if (transformedHeaders["content-type"] !== undefined) {
|
|
||||||
contentType = transformedHeaders["content-type"];
|
|
||||||
}
|
|
||||||
|
|
||||||
let transformedBody: any = body;
|
|
||||||
if (ArrayBuffer.isView(body)) {
|
|
||||||
transformedBody = bufferToArrayBuffer(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
const param: RequestUrlParam = {
|
|
||||||
body: transformedBody,
|
|
||||||
headers: transformedHeaders,
|
|
||||||
method: method,
|
|
||||||
url: url,
|
|
||||||
contentType: contentType,
|
|
||||||
};
|
|
||||||
|
|
||||||
const raceOfPromises = [
|
|
||||||
requestUrl(param).then((rsp) => {
|
|
||||||
const headers = rsp.headers;
|
|
||||||
const headersLower: Record<string, string> = {};
|
|
||||||
for (const key of Object.keys(headers)) {
|
|
||||||
headersLower[key.toLowerCase()] = headers[key];
|
|
||||||
}
|
|
||||||
const stream = new ReadableStream<Uint8Array>({
|
|
||||||
start(controller) {
|
|
||||||
controller.enqueue(new Uint8Array(rsp.arrayBuffer));
|
|
||||||
controller.close();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
response: new HttpResponse({
|
|
||||||
headers: headersLower,
|
|
||||||
statusCode: rsp.status,
|
|
||||||
body: stream,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
requestTimeout(this.requestTimeoutInMs),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (abortSignal) {
|
|
||||||
raceOfPromises.push(
|
|
||||||
new Promise<never>((resolve, reject) => {
|
|
||||||
abortSignal.onabort = () => {
|
|
||||||
const abortError = new Error("Request aborted");
|
|
||||||
abortError.name = "AbortError";
|
|
||||||
reject(abortError);
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Promise.race(raceOfPromises);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////////
|
|
||||||
// other stuffs
|
|
||||||
////////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
export const DEFAULT_S3_CONFIG = {
|
|
||||||
s3Endpoint: "",
|
|
||||||
s3Region: "",
|
|
||||||
s3AccessKeyID: "",
|
|
||||||
s3SecretAccessKey: "",
|
|
||||||
s3BucketName: "",
|
|
||||||
bypassCorsLocally: true,
|
|
||||||
partsConcurrency: 20,
|
|
||||||
forcePathStyle: false,
|
|
||||||
remotePrefix: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
export type S3ObjectType = _Object;
|
|
||||||
|
|
||||||
export const simpleTransRemotePrefix = (x: string) => {
|
|
||||||
if (x === undefined) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
let y = path.posix.normalize(x.trim());
|
|
||||||
if (y === undefined || y === "" || y === "/" || y === ".") {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
if (y.startsWith("/")) {
|
|
||||||
y = y.slice(1);
|
|
||||||
}
|
|
||||||
if (!y.endsWith("/")) {
|
|
||||||
y = `${y}/`;
|
|
||||||
}
|
|
||||||
return y;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRemoteWithPrefixPath = (
|
|
||||||
fileOrFolderPath: string,
|
|
||||||
remotePrefix: string
|
|
||||||
) => {
|
|
||||||
let key = fileOrFolderPath;
|
|
||||||
if (fileOrFolderPath === "/" || fileOrFolderPath === "") {
|
|
||||||
// special
|
|
||||||
key = remotePrefix;
|
|
||||||
}
|
|
||||||
if (!fileOrFolderPath.startsWith("/")) {
|
|
||||||
key = `${remotePrefix}${fileOrFolderPath}`;
|
|
||||||
}
|
|
||||||
return key;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLocalNoPrefixPath = (
|
|
||||||
fileOrFolderPathWithRemotePrefix: string,
|
|
||||||
remotePrefix: string
|
|
||||||
) => {
|
|
||||||
if (
|
|
||||||
!(
|
|
||||||
fileOrFolderPathWithRemotePrefix === `${remotePrefix}` ||
|
|
||||||
fileOrFolderPathWithRemotePrefix.startsWith(`${remotePrefix}`)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw Error(
|
|
||||||
`"${fileOrFolderPathWithRemotePrefix}" doesn't starts with "${remotePrefix}"`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return fileOrFolderPathWithRemotePrefix.slice(`${remotePrefix}`.length);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fromS3ObjectToRemoteItem = (x: S3ObjectType, remotePrefix: string) => {
|
|
||||||
return {
|
|
||||||
key: getLocalNoPrefixPath(x.Key, remotePrefix),
|
|
||||||
lastModified: x.LastModified.valueOf(),
|
|
||||||
size: x.Size,
|
|
||||||
remoteType: "s3",
|
|
||||||
etag: x.ETag,
|
|
||||||
} as RemoteItem;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fromS3HeadObjectToRemoteItem = (
|
|
||||||
fileOrFolderPathWithRemotePrefix: string,
|
|
||||||
x: HeadObjectCommandOutput,
|
|
||||||
remotePrefix: string
|
|
||||||
) => {
|
|
||||||
return {
|
|
||||||
key: getLocalNoPrefixPath(fileOrFolderPathWithRemotePrefix, remotePrefix),
|
|
||||||
lastModified: x.LastModified.valueOf(),
|
|
||||||
size: x.ContentLength,
|
|
||||||
remoteType: "s3",
|
|
||||||
etag: x.ETag,
|
|
||||||
} as RemoteItem;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getS3Client = (s3Config: S3Config) => {
|
|
||||||
let endpoint = s3Config.s3Endpoint;
|
|
||||||
if (!(endpoint.startsWith("http://") || endpoint.startsWith("https://"))) {
|
|
||||||
endpoint = `https://${endpoint}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let s3Client: S3Client;
|
|
||||||
|
|
||||||
if (VALID_REQURL && s3Config.bypassCorsLocally) {
|
|
||||||
s3Client = new S3Client({
|
|
||||||
region: s3Config.s3Region,
|
|
||||||
endpoint: endpoint,
|
|
||||||
forcePathStyle: s3Config.forcePathStyle,
|
|
||||||
credentials: {
|
|
||||||
accessKeyId: s3Config.s3AccessKeyID,
|
|
||||||
secretAccessKey: s3Config.s3SecretAccessKey,
|
|
||||||
},
|
|
||||||
requestHandler: new ObsHttpHandler(),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
s3Client = new S3Client({
|
|
||||||
region: s3Config.s3Region,
|
|
||||||
endpoint: endpoint,
|
|
||||||
forcePathStyle: s3Config.forcePathStyle,
|
|
||||||
credentials: {
|
|
||||||
accessKeyId: s3Config.s3AccessKeyID,
|
|
||||||
secretAccessKey: s3Config.s3SecretAccessKey,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
s3Client.middlewareStack.add(
|
|
||||||
(next, context) => (args) => {
|
|
||||||
(args.request as any).headers["cache-control"] = "no-cache";
|
|
||||||
return next(args);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: "build",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return s3Client;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getRemoteMeta = async (
|
|
||||||
s3Client: S3Client,
|
|
||||||
s3Config: S3Config,
|
|
||||||
fileOrFolderPathWithRemotePrefix: string
|
|
||||||
) => {
|
|
||||||
if (
|
|
||||||
s3Config.remotePrefix !== "" &&
|
|
||||||
!fileOrFolderPathWithRemotePrefix.startsWith(s3Config.remotePrefix)
|
|
||||||
) {
|
|
||||||
throw Error(`s3 getRemoteMeta should only accept prefix-ed path`);
|
|
||||||
}
|
|
||||||
const res = await s3Client.send(
|
|
||||||
new HeadObjectCommand({
|
|
||||||
Bucket: s3Config.s3BucketName,
|
|
||||||
Key: fileOrFolderPathWithRemotePrefix,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return fromS3HeadObjectToRemoteItem(
|
|
||||||
fileOrFolderPathWithRemotePrefix,
|
|
||||||
res,
|
|
||||||
s3Config.remotePrefix
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const uploadToRemote = async (
|
|
||||||
s3Client: S3Client,
|
|
||||||
s3Config: S3Config,
|
|
||||||
fileOrFolderPath: string,
|
|
||||||
vault: Vault,
|
|
||||||
isRecursively: boolean = false,
|
|
||||||
password: string = "",
|
|
||||||
remoteEncryptedKey: string = "",
|
|
||||||
uploadRaw: boolean = false,
|
|
||||||
rawContent: string | ArrayBuffer = ""
|
|
||||||
) => {
|
|
||||||
let uploadFile = fileOrFolderPath;
|
|
||||||
if (password !== "") {
|
|
||||||
uploadFile = remoteEncryptedKey;
|
|
||||||
}
|
|
||||||
uploadFile = getRemoteWithPrefixPath(uploadFile, s3Config.remotePrefix);
|
|
||||||
const isFolder = fileOrFolderPath.endsWith("/");
|
|
||||||
|
|
||||||
if (isFolder && isRecursively) {
|
|
||||||
throw Error("upload function doesn't implement recursive function yet!");
|
|
||||||
} else if (isFolder && !isRecursively) {
|
|
||||||
if (uploadRaw) {
|
|
||||||
throw Error(`you specify uploadRaw, but you also provide a folder key!`);
|
|
||||||
}
|
|
||||||
// folder
|
|
||||||
const contentType = DEFAULT_CONTENT_TYPE;
|
|
||||||
await s3Client.send(
|
|
||||||
new PutObjectCommand({
|
|
||||||
Bucket: s3Config.s3BucketName,
|
|
||||||
Key: uploadFile,
|
|
||||||
Body: "",
|
|
||||||
ContentType: contentType,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return await getRemoteMeta(s3Client, s3Config, uploadFile);
|
|
||||||
} else {
|
|
||||||
// file
|
|
||||||
// we ignore isRecursively parameter here
|
|
||||||
let contentType = DEFAULT_CONTENT_TYPE;
|
|
||||||
if (password === "") {
|
|
||||||
contentType =
|
|
||||||
mime.contentType(
|
|
||||||
mime.lookup(fileOrFolderPath) || DEFAULT_CONTENT_TYPE
|
|
||||||
) || DEFAULT_CONTENT_TYPE;
|
|
||||||
}
|
|
||||||
let localContent = undefined;
|
|
||||||
if (uploadRaw) {
|
|
||||||
if (typeof rawContent === "string") {
|
|
||||||
localContent = new TextEncoder().encode(rawContent).buffer;
|
|
||||||
} else {
|
|
||||||
localContent = rawContent;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
localContent = await vault.adapter.readBinary(fileOrFolderPath);
|
|
||||||
}
|
|
||||||
let remoteContent = localContent;
|
|
||||||
if (password !== "") {
|
|
||||||
remoteContent = await encryptArrayBuffer(localContent, password);
|
|
||||||
}
|
|
||||||
|
|
||||||
const bytesIn5MB = 5242880;
|
|
||||||
const body = new Uint8Array(remoteContent);
|
|
||||||
const upload = new Upload({
|
|
||||||
client: s3Client,
|
|
||||||
queueSize: s3Config.partsConcurrency, // concurrency
|
|
||||||
partSize: bytesIn5MB, // minimal 5MB by default
|
|
||||||
leavePartsOnError: false,
|
|
||||||
params: {
|
|
||||||
Bucket: s3Config.s3BucketName,
|
|
||||||
Key: uploadFile,
|
|
||||||
Body: body,
|
|
||||||
ContentType: contentType,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
upload.on("httpUploadProgress", (progress) => {
|
|
||||||
// log.info(progress);
|
|
||||||
});
|
|
||||||
await upload.done();
|
|
||||||
|
|
||||||
return await getRemoteMeta(s3Client, s3Config, uploadFile);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const listFromRemoteRaw = async (
|
|
||||||
s3Client: S3Client,
|
|
||||||
s3Config: S3Config,
|
|
||||||
prefixOfRawKeys?: string
|
|
||||||
) => {
|
|
||||||
const confCmd = {
|
|
||||||
Bucket: s3Config.s3BucketName,
|
|
||||||
} as ListObjectsV2CommandInput;
|
|
||||||
if (prefixOfRawKeys !== undefined && prefixOfRawKeys !== "") {
|
|
||||||
confCmd.Prefix = prefixOfRawKeys;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contents = [] as _Object[];
|
|
||||||
|
|
||||||
let isTruncated = true;
|
|
||||||
do {
|
|
||||||
const rsp = await s3Client.send(new ListObjectsV2Command(confCmd));
|
|
||||||
|
|
||||||
if (rsp.$metadata.httpStatusCode !== 200) {
|
|
||||||
throw Error("some thing bad while listing remote!");
|
|
||||||
}
|
|
||||||
if (rsp.Contents === undefined) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
contents.push(...rsp.Contents);
|
|
||||||
|
|
||||||
isTruncated = rsp.IsTruncated;
|
|
||||||
confCmd.ContinuationToken = rsp.NextContinuationToken;
|
|
||||||
if (
|
|
||||||
isTruncated &&
|
|
||||||
(confCmd.ContinuationToken === undefined ||
|
|
||||||
confCmd.ContinuationToken === "")
|
|
||||||
) {
|
|
||||||
throw Error("isTruncated is true but no continuationToken provided");
|
|
||||||
}
|
|
||||||
} while (isTruncated);
|
|
||||||
|
|
||||||
// ensemble fake rsp
|
|
||||||
// in the end, we need to transform the response list
|
|
||||||
// back to the local contents-alike list
|
|
||||||
return {
|
|
||||||
Contents: contents.map((x) =>
|
|
||||||
fromS3ObjectToRemoteItem(x, s3Config.remotePrefix)
|
|
||||||
),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const listAllFromRemote = async (
|
|
||||||
s3Client: S3Client,
|
|
||||||
s3Config: S3Config
|
|
||||||
) => {
|
|
||||||
return await listFromRemoteRaw(s3Client, s3Config, s3Config.remotePrefix);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Body of resp of aws GetObject has mix types
|
|
||||||
* and we want to get ArrayBuffer here.
|
|
||||||
* See https://github.com/aws/aws-sdk-js-v3/issues/1877
|
|
||||||
* @param b The Body of GetObject
|
|
||||||
* @returns Promise<ArrayBuffer>
|
|
||||||
*/
|
|
||||||
const getObjectBodyToArrayBuffer = async (
|
|
||||||
b: Readable | ReadableStream | Blob
|
|
||||||
) => {
|
|
||||||
if (b instanceof Readable) {
|
|
||||||
return (await new Promise((resolve, reject) => {
|
|
||||||
const chunks: Uint8Array[] = [];
|
|
||||||
b.on("data", (chunk) => chunks.push(chunk));
|
|
||||||
b.on("error", reject);
|
|
||||||
b.on("end", () => resolve(bufferToArrayBuffer(Buffer.concat(chunks))));
|
|
||||||
})) as ArrayBuffer;
|
|
||||||
} else if (b instanceof ReadableStream) {
|
|
||||||
return await new Response(b, {}).arrayBuffer();
|
|
||||||
} else if (b instanceof Blob) {
|
|
||||||
return await b.arrayBuffer();
|
|
||||||
} else {
|
|
||||||
throw TypeError(`The type of ${b} is not one of the supported types`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadFromRemoteRaw = async (
|
|
||||||
s3Client: S3Client,
|
|
||||||
s3Config: S3Config,
|
|
||||||
fileOrFolderPathWithRemotePrefix: string
|
|
||||||
) => {
|
|
||||||
if (
|
|
||||||
s3Config.remotePrefix !== "" &&
|
|
||||||
!fileOrFolderPathWithRemotePrefix.startsWith(s3Config.remotePrefix)
|
|
||||||
) {
|
|
||||||
throw Error(`downloadFromRemoteRaw should only accept prefix-ed path`);
|
|
||||||
}
|
|
||||||
const data = await s3Client.send(
|
|
||||||
new GetObjectCommand({
|
|
||||||
Bucket: s3Config.s3BucketName,
|
|
||||||
Key: fileOrFolderPathWithRemotePrefix,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const bodyContents = await getObjectBodyToArrayBuffer(data.Body);
|
|
||||||
return bodyContents;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const downloadFromRemote = async (
|
|
||||||
s3Client: S3Client,
|
|
||||||
s3Config: S3Config,
|
|
||||||
fileOrFolderPath: string,
|
|
||||||
vault: Vault,
|
|
||||||
mtime: number,
|
|
||||||
password: string = "",
|
|
||||||
remoteEncryptedKey: string = "",
|
|
||||||
skipSaving: boolean = false
|
|
||||||
) => {
|
|
||||||
const isFolder = fileOrFolderPath.endsWith("/");
|
|
||||||
|
|
||||||
if (!skipSaving) {
|
|
||||||
await mkdirpInVault(fileOrFolderPath, vault);
|
|
||||||
}
|
|
||||||
|
|
||||||
// the file is always local file
|
|
||||||
// we need to encrypt it
|
|
||||||
|
|
||||||
if (isFolder) {
|
|
||||||
// mkdirp locally is enough
|
|
||||||
// do nothing here
|
|
||||||
return new ArrayBuffer(0);
|
|
||||||
} else {
|
|
||||||
let downloadFile = fileOrFolderPath;
|
|
||||||
if (password !== "") {
|
|
||||||
downloadFile = remoteEncryptedKey;
|
|
||||||
}
|
|
||||||
downloadFile = getRemoteWithPrefixPath(downloadFile, s3Config.remotePrefix);
|
|
||||||
const remoteContent = await downloadFromRemoteRaw(
|
|
||||||
s3Client,
|
|
||||||
s3Config,
|
|
||||||
downloadFile
|
|
||||||
);
|
|
||||||
let localContent = remoteContent;
|
|
||||||
if (password !== "") {
|
|
||||||
localContent = await decryptArrayBuffer(remoteContent, password);
|
|
||||||
}
|
|
||||||
if (!skipSaving) {
|
|
||||||
await vault.adapter.writeBinary(fileOrFolderPath, localContent, {
|
|
||||||
mtime: mtime,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return localContent;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function deals with file normally and "folder" recursively.
|
|
||||||
* @param s3Client
|
|
||||||
* @param s3Config
|
|
||||||
* @param fileOrFolderPath
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
export const deleteFromRemote = async (
|
|
||||||
s3Client: S3Client,
|
|
||||||
s3Config: S3Config,
|
|
||||||
fileOrFolderPath: string,
|
|
||||||
password: string = "",
|
|
||||||
remoteEncryptedKey: string = ""
|
|
||||||
) => {
|
|
||||||
if (fileOrFolderPath === "/") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let remoteFileName = fileOrFolderPath;
|
|
||||||
if (password !== "") {
|
|
||||||
remoteFileName = remoteEncryptedKey;
|
|
||||||
}
|
|
||||||
remoteFileName = getRemoteWithPrefixPath(
|
|
||||||
remoteFileName,
|
|
||||||
s3Config.remotePrefix
|
|
||||||
);
|
|
||||||
await s3Client.send(
|
|
||||||
new DeleteObjectCommand({
|
|
||||||
Bucket: s3Config.s3BucketName,
|
|
||||||
Key: remoteFileName,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (fileOrFolderPath.endsWith("/") && password === "") {
|
|
||||||
const x = await listFromRemoteRaw(s3Client, s3Config, remoteFileName);
|
|
||||||
x.Contents.forEach(async (element) => {
|
|
||||||
await s3Client.send(
|
|
||||||
new DeleteObjectCommand({
|
|
||||||
Bucket: s3Config.s3BucketName,
|
|
||||||
Key: element.key,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} else if (fileOrFolderPath.endsWith("/") && password !== "") {
|
|
||||||
// TODO
|
|
||||||
} else {
|
|
||||||
// pass
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check the config of S3 by heading bucket
|
|
||||||
* https://stackoverflow.com/questions/50842835
|
|
||||||
*
|
|
||||||
* Updated on 20240102:
|
|
||||||
* Users are not always have permission of heading bucket,
|
|
||||||
* so we need to use listing objects instead...
|
|
||||||
*
|
|
||||||
* @param s3Client
|
|
||||||
* @param s3Config
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
export const checkConnectivity = async (
|
|
||||||
s3Client: S3Client,
|
|
||||||
s3Config: S3Config,
|
|
||||||
callbackFunc?: any
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
// const results = await s3Client.send(
|
|
||||||
// new HeadBucketCommand({ Bucket: s3Config.s3BucketName })
|
|
||||||
// );
|
|
||||||
// very simplified version of listing objects
|
|
||||||
const confCmd = {
|
|
||||||
Bucket: s3Config.s3BucketName,
|
|
||||||
} as ListObjectsV2CommandInput;
|
|
||||||
const results = await s3Client.send(new ListObjectsV2Command(confCmd));
|
|
||||||
|
|
||||||
if (
|
|
||||||
results === undefined ||
|
|
||||||
results.$metadata === undefined ||
|
|
||||||
results.$metadata.httpStatusCode === undefined
|
|
||||||
) {
|
|
||||||
const err = "results or $metadata or httStatusCode is undefined";
|
|
||||||
log.debug(err);
|
|
||||||
if (callbackFunc !== undefined) {
|
|
||||||
callbackFunc(err);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return results.$metadata.httpStatusCode === 200;
|
|
||||||
} catch (err) {
|
|
||||||
log.debug(err);
|
|
||||||
if (callbackFunc !== undefined) {
|
|
||||||
if (s3Config.s3Endpoint.contains(s3Config.s3BucketName)) {
|
|
||||||
const err2 = new AggregateError([
|
|
||||||
err,
|
|
||||||
new Error(
|
|
||||||
"Maybe you've included the bucket name inside the endpoint setting. Please remove the bucket name and try again."
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
callbackFunc(err2);
|
|
||||||
} else {
|
|
||||||
callbackFunc(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,593 +0,0 @@
|
|||||||
import { Buffer } from "buffer";
|
|
||||||
import { Vault, requestUrl } from "obsidian";
|
|
||||||
|
|
||||||
import { Queue } from "@fyears/tsqueue";
|
|
||||||
import chunk from "lodash/chunk";
|
|
||||||
import flatten from "lodash/flatten";
|
|
||||||
import { getReasonPhrase } from "http-status-codes";
|
|
||||||
import { RemoteItem, VALID_REQURL, WebdavConfig } from "./baseTypes";
|
|
||||||
import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
|
|
||||||
import { bufferToArrayBuffer, getPathFolder, mkdirpInVault } from "./misc";
|
|
||||||
|
|
||||||
import { log } from "./moreOnLog";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
FileStat,
|
|
||||||
WebDAVClient,
|
|
||||||
RequestOptionsWithState,
|
|
||||||
// Response,
|
|
||||||
// ResponseDataDetailed,
|
|
||||||
} from "webdav";
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
import { getPatcher } from "webdav/dist/web/index.js";
|
|
||||||
if (VALID_REQURL) {
|
|
||||||
getPatcher().patch(
|
|
||||||
"request",
|
|
||||||
async (options: RequestOptionsWithState): Promise<Response> => {
|
|
||||||
const transformedHeaders = { ...options.headers };
|
|
||||||
delete transformedHeaders["host"];
|
|
||||||
delete transformedHeaders["Host"];
|
|
||||||
delete transformedHeaders["content-length"];
|
|
||||||
delete transformedHeaders["Content-Length"];
|
|
||||||
|
|
||||||
const r = await requestUrl({
|
|
||||||
url: options.url,
|
|
||||||
method: options.method,
|
|
||||||
body: options.data as string | ArrayBuffer,
|
|
||||||
headers: transformedHeaders,
|
|
||||||
});
|
|
||||||
|
|
||||||
let contentType: string | undefined =
|
|
||||||
r.headers["Content-Type"] ||
|
|
||||||
r.headers["content-type"] ||
|
|
||||||
options.headers["Content-Type"] ||
|
|
||||||
options.headers["content-type"] ||
|
|
||||||
options.headers["Accept"] ||
|
|
||||||
options.headers["accept"];
|
|
||||||
if (contentType !== undefined) {
|
|
||||||
contentType = contentType.toLowerCase();
|
|
||||||
}
|
|
||||||
const rspHeaders = { ...r.headers };
|
|
||||||
for (let key in rspHeaders) {
|
|
||||||
if (rspHeaders.hasOwnProperty(key)) {
|
|
||||||
if (key === "content-disposition" || key === "Content-Disposition") {
|
|
||||||
rspHeaders[key] = encodeURIComponent(rspHeaders[key]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// console.log(`requesting url=${options.url}`);
|
|
||||||
// console.log(`contentType=${contentType}`);
|
|
||||||
// console.log(`rspHeaders=${JSON.stringify(rspHeaders)}`)
|
|
||||||
|
|
||||||
// let r2: Response = undefined;
|
|
||||||
// if (contentType.includes("xml")) {
|
|
||||||
// r2 = new Response(r.text, {
|
|
||||||
// status: r.status,
|
|
||||||
// statusText: getReasonPhrase(r.status),
|
|
||||||
// headers: rspHeaders,
|
|
||||||
// });
|
|
||||||
// } else if (
|
|
||||||
// contentType.includes("json") ||
|
|
||||||
// contentType.includes("javascript")
|
|
||||||
// ) {
|
|
||||||
// console.log('inside json branch');
|
|
||||||
// // const j = r.json;
|
|
||||||
// // console.log(j);
|
|
||||||
// r2 = new Response(
|
|
||||||
// r.text, // yea, here is the text because Response constructor expects a text
|
|
||||||
// {
|
|
||||||
// status: r.status,
|
|
||||||
// statusText: getReasonPhrase(r.status),
|
|
||||||
// headers: rspHeaders,
|
|
||||||
// });
|
|
||||||
// } else if (contentType.includes("text")) {
|
|
||||||
// // avoid text/json,
|
|
||||||
// // so we split this out from the above xml or json branch
|
|
||||||
// r2 = new Response(r.text, {
|
|
||||||
// status: r.status,
|
|
||||||
// statusText: getReasonPhrase(r.status),
|
|
||||||
// headers: rspHeaders,
|
|
||||||
// });
|
|
||||||
// } else if (
|
|
||||||
// contentType.includes("octet-stream") ||
|
|
||||||
// contentType.includes("binary") ||
|
|
||||||
// contentType.includes("buffer")
|
|
||||||
// ) {
|
|
||||||
// // application/octet-stream
|
|
||||||
// r2 = new Response(r.arrayBuffer, {
|
|
||||||
// status: r.status,
|
|
||||||
// statusText: getReasonPhrase(r.status),
|
|
||||||
// headers: rspHeaders,
|
|
||||||
// });
|
|
||||||
// } else {
|
|
||||||
// throw Error(
|
|
||||||
// `do not know how to deal with requested content type = ${contentType}`
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
let r2: Response = undefined;
|
|
||||||
if ([101, 103, 204, 205, 304].includes(r.status)) {
|
|
||||||
// A null body status is a status that is 101, 103, 204, 205, or 304.
|
|
||||||
// https://fetch.spec.whatwg.org/#statuses
|
|
||||||
// fix this: Failed to construct 'Response': Response with null body status cannot have body
|
|
||||||
r2 = new Response(null, {
|
|
||||||
status: r.status,
|
|
||||||
statusText: getReasonPhrase(r.status),
|
|
||||||
headers: rspHeaders,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
r2 = new Response(r.arrayBuffer, {
|
|
||||||
status: r.status,
|
|
||||||
statusText: getReasonPhrase(r.status),
|
|
||||||
headers: rspHeaders,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return r2;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
import { AuthType, BufferLike, createClient } from "webdav/dist/web/index.js";
|
|
||||||
export type { WebDAVClient } from "webdav";
|
|
||||||
|
|
||||||
export const DEFAULT_WEBDAV_CONFIG = {
|
|
||||||
address: "",
|
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
authType: "basic",
|
|
||||||
manualRecursive: false,
|
|
||||||
depth: "auto_unknown",
|
|
||||||
remoteBaseDir: "",
|
|
||||||
} as WebdavConfig;
|
|
||||||
|
|
||||||
const getWebdavPath = (fileOrFolderPath: string, remoteBaseDir: string) => {
|
|
||||||
let key = fileOrFolderPath;
|
|
||||||
if (fileOrFolderPath === "/" || fileOrFolderPath === "") {
|
|
||||||
// special
|
|
||||||
key = `/${remoteBaseDir}/`;
|
|
||||||
}
|
|
||||||
if (!fileOrFolderPath.startsWith("/")) {
|
|
||||||
key = `/${remoteBaseDir}/${fileOrFolderPath}`;
|
|
||||||
}
|
|
||||||
return key;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getNormPath = (fileOrFolderPath: string, remoteBaseDir: string) => {
|
|
||||||
if (
|
|
||||||
!(
|
|
||||||
fileOrFolderPath === `/${remoteBaseDir}` ||
|
|
||||||
fileOrFolderPath.startsWith(`/${remoteBaseDir}/`)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw Error(
|
|
||||||
`"${fileOrFolderPath}" doesn't starts with "/${remoteBaseDir}/"`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// if (fileOrFolderPath.startsWith("/")) {
|
|
||||||
// return fileOrFolderPath.slice(1);
|
|
||||||
// }
|
|
||||||
return fileOrFolderPath.slice(`/${remoteBaseDir}/`.length);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fromWebdavItemToRemoteItem = (x: FileStat, remoteBaseDir: string) => {
|
|
||||||
let key = getNormPath(x.filename, remoteBaseDir);
|
|
||||||
if (x.type === "directory" && !key.endsWith("/")) {
|
|
||||||
key = `${key}/`;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
key: key,
|
|
||||||
lastModified: Date.parse(x.lastmod).valueOf(),
|
|
||||||
size: x.size,
|
|
||||||
remoteType: "webdav",
|
|
||||||
etag: x.etag || undefined,
|
|
||||||
} as RemoteItem;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class WrappedWebdavClient {
|
|
||||||
webdavConfig: WebdavConfig;
|
|
||||||
remoteBaseDir: string;
|
|
||||||
client: WebDAVClient;
|
|
||||||
vaultFolderExists: boolean;
|
|
||||||
saveUpdatedConfigFunc: () => Promise<any>;
|
|
||||||
constructor(
|
|
||||||
webdavConfig: WebdavConfig,
|
|
||||||
remoteBaseDir: string,
|
|
||||||
saveUpdatedConfigFunc: () => Promise<any>
|
|
||||||
) {
|
|
||||||
this.webdavConfig = webdavConfig;
|
|
||||||
this.remoteBaseDir = remoteBaseDir;
|
|
||||||
this.vaultFolderExists = false;
|
|
||||||
this.saveUpdatedConfigFunc = saveUpdatedConfigFunc;
|
|
||||||
}
|
|
||||||
|
|
||||||
init = async () => {
|
|
||||||
// init client if not inited
|
|
||||||
const headers = {
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
};
|
|
||||||
if (this.client === undefined) {
|
|
||||||
if (
|
|
||||||
this.webdavConfig.username !== "" &&
|
|
||||||
this.webdavConfig.password !== ""
|
|
||||||
) {
|
|
||||||
this.client = createClient(this.webdavConfig.address, {
|
|
||||||
username: this.webdavConfig.username,
|
|
||||||
password: this.webdavConfig.password,
|
|
||||||
headers: headers,
|
|
||||||
authType:
|
|
||||||
this.webdavConfig.authType === "digest"
|
|
||||||
? AuthType.Digest
|
|
||||||
: AuthType.Password,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
log.info("no password");
|
|
||||||
this.client = createClient(this.webdavConfig.address, {
|
|
||||||
headers: headers,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// check vault folder
|
|
||||||
if (this.vaultFolderExists) {
|
|
||||||
// pass
|
|
||||||
} else {
|
|
||||||
const res = await this.client.exists(`/${this.remoteBaseDir}/`);
|
|
||||||
if (res) {
|
|
||||||
// log.info("remote vault folder exits!");
|
|
||||||
this.vaultFolderExists = true;
|
|
||||||
} else {
|
|
||||||
log.info("remote vault folder not exists, creating");
|
|
||||||
await this.client.createDirectory(`/${this.remoteBaseDir}/`);
|
|
||||||
log.info("remote vault folder created!");
|
|
||||||
this.vaultFolderExists = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// adjust depth parameter
|
|
||||||
if (this.webdavConfig.depth === "auto_unknown") {
|
|
||||||
let testPassed = false;
|
|
||||||
try {
|
|
||||||
const res = await this.client.customRequest(`/${this.remoteBaseDir}/`, {
|
|
||||||
method: "PROPFIND",
|
|
||||||
headers: {
|
|
||||||
Depth: "infinity",
|
|
||||||
Accept: "text/plain,application/xml",
|
|
||||||
},
|
|
||||||
// responseType: "text",
|
|
||||||
} as any);
|
|
||||||
if (res.status === 403) {
|
|
||||||
throw Error("not support Infinity, get 403");
|
|
||||||
} else {
|
|
||||||
testPassed = true;
|
|
||||||
this.webdavConfig.depth = "auto_infinity";
|
|
||||||
this.webdavConfig.manualRecursive = false;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
testPassed = false;
|
|
||||||
}
|
|
||||||
if (!testPassed) {
|
|
||||||
try {
|
|
||||||
const res = await this.client.customRequest(
|
|
||||||
`/${this.remoteBaseDir}/`,
|
|
||||||
{
|
|
||||||
method: "PROPFIND",
|
|
||||||
headers: {
|
|
||||||
Depth: "1",
|
|
||||||
Accept: "text/plain,application/xml",
|
|
||||||
},
|
|
||||||
// responseType: "text",
|
|
||||||
} as any
|
|
||||||
);
|
|
||||||
testPassed = true;
|
|
||||||
this.webdavConfig.depth = "auto_1";
|
|
||||||
this.webdavConfig.manualRecursive = true;
|
|
||||||
} catch (error) {
|
|
||||||
testPassed = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (testPassed) {
|
|
||||||
// the depth option has been changed
|
|
||||||
// save the setting
|
|
||||||
if (this.saveUpdatedConfigFunc !== undefined) {
|
|
||||||
await this.saveUpdatedConfigFunc();
|
|
||||||
log.info(
|
|
||||||
`webdav depth="auto_unknown" is changed to ${this.webdavConfig.depth}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getWebdavClient = (
|
|
||||||
webdavConfig: WebdavConfig,
|
|
||||||
remoteBaseDir: string,
|
|
||||||
saveUpdatedConfigFunc: () => Promise<any>
|
|
||||||
) => {
|
|
||||||
return new WrappedWebdavClient(
|
|
||||||
webdavConfig,
|
|
||||||
remoteBaseDir,
|
|
||||||
saveUpdatedConfigFunc
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getRemoteMeta = async (
|
|
||||||
client: WrappedWebdavClient,
|
|
||||||
fileOrFolderPath: string
|
|
||||||
) => {
|
|
||||||
await client.init();
|
|
||||||
const remotePath = getWebdavPath(fileOrFolderPath, client.remoteBaseDir);
|
|
||||||
// log.info(`remotePath = ${remotePath}`);
|
|
||||||
const res = (await client.client.stat(remotePath, {
|
|
||||||
details: false,
|
|
||||||
})) as FileStat;
|
|
||||||
return fromWebdavItemToRemoteItem(res, client.remoteBaseDir);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const uploadToRemote = async (
|
|
||||||
client: WrappedWebdavClient,
|
|
||||||
fileOrFolderPath: string,
|
|
||||||
vault: Vault,
|
|
||||||
isRecursively: boolean = false,
|
|
||||||
password: string = "",
|
|
||||||
remoteEncryptedKey: string = "",
|
|
||||||
uploadRaw: boolean = false,
|
|
||||||
rawContent: string | ArrayBuffer = ""
|
|
||||||
) => {
|
|
||||||
await client.init();
|
|
||||||
let uploadFile = fileOrFolderPath;
|
|
||||||
if (password !== "") {
|
|
||||||
uploadFile = remoteEncryptedKey;
|
|
||||||
}
|
|
||||||
uploadFile = getWebdavPath(uploadFile, client.remoteBaseDir);
|
|
||||||
|
|
||||||
const isFolder = fileOrFolderPath.endsWith("/");
|
|
||||||
|
|
||||||
if (isFolder && isRecursively) {
|
|
||||||
throw Error("upload function doesn't implement recursive function yet!");
|
|
||||||
} else if (isFolder && !isRecursively) {
|
|
||||||
if (uploadRaw) {
|
|
||||||
throw Error(`you specify uploadRaw, but you also provide a folder key!`);
|
|
||||||
}
|
|
||||||
// folder
|
|
||||||
if (password === "") {
|
|
||||||
// if not encrypted, mkdir a remote folder
|
|
||||||
await client.client.createDirectory(uploadFile, {
|
|
||||||
recursive: false, // the sync algo should guarantee no need to recursive
|
|
||||||
});
|
|
||||||
const res = await getRemoteMeta(client, uploadFile);
|
|
||||||
return res;
|
|
||||||
} else {
|
|
||||||
// if encrypted, upload a fake file with the encrypted file name
|
|
||||||
await client.client.putFileContents(uploadFile, "", {
|
|
||||||
overwrite: true,
|
|
||||||
onUploadProgress: (progress: any) => {
|
|
||||||
// log.info(`Uploaded ${progress.loaded} bytes of ${progress.total}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return await getRemoteMeta(client, uploadFile);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// file
|
|
||||||
// we ignore isRecursively parameter here
|
|
||||||
let localContent = undefined;
|
|
||||||
if (uploadRaw) {
|
|
||||||
if (typeof rawContent === "string") {
|
|
||||||
localContent = new TextEncoder().encode(rawContent).buffer;
|
|
||||||
} else {
|
|
||||||
localContent = rawContent;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
localContent = await vault.adapter.readBinary(fileOrFolderPath);
|
|
||||||
}
|
|
||||||
let remoteContent = localContent;
|
|
||||||
if (password !== "") {
|
|
||||||
remoteContent = await encryptArrayBuffer(localContent, password);
|
|
||||||
}
|
|
||||||
// updated 20220326: the algorithm guarantee this
|
|
||||||
// // we need to create folders before uploading
|
|
||||||
// const dir = getPathFolder(uploadFile);
|
|
||||||
// if (dir !== "/" && dir !== "") {
|
|
||||||
// await client.client.createDirectory(dir, { recursive: false });
|
|
||||||
// }
|
|
||||||
await client.client.putFileContents(uploadFile, remoteContent, {
|
|
||||||
overwrite: true,
|
|
||||||
onUploadProgress: (progress: any) => {
|
|
||||||
log.info(`Uploaded ${progress.loaded} bytes of ${progress.total}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return await getRemoteMeta(client, uploadFile);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const listAllFromRemote = async (client: WrappedWebdavClient) => {
|
|
||||||
await client.init();
|
|
||||||
|
|
||||||
let contents = [] as FileStat[];
|
|
||||||
if (
|
|
||||||
client.webdavConfig.depth === "auto_1" ||
|
|
||||||
client.webdavConfig.depth === "manual_1"
|
|
||||||
) {
|
|
||||||
// the remote doesn't support infinity propfind,
|
|
||||||
// we need to do a bfs here
|
|
||||||
const q = new Queue([`/${client.remoteBaseDir}`]);
|
|
||||||
const CHUNK_SIZE = 10;
|
|
||||||
while (q.length > 0) {
|
|
||||||
const itemsToFetch = [];
|
|
||||||
while (q.length > 0) {
|
|
||||||
itemsToFetch.push(q.pop());
|
|
||||||
}
|
|
||||||
const itemsToFetchChunks = chunk(itemsToFetch, CHUNK_SIZE);
|
|
||||||
// log.debug(itemsToFetchChunks);
|
|
||||||
const subContents = [] as FileStat[];
|
|
||||||
for (const singleChunk of itemsToFetchChunks) {
|
|
||||||
const r = singleChunk.map((x) => {
|
|
||||||
return client.client.getDirectoryContents(x, {
|
|
||||||
deep: false,
|
|
||||||
details: false /* no need for verbose details here */,
|
|
||||||
// TODO: to support .obsidian,
|
|
||||||
// we need to load all files including dot,
|
|
||||||
// anyway to reduce the resources?
|
|
||||||
// glob: "/**" /* avoid dot files by using glob */,
|
|
||||||
}) as Promise<FileStat[]>;
|
|
||||||
});
|
|
||||||
const r2 = flatten(await Promise.all(r));
|
|
||||||
subContents.push(...r2);
|
|
||||||
}
|
|
||||||
for (let i = 0; i < subContents.length; ++i) {
|
|
||||||
const f = subContents[i];
|
|
||||||
contents.push(f);
|
|
||||||
if (f.type === "directory") {
|
|
||||||
q.push(f.filename);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// the remote supports infinity propfind
|
|
||||||
contents = (await client.client.getDirectoryContents(
|
|
||||||
`/${client.remoteBaseDir}`,
|
|
||||||
{
|
|
||||||
deep: true,
|
|
||||||
details: false /* no need for verbose details here */,
|
|
||||||
// TODO: to support .obsidian,
|
|
||||||
// we need to load all files including dot,
|
|
||||||
// anyway to reduce the resources?
|
|
||||||
// glob: "/**" /* avoid dot files by using glob */,
|
|
||||||
}
|
|
||||||
)) as FileStat[];
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
Contents: contents.map((x) =>
|
|
||||||
fromWebdavItemToRemoteItem(x, client.remoteBaseDir)
|
|
||||||
),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadFromRemoteRaw = async (
|
|
||||||
client: WrappedWebdavClient,
|
|
||||||
fileOrFolderPath: string
|
|
||||||
) => {
|
|
||||||
await client.init();
|
|
||||||
const p = getWebdavPath(fileOrFolderPath, client.remoteBaseDir);
|
|
||||||
// console.log(`getWebdavPath=${p}`);
|
|
||||||
const buff = (await client.client.getFileContents(p)) as BufferLike;
|
|
||||||
if (buff instanceof ArrayBuffer) {
|
|
||||||
return buff;
|
|
||||||
} else if (buff instanceof Buffer) {
|
|
||||||
return bufferToArrayBuffer(buff);
|
|
||||||
}
|
|
||||||
throw Error(`unexpected file content result with type ${typeof buff}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const downloadFromRemote = async (
|
|
||||||
client: WrappedWebdavClient,
|
|
||||||
fileOrFolderPath: string,
|
|
||||||
vault: Vault,
|
|
||||||
mtime: number,
|
|
||||||
password: string = "",
|
|
||||||
remoteEncryptedKey: string = "",
|
|
||||||
skipSaving: boolean = false
|
|
||||||
) => {
|
|
||||||
await client.init();
|
|
||||||
|
|
||||||
const isFolder = fileOrFolderPath.endsWith("/");
|
|
||||||
|
|
||||||
if (!skipSaving) {
|
|
||||||
await mkdirpInVault(fileOrFolderPath, vault);
|
|
||||||
}
|
|
||||||
|
|
||||||
// the file is always local file
|
|
||||||
// we need to encrypt it
|
|
||||||
|
|
||||||
if (isFolder) {
|
|
||||||
// mkdirp locally is enough
|
|
||||||
// do nothing here
|
|
||||||
return new ArrayBuffer(0);
|
|
||||||
} else {
|
|
||||||
let downloadFile = fileOrFolderPath;
|
|
||||||
if (password !== "") {
|
|
||||||
downloadFile = remoteEncryptedKey;
|
|
||||||
}
|
|
||||||
downloadFile = getWebdavPath(downloadFile, client.remoteBaseDir);
|
|
||||||
// console.log(`downloadFile=${downloadFile}`);
|
|
||||||
const remoteContent = await downloadFromRemoteRaw(client, downloadFile);
|
|
||||||
let localContent = remoteContent;
|
|
||||||
if (password !== "") {
|
|
||||||
localContent = await decryptArrayBuffer(remoteContent, password);
|
|
||||||
}
|
|
||||||
if (!skipSaving) {
|
|
||||||
await vault.adapter.writeBinary(fileOrFolderPath, localContent, {
|
|
||||||
mtime: mtime,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return localContent;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteFromRemote = async (
|
|
||||||
client: WrappedWebdavClient,
|
|
||||||
fileOrFolderPath: string,
|
|
||||||
password: string = "",
|
|
||||||
remoteEncryptedKey: string = ""
|
|
||||||
) => {
|
|
||||||
if (fileOrFolderPath === "/") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let remoteFileName = fileOrFolderPath;
|
|
||||||
if (password !== "") {
|
|
||||||
remoteFileName = remoteEncryptedKey;
|
|
||||||
}
|
|
||||||
remoteFileName = getWebdavPath(remoteFileName, client.remoteBaseDir);
|
|
||||||
|
|
||||||
await client.init();
|
|
||||||
try {
|
|
||||||
await client.client.deleteFile(remoteFileName);
|
|
||||||
// log.info(`delete ${remoteFileName} succeeded`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("some error while deleting");
|
|
||||||
log.info(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const checkConnectivity = async (
|
|
||||||
client: WrappedWebdavClient,
|
|
||||||
callbackFunc?: any
|
|
||||||
) => {
|
|
||||||
if (
|
|
||||||
!(
|
|
||||||
client.webdavConfig.address.startsWith("http://") ||
|
|
||||||
client.webdavConfig.address.startsWith("https://")
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
const err = "Error: the url should start with http(s):// but it does not!";
|
|
||||||
log.debug(err);
|
|
||||||
if (callbackFunc !== undefined) {
|
|
||||||
callbackFunc(err);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await client.init();
|
|
||||||
const results = await getRemoteMeta(client, "/");
|
|
||||||
if (results === undefined) {
|
|
||||||
const err = "results is undefined";
|
|
||||||
log.debug(err);
|
|
||||||
if (callbackFunc !== undefined) {
|
|
||||||
callbackFunc(err);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
log.debug(err);
|
|
||||||
if (callbackFunc !== undefined) {
|
|
||||||
callbackFunc(err);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
+507
-351
File diff suppressed because it is too large
Load Diff
+1208
-1301
File diff suppressed because it is too large
Load Diff
@@ -1,64 +0,0 @@
|
|||||||
import { App, Modal, Notice, PluginSettingTab, Setting } from "obsidian";
|
|
||||||
import type RemotelySavePlugin from "./main"; // unavoidable
|
|
||||||
import type { TransItemType } from "./i18n";
|
|
||||||
|
|
||||||
import { log } from "./moreOnLog";
|
|
||||||
|
|
||||||
export class SyncAlgoV2Modal extends Modal {
|
|
||||||
agree: boolean;
|
|
||||||
readonly plugin: RemotelySavePlugin;
|
|
||||||
constructor(app: App, plugin: RemotelySavePlugin) {
|
|
||||||
super(app);
|
|
||||||
this.plugin = plugin;
|
|
||||||
this.agree = false;
|
|
||||||
}
|
|
||||||
onOpen() {
|
|
||||||
let { contentEl } = this;
|
|
||||||
const t = (x: TransItemType, vars?: any) => {
|
|
||||||
return this.plugin.i18n.t(x, vars);
|
|
||||||
};
|
|
||||||
|
|
||||||
contentEl.createEl("h2", {
|
|
||||||
text: t("syncalgov2_title"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const ul = contentEl.createEl("ul");
|
|
||||||
t("syncalgov2_texts")
|
|
||||||
.split("\n")
|
|
||||||
.forEach((val) => {
|
|
||||||
ul.createEl("li", {
|
|
||||||
text: val,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
new Setting(contentEl)
|
|
||||||
.addButton((button) => {
|
|
||||||
button.setButtonText(t("syncalgov2_button_agree"));
|
|
||||||
button.onClick(async () => {
|
|
||||||
this.agree = true;
|
|
||||||
this.close();
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.addButton((button) => {
|
|
||||||
button.setButtonText(t("syncalgov2_button_disagree"));
|
|
||||||
button.onClick(() => {
|
|
||||||
this.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onClose() {
|
|
||||||
let { contentEl } = this;
|
|
||||||
contentEl.empty();
|
|
||||||
if (this.agree) {
|
|
||||||
log.info("agree to use the new algorithm");
|
|
||||||
this.plugin.saveAgreeToUseNewSyncAlgorithm();
|
|
||||||
this.plugin.enableAutoSyncIfSet();
|
|
||||||
this.plugin.enableInitSyncIfSet();
|
|
||||||
this.plugin.enableSyncOnSaveIfSet();
|
|
||||||
} else {
|
|
||||||
log.info("do not agree to use the new algorithm");
|
|
||||||
this.plugin.unload();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { App, Modal, Notice, PluginSettingTab, Setting } from "obsidian";
|
||||||
|
import type RemotelySavePlugin from "./main"; // unavoidable
|
||||||
|
import type { TransItemType } from "./i18n";
|
||||||
|
|
||||||
|
import { stringToFragment } from "./misc";
|
||||||
|
|
||||||
|
export class SyncAlgoV3Modal extends Modal {
|
||||||
|
agree: boolean;
|
||||||
|
manualBackup: boolean;
|
||||||
|
requireUpdateAllDev: boolean;
|
||||||
|
readonly plugin: RemotelySavePlugin;
|
||||||
|
constructor(app: App, plugin: RemotelySavePlugin) {
|
||||||
|
super(app);
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.agree = false;
|
||||||
|
this.manualBackup = false;
|
||||||
|
this.requireUpdateAllDev = false;
|
||||||
|
}
|
||||||
|
onOpen() {
|
||||||
|
let { contentEl } = this;
|
||||||
|
const t = (x: TransItemType, vars?: any) => {
|
||||||
|
return this.plugin.i18n.t(x, vars);
|
||||||
|
};
|
||||||
|
|
||||||
|
contentEl.createEl("h2", {
|
||||||
|
text: t("syncalgov3_title"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ul = contentEl.createEl("ul");
|
||||||
|
t("syncalgov3_texts")
|
||||||
|
.split("\n")
|
||||||
|
.forEach((val) => {
|
||||||
|
ul.createEl("li", {
|
||||||
|
text: stringToFragment(val),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// code modified partially from BART released under MIT License
|
||||||
|
contentEl.createDiv("modal-button-container", (buttonContainerEl) => {
|
||||||
|
let agreeBtn: HTMLButtonElement | undefined = undefined;
|
||||||
|
|
||||||
|
buttonContainerEl.createEl(
|
||||||
|
"label",
|
||||||
|
{
|
||||||
|
cls: "mod-checkbox",
|
||||||
|
},
|
||||||
|
(labelEl) => {
|
||||||
|
const checkboxEl = labelEl.createEl("input", {
|
||||||
|
attr: { tabindex: -1 },
|
||||||
|
type: "checkbox",
|
||||||
|
});
|
||||||
|
checkboxEl.checked = this.manualBackup;
|
||||||
|
checkboxEl.addEventListener("click", () => {
|
||||||
|
this.manualBackup = checkboxEl.checked;
|
||||||
|
if (agreeBtn !== undefined) {
|
||||||
|
if (this.manualBackup && this.requireUpdateAllDev) {
|
||||||
|
agreeBtn.removeAttribute("disabled");
|
||||||
|
} else {
|
||||||
|
agreeBtn.setAttr("disabled", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
labelEl.appendText(t("syncalgov3_checkbox_manual_backup"));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
buttonContainerEl.createEl(
|
||||||
|
"label",
|
||||||
|
{
|
||||||
|
cls: "mod-checkbox",
|
||||||
|
},
|
||||||
|
(labelEl) => {
|
||||||
|
const checkboxEl = labelEl.createEl("input", {
|
||||||
|
attr: { tabindex: -1 },
|
||||||
|
type: "checkbox",
|
||||||
|
});
|
||||||
|
checkboxEl.checked = this.requireUpdateAllDev;
|
||||||
|
checkboxEl.addEventListener("click", () => {
|
||||||
|
this.requireUpdateAllDev = checkboxEl.checked;
|
||||||
|
if (agreeBtn !== undefined) {
|
||||||
|
if (this.manualBackup && this.requireUpdateAllDev) {
|
||||||
|
agreeBtn.removeAttribute("disabled");
|
||||||
|
} else {
|
||||||
|
agreeBtn.setAttr("disabled", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
labelEl.appendText(t("syncalgov3_checkbox_requiremultidevupdate"));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
agreeBtn = buttonContainerEl.createEl("button", {
|
||||||
|
attr: { type: "button" },
|
||||||
|
cls: "mod-cta",
|
||||||
|
text: t("syncalgov3_button_agree"),
|
||||||
|
});
|
||||||
|
agreeBtn.setAttr("disabled", true);
|
||||||
|
agreeBtn.addEventListener("click", () => {
|
||||||
|
this.agree = true;
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
buttonContainerEl
|
||||||
|
.createEl("button", {
|
||||||
|
attr: { type: "submit" },
|
||||||
|
text: t("syncalgov3_button_disagree"),
|
||||||
|
})
|
||||||
|
.addEventListener("click", () => {
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
let { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
if (this.agree) {
|
||||||
|
console.info("agree to use the new algorithm");
|
||||||
|
this.plugin.saveAgreeToUseNewSyncAlgorithm();
|
||||||
|
this.plugin.enableAutoSyncIfSet();
|
||||||
|
this.plugin.enableInitSyncIfSet();
|
||||||
|
this.plugin.toggleSyncOnSaveIfSet();
|
||||||
|
} else {
|
||||||
|
console.info("do not agree to use the new algorithm");
|
||||||
|
this.plugin.unload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { App, Modal, Notice, PluginSettingTab, Setting } from "obsidian";
|
|
||||||
import type RemotelySavePlugin from "./main"; // unavoidable
|
|
||||||
import type { TransItemType } from "./i18n";
|
|
||||||
import type { FileOrFolderMixedState } from "./baseTypes";
|
|
||||||
|
|
||||||
import { log } from "./moreOnLog";
|
|
||||||
|
|
||||||
export class SizesConflictModal extends Modal {
|
|
||||||
readonly plugin: RemotelySavePlugin;
|
|
||||||
readonly skipSizeLargerThan: number;
|
|
||||||
readonly sizesGoWrong: FileOrFolderMixedState[];
|
|
||||||
readonly hasPassword: boolean;
|
|
||||||
constructor(
|
|
||||||
app: App,
|
|
||||||
plugin: RemotelySavePlugin,
|
|
||||||
skipSizeLargerThan: number,
|
|
||||||
sizesGoWrong: FileOrFolderMixedState[],
|
|
||||||
hasPassword: boolean
|
|
||||||
) {
|
|
||||||
super(app);
|
|
||||||
this.plugin = plugin;
|
|
||||||
this.skipSizeLargerThan = skipSizeLargerThan;
|
|
||||||
this.sizesGoWrong = sizesGoWrong;
|
|
||||||
this.hasPassword = hasPassword;
|
|
||||||
}
|
|
||||||
onOpen() {
|
|
||||||
let { contentEl } = this;
|
|
||||||
const t = (x: TransItemType, vars?: any) => {
|
|
||||||
return this.plugin.i18n.t(x, vars);
|
|
||||||
};
|
|
||||||
|
|
||||||
contentEl.createEl("h2", {
|
|
||||||
text: t("modal_sizesconflict_title"),
|
|
||||||
});
|
|
||||||
|
|
||||||
t("modal_sizesconflict_desc", {
|
|
||||||
thresholdMB: `${this.skipSizeLargerThan / 1000 / 1000}`,
|
|
||||||
thresholdBytes: `${this.skipSizeLargerThan}`,
|
|
||||||
})
|
|
||||||
.split("\n")
|
|
||||||
.forEach((val) => {
|
|
||||||
contentEl.createEl("p", { text: val });
|
|
||||||
});
|
|
||||||
|
|
||||||
const info = this.serialize();
|
|
||||||
|
|
||||||
contentEl.createDiv().createEl(
|
|
||||||
"button",
|
|
||||||
{
|
|
||||||
text: t("modal_sizesconflict_copybutton"),
|
|
||||||
},
|
|
||||||
(el) => {
|
|
||||||
el.onclick = async () => {
|
|
||||||
await navigator.clipboard.writeText(info);
|
|
||||||
new Notice(t("modal_sizesconflict_copynotice"));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
contentEl.createEl("pre", {
|
|
||||||
text: info,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
serialize() {
|
|
||||||
return this.sizesGoWrong
|
|
||||||
.map((x) => {
|
|
||||||
return [
|
|
||||||
x.key,
|
|
||||||
this.hasPassword
|
|
||||||
? `encrypted name: ${x.remoteEncryptedKey}`
|
|
||||||
: undefined,
|
|
||||||
`local ${this.hasPassword ? "encrypted " : ""}bytes: ${
|
|
||||||
this.hasPassword ? x.sizeLocalEnc : x.sizeLocal
|
|
||||||
}`,
|
|
||||||
`remote ${this.hasPassword ? "encrypted " : ""}bytes: ${
|
|
||||||
this.hasPassword ? x.sizeRemoteEnc : x.sizeRemote
|
|
||||||
}`,
|
|
||||||
]
|
|
||||||
.filter((tmp) => tmp !== undefined)
|
|
||||||
.join("\n");
|
|
||||||
})
|
|
||||||
.join("\n\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
onClose() {
|
|
||||||
let { contentEl } = this;
|
|
||||||
contentEl.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
declare module "*.worker.ts" {
|
||||||
|
class WebpackWorker extends Worker {
|
||||||
|
constructor();
|
||||||
|
}
|
||||||
|
export default WebpackWorker;
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.encryptionmethod-second-confirm {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-auth-related {
|
.settings-auth-related {
|
||||||
border-top: 1px solid var(--background-modifier-border);
|
border-top: 1px solid var(--background-modifier-border);
|
||||||
padding-top: 18px;
|
padding-top: 18px;
|
||||||
@@ -70,3 +74,7 @@
|
|||||||
color: red;
|
color: red;
|
||||||
font-weight: bolder;
|
font-weight: bolder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setting-need-wrapping-mobile .setting-item-control {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import * as chai from "chai";
|
import { strict as assert } from "assert";
|
||||||
import chaiAsPromised from "chai-as-promised";
|
|
||||||
|
|
||||||
import { RemotelySavePluginSettings } from "../src/baseTypes";
|
import { RemotelySavePluginSettings } from "../src/baseTypes";
|
||||||
import { messyConfigToNormal, normalConfigToMessy } from "../src/configPersist";
|
import { messyConfigToNormal, normalConfigToMessy } from "../src/configPersist";
|
||||||
|
|
||||||
chai.use(chaiAsPromised);
|
|
||||||
const expect = chai.expect;
|
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: RemotelySavePluginSettings = {
|
const DEFAULT_SETTINGS: RemotelySavePluginSettings = {
|
||||||
s3: {
|
s3: {
|
||||||
s3AccessKeyID: "acc",
|
s3AccessKeyID: "acc",
|
||||||
@@ -32,6 +28,6 @@ describe("Config Persist tests", () => {
|
|||||||
const k = DEFAULT_SETTINGS;
|
const k = DEFAULT_SETTINGS;
|
||||||
const k2 = normalConfigToMessy(k);
|
const k2 = normalConfigToMessy(k);
|
||||||
const k3 = messyConfigToNormal(k2);
|
const k3 = messyConfigToNormal(k2);
|
||||||
expect(k3).to.deep.equal(k);
|
assert.deepEqual(k3, k);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import * as chai from "chai";
|
import { strict as assert } from "assert";
|
||||||
import chaiAsPromised from "chai-as-promised";
|
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import {
|
import {
|
||||||
@@ -10,13 +9,10 @@ import {
|
|||||||
encryptStringToBase64url,
|
encryptStringToBase64url,
|
||||||
getSizeFromEncToOrig,
|
getSizeFromEncToOrig,
|
||||||
getSizeFromOrigToEnc,
|
getSizeFromOrigToEnc,
|
||||||
} from "../src/encrypt";
|
} from "../src/encryptOpenSSL";
|
||||||
import { base64ToBase64url, bufferToArrayBuffer } from "../src/misc";
|
import { base64ToBase64url, bufferToArrayBuffer } from "../src/misc";
|
||||||
|
|
||||||
chai.use(chaiAsPromised);
|
describe("Encryption OpenSSL tests", () => {
|
||||||
const expect = chai.expect;
|
|
||||||
|
|
||||||
describe("Encryption tests", () => {
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
global.window = {
|
global.window = {
|
||||||
crypto: require("crypto").webcrypto,
|
crypto: require("crypto").webcrypto,
|
||||||
@@ -26,7 +22,7 @@ describe("Encryption tests", () => {
|
|||||||
it("should encrypt string", async () => {
|
it("should encrypt string", async () => {
|
||||||
const k = "dkjdhkfhdkjgsdklxxd";
|
const k = "dkjdhkfhdkjgsdklxxd";
|
||||||
const password = "hey";
|
const password = "hey";
|
||||||
expect(await encryptStringToBase32(k, password)).to.not.equal(k);
|
assert.notEqual(await encryptStringToBase32(k, password), k);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should encrypt string and return different results each time", async () => {
|
it("should encrypt string and return different results each time", async () => {
|
||||||
@@ -34,7 +30,7 @@ describe("Encryption tests", () => {
|
|||||||
const password = "hey";
|
const password = "hey";
|
||||||
const res1 = await encryptStringToBase32(k, password);
|
const res1 = await encryptStringToBase32(k, password);
|
||||||
const res2 = await encryptStringToBase32(k, password);
|
const res2 = await encryptStringToBase32(k, password);
|
||||||
expect(res1).to.not.equal(res2);
|
assert.notEqual(res1, res2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should raise error using different password", async () => {
|
it("should raise error using different password", async () => {
|
||||||
@@ -42,7 +38,7 @@ describe("Encryption tests", () => {
|
|||||||
const password = "hey";
|
const password = "hey";
|
||||||
const password2 = "hey2";
|
const password2 = "hey2";
|
||||||
const enc = await encryptStringToBase32(k, password);
|
const enc = await encryptStringToBase32(k, password);
|
||||||
await expect(decryptBase32ToString(enc, password2)).to.be.rejected;
|
await assert.rejects(decryptBase32ToString(enc, password2));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should encrypt and decrypt string and get the same result returned", async () => {
|
it("should encrypt and decrypt string and get the same result returned", async () => {
|
||||||
@@ -52,7 +48,7 @@ describe("Encryption tests", () => {
|
|||||||
// console.log(enc);
|
// console.log(enc);
|
||||||
const dec = await decryptBase32ToString(enc, password);
|
const dec = await decryptBase32ToString(enc, password);
|
||||||
// console.log(dec);
|
// console.log(dec);
|
||||||
expect(dec).equal(k);
|
assert.equal(dec, k);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should encrypt text file and get the same result as openssl", async () => {
|
it("should encrypt text file and get the same result as openssl", async () => {
|
||||||
@@ -78,7 +74,7 @@ describe("Encryption tests", () => {
|
|||||||
// we output base32, so we need some transformation
|
// we output base32, so we need some transformation
|
||||||
const opensslBase64urlRes = base64ToBase64url(opensslBase64Res);
|
const opensslBase64urlRes = base64ToBase64url(opensslBase64Res);
|
||||||
|
|
||||||
expect(enc).equal(opensslBase64urlRes);
|
assert.equal(enc, opensslBase64urlRes);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should encrypt binary file and get the same result as openssl", async () => {
|
it("should encrypt binary file and get the same result as openssl", async () => {
|
||||||
@@ -102,7 +98,7 @@ describe("Encryption tests", () => {
|
|||||||
|
|
||||||
// openssl enc -p -aes-256-cbc -S 8302F586FAB491EC -pbkdf2 -iter 20000 -pass pass:somepassword -in mona_lisa/1374px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg -out mona_lisa/1374px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg.enc
|
// openssl enc -p -aes-256-cbc -S 8302F586FAB491EC -pbkdf2 -iter 20000 -pass pass:somepassword -in mona_lisa/1374px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg -out mona_lisa/1374px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg.enc
|
||||||
|
|
||||||
expect(Buffer.from(enc).equals(Buffer.from(opensslArrBuf))).to.be.true;
|
assert.ok(Buffer.from(enc).equals(Buffer.from(opensslArrBuf)));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should encrypt binary file not deterministically", async () => {
|
it("should encrypt binary file not deterministically", async () => {
|
||||||
@@ -116,7 +112,7 @@ describe("Encryption tests", () => {
|
|||||||
const res1 = await encryptArrayBuffer(fileArrBuf, password);
|
const res1 = await encryptArrayBuffer(fileArrBuf, password);
|
||||||
const res2 = await encryptArrayBuffer(fileArrBuf, password);
|
const res2 = await encryptArrayBuffer(fileArrBuf, password);
|
||||||
|
|
||||||
expect(Buffer.from(res1).equals(Buffer.from(res2))).to.be.false;
|
assert.ok(!Buffer.from(res1).equals(Buffer.from(res2)));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should decrypt binary file and get the same result as openssl", async () => {
|
it("should decrypt binary file and get the same result as openssl", async () => {
|
||||||
@@ -132,36 +128,36 @@ describe("Encryption tests", () => {
|
|||||||
await fs.readFileSync(path.join(testFolder, testFileName))
|
await fs.readFileSync(path.join(testFolder, testFileName))
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(Buffer.from(dec).equals(Buffer.from(opensslArrBuf))).to.be.true;
|
assert.deepEqual(Buffer.from(dec), Buffer.from(opensslArrBuf));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should get size from origin to encrypted correctly", () => {
|
it("should get size from origin to encrypted correctly", () => {
|
||||||
expect(() => getSizeFromOrigToEnc(-1)).to.throw();
|
assert.throws(() => getSizeFromOrigToEnc(-1));
|
||||||
expect(() => getSizeFromOrigToEnc(0.5)).to.throw();
|
assert.throws(() => getSizeFromOrigToEnc(0.5));
|
||||||
expect(getSizeFromOrigToEnc(0)).equals(32);
|
assert.equal(getSizeFromOrigToEnc(0), 32);
|
||||||
expect(getSizeFromOrigToEnc(15)).equals(32);
|
assert.equal(getSizeFromOrigToEnc(15), 32);
|
||||||
expect(getSizeFromOrigToEnc(16)).equals(48);
|
assert.equal(getSizeFromOrigToEnc(16), 48);
|
||||||
expect(getSizeFromOrigToEnc(31)).equals(48);
|
assert.equal(getSizeFromOrigToEnc(31), 48);
|
||||||
expect(getSizeFromOrigToEnc(32)).equals(64);
|
assert.equal(getSizeFromOrigToEnc(32), 64);
|
||||||
expect(getSizeFromOrigToEnc(14787203)).equals(14787232);
|
assert.equal(getSizeFromOrigToEnc(14787203), 14787232);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should get size from encrypted to origin correctly", () => {
|
it("should get size from encrypted to origin correctly", () => {
|
||||||
expect(() => getSizeFromEncToOrig(-1)).to.throw();
|
assert.throws(() => getSizeFromEncToOrig(-1));
|
||||||
expect(() => getSizeFromEncToOrig(30)).to.throw();
|
assert.throws(() => getSizeFromEncToOrig(30));
|
||||||
|
|
||||||
expect(getSizeFromEncToOrig(32)).to.deep.equal({
|
assert.deepEqual(getSizeFromEncToOrig(32), {
|
||||||
minSize: 0,
|
minSize: 0,
|
||||||
maxSize: 15,
|
maxSize: 15,
|
||||||
});
|
});
|
||||||
expect(getSizeFromEncToOrig(48)).to.deep.equal({
|
assert.deepEqual(getSizeFromEncToOrig(48), {
|
||||||
minSize: 16,
|
minSize: 16,
|
||||||
maxSize: 31,
|
maxSize: 31,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(() => getSizeFromEncToOrig(14787231)).to.throw();
|
assert.throws(() => getSizeFromEncToOrig(14787231));
|
||||||
|
|
||||||
let { minSize, maxSize } = getSizeFromEncToOrig(14787232);
|
let { minSize, maxSize } = getSizeFromEncToOrig(14787232);
|
||||||
expect(minSize <= 14787203 && 14787203 <= maxSize).to.be.true;
|
assert.ok(minSize <= 14787203 && 14787203 <= maxSize);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,14 +1,9 @@
|
|||||||
import * as chai from "chai";
|
import { strict as assert } from "assert";
|
||||||
import chaiAsPromised from "chai-as-promised";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isEqualMetadataOnRemote,
|
isEqualMetadataOnRemote,
|
||||||
MetadataOnRemote,
|
MetadataOnRemote,
|
||||||
} from "../src/metadataOnRemote";
|
} from "../src/metadataOnRemote";
|
||||||
|
|
||||||
chai.use(chaiAsPromised);
|
|
||||||
const expect = chai.expect;
|
|
||||||
|
|
||||||
describe("Metadata operations tests", () => {
|
describe("Metadata operations tests", () => {
|
||||||
it("should compare objects deeply", async () => {
|
it("should compare objects deeply", async () => {
|
||||||
const a: MetadataOnRemote = {
|
const a: MetadataOnRemote = {
|
||||||
@@ -24,7 +19,7 @@ describe("Metadata operations tests", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(isEqualMetadataOnRemote(a, b));
|
assert.ok(isEqualMetadataOnRemote(a, b));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should find diff", async () => {
|
it("should find diff", async () => {
|
||||||
@@ -41,34 +36,34 @@ describe("Metadata operations tests", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(!isEqualMetadataOnRemote(a, b));
|
assert.ok(!isEqualMetadataOnRemote(a, b));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should treat undefined correctly", async () => {
|
it("should treat undefined correctly", async () => {
|
||||||
const a: MetadataOnRemote = undefined;
|
const a: MetadataOnRemote | undefined = undefined;
|
||||||
let b: MetadataOnRemote = {
|
let b: MetadataOnRemote | undefined = {
|
||||||
deletions: [
|
deletions: [
|
||||||
{ key: "xxx", actionWhen: 1 },
|
{ key: "xxx", actionWhen: 1 },
|
||||||
{ key: "yyy", actionWhen: 2 },
|
{ key: "yyy", actionWhen: 2 },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(!isEqualMetadataOnRemote(a, b));
|
assert.ok(!isEqualMetadataOnRemote(a, b));
|
||||||
|
|
||||||
b = { deletions: [] };
|
b = { deletions: [] };
|
||||||
expect(isEqualMetadataOnRemote(a, b));
|
assert.ok(isEqualMetadataOnRemote(a, b));
|
||||||
|
|
||||||
b = { deletions: undefined };
|
b = { deletions: undefined };
|
||||||
expect(isEqualMetadataOnRemote(a, b));
|
assert.ok(isEqualMetadataOnRemote(a, b));
|
||||||
|
|
||||||
b = undefined;
|
b = undefined;
|
||||||
expect(isEqualMetadataOnRemote(a, b));
|
assert.ok(isEqualMetadataOnRemote(a, b));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should ignore generated at fields", async () => {
|
it("should ignore generated at fields", async () => {
|
||||||
const a: MetadataOnRemote = {
|
const a: MetadataOnRemote = {
|
||||||
deletions: [
|
deletions: [
|
||||||
{ key: "xxxx", actionWhen: 1 },
|
{ key: "xxx", actionWhen: 1 },
|
||||||
{ key: "yyy", actionWhen: 2 },
|
{ key: "yyy", actionWhen: 2 },
|
||||||
],
|
],
|
||||||
generatedWhen: 1,
|
generatedWhen: 1,
|
||||||
@@ -81,6 +76,6 @@ describe("Metadata operations tests", () => {
|
|||||||
generatedWhen: 2,
|
generatedWhen: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(isEqualMetadataOnRemote(a, b));
|
assert.ok(isEqualMetadataOnRemote(a, b));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+122
-69
@@ -1,139 +1,139 @@
|
|||||||
import { expect } from "chai";
|
import { strict as assert } from "assert";
|
||||||
import { JSDOM } from "jsdom";
|
import { JSDOM } from "jsdom";
|
||||||
import * as misc from "../src/misc";
|
import * as misc from "../src/misc";
|
||||||
|
|
||||||
describe("Misc: hidden file", () => {
|
describe("Misc: hidden file", () => {
|
||||||
it("should find hidden file correctly", () => {
|
it("should find hidden file correctly", () => {
|
||||||
let item = "";
|
let item = "";
|
||||||
expect(misc.isHiddenPath(item)).to.be.false;
|
assert.ok(!misc.isHiddenPath(item));
|
||||||
|
|
||||||
item = ".";
|
item = ".";
|
||||||
expect(misc.isHiddenPath(item)).to.be.false;
|
assert.ok(!misc.isHiddenPath(item));
|
||||||
|
|
||||||
item = "..";
|
item = "..";
|
||||||
expect(misc.isHiddenPath(item)).to.be.false;
|
assert.ok(!misc.isHiddenPath(item));
|
||||||
|
|
||||||
item = "/x/y/z/../././../a/b/c";
|
item = "/x/y/z/../././../a/b/c";
|
||||||
expect(misc.isHiddenPath(item)).to.be.false;
|
assert.ok(!misc.isHiddenPath(item));
|
||||||
|
|
||||||
item = ".hidden";
|
item = ".hidden";
|
||||||
expect(misc.isHiddenPath(item)).to.be.true;
|
assert.ok(misc.isHiddenPath(item));
|
||||||
|
|
||||||
item = "_hidden_loose";
|
item = "_hidden_loose";
|
||||||
expect(misc.isHiddenPath(item)).to.be.true;
|
assert.ok(misc.isHiddenPath(item));
|
||||||
expect(misc.isHiddenPath(item, true, false)).to.be.false;
|
assert.ok(!misc.isHiddenPath(item, true, false));
|
||||||
|
|
||||||
item = "/sdd/_hidden_loose";
|
item = "/sdd/_hidden_loose";
|
||||||
expect(misc.isHiddenPath(item)).to.be.true;
|
assert.ok(misc.isHiddenPath(item));
|
||||||
|
|
||||||
item = "what/../_hidden_loose/what/what/what";
|
item = "what/../_hidden_loose/what/what/what";
|
||||||
expect(misc.isHiddenPath(item)).to.be.true;
|
assert.ok(misc.isHiddenPath(item));
|
||||||
|
|
||||||
item = "what/../_hidden_loose/what/what/what";
|
item = "what/../_hidden_loose/what/what/what";
|
||||||
expect(misc.isHiddenPath(item, true, false)).to.be.false;
|
assert.ok(!misc.isHiddenPath(item, true, false));
|
||||||
|
|
||||||
item = "what/../_hidden_loose/../.hidden/what/what/what";
|
item = "what/../_hidden_loose/../.hidden/what/what/what";
|
||||||
expect(misc.isHiddenPath(item, true, false)).to.be.true;
|
assert.ok(misc.isHiddenPath(item, true, false));
|
||||||
|
|
||||||
item = "what/../_hidden_loose/../.hidden/what/what/what";
|
item = "what/../_hidden_loose/../.hidden/what/what/what";
|
||||||
expect(misc.isHiddenPath(item, false, true)).to.be.false;
|
assert.ok(!misc.isHiddenPath(item, false, true));
|
||||||
|
|
||||||
item = "what/_hidden_loose/what/what/what";
|
item = "what/_hidden_loose/what/what/what";
|
||||||
expect(misc.isHiddenPath(item, false, true)).to.be.true;
|
assert.ok(misc.isHiddenPath(item, false, true));
|
||||||
expect(misc.isHiddenPath(item, true, false)).to.be.false;
|
assert.ok(!misc.isHiddenPath(item, true, false));
|
||||||
|
|
||||||
item = "what/.hidden/what/what/what";
|
item = "what/.hidden/what/what/what";
|
||||||
expect(misc.isHiddenPath(item, false, true)).to.be.false;
|
assert.ok(!misc.isHiddenPath(item, false, true));
|
||||||
expect(misc.isHiddenPath(item, true, false)).to.be.true;
|
assert.ok(misc.isHiddenPath(item, true, false));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Misc: get folder levels", () => {
|
describe("Misc: get folder levels", () => {
|
||||||
it("should ignore empty path", () => {
|
it("should ignore empty path", () => {
|
||||||
const item = "";
|
const item = "";
|
||||||
expect(misc.getFolderLevels(item)).to.be.empty;
|
assert.equal(misc.getFolderLevels(item).length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should ignore single file", () => {
|
it("should ignore single file", () => {
|
||||||
const item = "xxx";
|
const item = "xxx";
|
||||||
expect(misc.getFolderLevels(item)).to.be.empty;
|
assert.equal(misc.getFolderLevels(item).length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should detect path ending with /", () => {
|
it("should detect path ending with /", () => {
|
||||||
const item = "xxx/";
|
const item = "xxx/";
|
||||||
const res = ["xxx"];
|
const res = ["xxx"];
|
||||||
expect(misc.getFolderLevels(item)).to.deep.equal(res);
|
assert.deepEqual(misc.getFolderLevels(item), res);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should correctly split folders and files", () => {
|
it("should correctly split folders and files", () => {
|
||||||
const item = "xxx/yyy/zzz.md";
|
const item = "xxx/yyy/zzz.md";
|
||||||
const res = ["xxx", "xxx/yyy"];
|
const res = ["xxx", "xxx/yyy"];
|
||||||
expect(misc.getFolderLevels(item)).to.deep.equal(res);
|
assert.deepEqual(misc.getFolderLevels(item), res);
|
||||||
|
|
||||||
const item2 = "xxx/yyy/zzz";
|
const item2 = "xxx/yyy/zzz";
|
||||||
const res2 = ["xxx", "xxx/yyy"];
|
const res2 = ["xxx", "xxx/yyy"];
|
||||||
expect(misc.getFolderLevels(item2)).to.deep.equal(res2);
|
assert.deepEqual(misc.getFolderLevels(item2), res2);
|
||||||
|
|
||||||
const item3 = "xxx/yyy/zzz/";
|
const item3 = "xxx/yyy/zzz/";
|
||||||
const res3 = ["xxx", "xxx/yyy", "xxx/yyy/zzz"];
|
const res3 = ["xxx", "xxx/yyy", "xxx/yyy/zzz"];
|
||||||
expect(misc.getFolderLevels(item3)).to.deep.equal(res3);
|
assert.deepEqual(misc.getFolderLevels(item3), res3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should correctly add ending slash if required", () => {
|
it("should correctly add ending slash if required", () => {
|
||||||
const item = "xxx/yyy/zzz.md";
|
const item = "xxx/yyy/zzz.md";
|
||||||
const res = ["xxx/", "xxx/yyy/"];
|
const res = ["xxx/", "xxx/yyy/"];
|
||||||
expect(misc.getFolderLevels(item, true)).to.deep.equal(res);
|
assert.deepEqual(misc.getFolderLevels(item, true), res);
|
||||||
|
|
||||||
const item2 = "xxx/yyy/zzz";
|
const item2 = "xxx/yyy/zzz";
|
||||||
const res2 = ["xxx/", "xxx/yyy/"];
|
const res2 = ["xxx/", "xxx/yyy/"];
|
||||||
expect(misc.getFolderLevels(item2, true)).to.deep.equal(res2);
|
assert.deepEqual(misc.getFolderLevels(item2, true), res2);
|
||||||
|
|
||||||
const item3 = "xxx/yyy/zzz/";
|
const item3 = "xxx/yyy/zzz/";
|
||||||
const res3 = ["xxx/", "xxx/yyy/", "xxx/yyy/zzz/"];
|
const res3 = ["xxx/", "xxx/yyy/", "xxx/yyy/zzz/"];
|
||||||
expect(misc.getFolderLevels(item3, true)).to.deep.equal(res3);
|
assert.deepEqual(misc.getFolderLevels(item3, true), res3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should treat path starting with / correctly", () => {
|
it("should treat path starting with / correctly", () => {
|
||||||
const item = "/xxx/yyy/zzz.md";
|
const item = "/xxx/yyy/zzz.md";
|
||||||
const res = ["/xxx", "/xxx/yyy"];
|
const res = ["/xxx", "/xxx/yyy"];
|
||||||
expect(misc.getFolderLevels(item)).to.deep.equal(res);
|
assert.deepEqual(misc.getFolderLevels(item), res);
|
||||||
|
|
||||||
const item2 = "/xxx/yyy/zzz";
|
const item2 = "/xxx/yyy/zzz";
|
||||||
const res2 = ["/xxx", "/xxx/yyy"];
|
const res2 = ["/xxx", "/xxx/yyy"];
|
||||||
expect(misc.getFolderLevels(item2)).to.deep.equal(res2);
|
assert.deepEqual(misc.getFolderLevels(item2), res2);
|
||||||
|
|
||||||
const item3 = "/xxx/yyy/zzz/";
|
const item3 = "/xxx/yyy/zzz/";
|
||||||
const res3 = ["/xxx", "/xxx/yyy", "/xxx/yyy/zzz"];
|
const res3 = ["/xxx", "/xxx/yyy", "/xxx/yyy/zzz"];
|
||||||
expect(misc.getFolderLevels(item3)).to.deep.equal(res3);
|
assert.deepEqual(misc.getFolderLevels(item3), res3);
|
||||||
|
|
||||||
const item4 = "/xxx";
|
const item4 = "/xxx";
|
||||||
const res4 = [] as string[];
|
const res4 = [] as string[];
|
||||||
expect(misc.getFolderLevels(item4)).to.deep.equal(res4);
|
assert.deepEqual(misc.getFolderLevels(item4), res4);
|
||||||
|
|
||||||
const item5 = "/";
|
const item5 = "/";
|
||||||
const res5 = [] as string[];
|
const res5 = [] as string[];
|
||||||
expect(misc.getFolderLevels(item5)).to.deep.equal(res5);
|
assert.deepEqual(misc.getFolderLevels(item5), res5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Misc: get parent folder", () => {
|
describe("Misc: get parent folder", () => {
|
||||||
it("should treat empty path correctly", () => {
|
it("should treat empty path correctly", () => {
|
||||||
const item = "";
|
const item = "";
|
||||||
expect(misc.getParentFolder(item)).equals("/");
|
assert.equal(misc.getParentFolder(item), "/");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should treat one level path correctly", () => {
|
it("should treat one level path correctly", () => {
|
||||||
let item = "abc/";
|
let item = "abc/";
|
||||||
expect(misc.getParentFolder(item)).equals("/");
|
assert.equal(misc.getParentFolder(item), "/");
|
||||||
item = "/efg/";
|
item = "/efg/";
|
||||||
expect(misc.getParentFolder(item)).equals("/");
|
assert.equal(misc.getParentFolder(item), "/");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should treat more levels path correctly", () => {
|
it("should treat more levels path correctly", () => {
|
||||||
let item = "abc/efg";
|
let item = "abc/efg";
|
||||||
expect(misc.getParentFolder(item)).equals("abc/");
|
assert.equal(misc.getParentFolder(item), "abc/");
|
||||||
item = "/hij/klm/";
|
item = "/hij/klm/";
|
||||||
expect(misc.getParentFolder(item)).equals("/hij/");
|
assert.equal(misc.getParentFolder(item), "/hij/");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,18 +141,18 @@ describe("Misc: vaild file name tests", () => {
|
|||||||
it("should treat no ascii correctly", async () => {
|
it("should treat no ascii correctly", async () => {
|
||||||
const x = misc.isVaildText("😄🍎 apple 苹果");
|
const x = misc.isVaildText("😄🍎 apple 苹果");
|
||||||
// console.log(x)
|
// console.log(x)
|
||||||
expect(x).to.be.true;
|
assert.ok(x);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should find not-printable chars correctly", async () => {
|
it("should find not-printable chars correctly", async () => {
|
||||||
const x = misc.isVaildText("😄🍎 apple 苹果\u0000");
|
const x = misc.isVaildText("😄🍎 apple 苹果\u0000");
|
||||||
// console.log(x)
|
// console.log(x)
|
||||||
expect(x).to.be.false;
|
assert.ok(!x);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should allow spaces/slashes/...", async () => {
|
it("should allow spaces/slashes/...", async () => {
|
||||||
const x = misc.isVaildText("😄🍎 apple 苹果/-_=/\\*%^&@#$`");
|
const x = misc.isVaildText("😄🍎 apple 苹果/-_=/\\*%^&@#$`");
|
||||||
expect(x).to.be.true;
|
assert.ok(x);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,21 +160,21 @@ describe("Misc: get dirname", () => {
|
|||||||
it("should return itself for folder", async () => {
|
it("should return itself for folder", async () => {
|
||||||
const x = misc.getPathFolder("ssss/");
|
const x = misc.getPathFolder("ssss/");
|
||||||
// console.log(x)
|
// console.log(x)
|
||||||
expect(x).to.equal("ssss/");
|
assert.equal(x, "ssss/");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return folder for file", async () => {
|
it("should return folder for file", async () => {
|
||||||
const x = misc.getPathFolder("sss/yyy");
|
const x = misc.getPathFolder("sss/yyy");
|
||||||
// console.log(x)
|
// console.log(x)
|
||||||
expect(x).to.equal("sss/");
|
assert.equal(x, "sss/");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should treat / specially", async () => {
|
it("should treat / specially", async () => {
|
||||||
const x = misc.getPathFolder("/");
|
const x = misc.getPathFolder("/");
|
||||||
expect(x).to.equal("/");
|
assert.equal(x, "/");
|
||||||
|
|
||||||
const y = misc.getPathFolder("/abc");
|
const y = misc.getPathFolder("/abc");
|
||||||
expect(y).to.equal("/");
|
assert.equal(y, "/");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,7 +188,7 @@ describe("Misc: extract svg", () => {
|
|||||||
const x = "<svg><rect/><g/></svg>";
|
const x = "<svg><rect/><g/></svg>";
|
||||||
const y = misc.extractSvgSub(x);
|
const y = misc.extractSvgSub(x);
|
||||||
// console.log(x)
|
// console.log(x)
|
||||||
expect(y).to.equal("<rect/><g/>");
|
assert.equal(y, "<rect/><g/>");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -202,7 +202,7 @@ describe("Misc: get split ranges", () => {
|
|||||||
end: 10,
|
end: 10,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
expect(k).to.deep.equal(k2);
|
assert.deepEqual(k, k2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should deal with 0 remainder", () => {
|
it("should deal with 0 remainder", () => {
|
||||||
@@ -219,7 +219,7 @@ describe("Misc: get split ranges", () => {
|
|||||||
end: 20,
|
end: 20,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
expect(k).to.deep.equal(k2);
|
assert.deepEqual(k, k2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should deal with not-0 remainder", () => {
|
it("should deal with not-0 remainder", () => {
|
||||||
@@ -241,47 +241,100 @@ describe("Misc: get split ranges", () => {
|
|||||||
end: 25,
|
end: 25,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
expect(k).to.deep.equal(k2);
|
assert.deepEqual(k, k2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Misc: at which level", () => {
|
describe("Misc: at which level", () => {
|
||||||
it("should throw error on some parameters", () => {
|
it("should throw error on some parameters", () => {
|
||||||
expect(() => misc.atWhichLevel(undefined)).to.throw();
|
assert.throws(() => misc.atWhichLevel(undefined));
|
||||||
expect(() => misc.atWhichLevel("")).to.throw();
|
assert.throws(() => misc.atWhichLevel(""));
|
||||||
expect(() => misc.atWhichLevel("..")).to.throw();
|
assert.throws(() => misc.atWhichLevel(".."));
|
||||||
expect(() => misc.atWhichLevel(".")).to.throw();
|
assert.throws(() => misc.atWhichLevel("."));
|
||||||
expect(() => misc.atWhichLevel("/")).to.throw();
|
assert.throws(() => misc.atWhichLevel("/"));
|
||||||
expect(() => misc.atWhichLevel("/xxyy")).to.throw();
|
assert.throws(() => misc.atWhichLevel("/xxyy"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should treat folders correctly", () => {
|
it("should treat folders correctly", () => {
|
||||||
expect(misc.atWhichLevel("x/")).to.be.equal(1);
|
assert.equal(misc.atWhichLevel("x/"), 1);
|
||||||
expect(misc.atWhichLevel("x/y/")).to.be.equal(2);
|
assert.equal(misc.atWhichLevel("x/y/"), 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should treat files correctly", () => {
|
it("should treat files correctly", () => {
|
||||||
expect(misc.atWhichLevel("x.md")).to.be.equal(1);
|
assert.equal(misc.atWhichLevel("x.md"), 1);
|
||||||
expect(misc.atWhichLevel("x/y.md")).to.be.equal(2);
|
assert.equal(misc.atWhichLevel("x/y.md"), 2);
|
||||||
expect(misc.atWhichLevel("x/y/z.md")).to.be.equal(3);
|
assert.equal(misc.atWhichLevel("x/y/z.md"), 3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Misc: special char for dir", () => {
|
describe("Misc: special char for dir", () => {
|
||||||
it("should return false for normal string", () => {
|
it("should return false for normal string", () => {
|
||||||
expect(misc.checkHasSpecialCharForDir("")).to.be.false;
|
assert.ok(!misc.checkHasSpecialCharForDir(""));
|
||||||
expect(misc.checkHasSpecialCharForDir("xxx")).to.be.false;
|
assert.ok(!misc.checkHasSpecialCharForDir("xxx"));
|
||||||
expect(misc.checkHasSpecialCharForDir("yyy_xxx")).to.be.false;
|
assert.ok(!misc.checkHasSpecialCharForDir("yyy_xxx"));
|
||||||
expect(misc.checkHasSpecialCharForDir("yyy.xxx")).to.be.false;
|
assert.ok(!misc.checkHasSpecialCharForDir("yyy.xxx"));
|
||||||
expect(misc.checkHasSpecialCharForDir("yyy?xxx")).to.be.false;
|
assert.ok(!misc.checkHasSpecialCharForDir("yyy?xxx"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return true for special cases", () => {
|
it("should return true for special cases", () => {
|
||||||
expect(misc.checkHasSpecialCharForDir("?")).to.be.true;
|
assert.ok(misc.checkHasSpecialCharForDir("?"));
|
||||||
expect(misc.checkHasSpecialCharForDir("/")).to.be.true;
|
assert.ok(misc.checkHasSpecialCharForDir("/"));
|
||||||
expect(misc.checkHasSpecialCharForDir("\\")).to.be.true;
|
assert.ok(misc.checkHasSpecialCharForDir("\\"));
|
||||||
expect(misc.checkHasSpecialCharForDir("xxx/yyy")).to.be.true;
|
assert.ok(misc.checkHasSpecialCharForDir("xxx/yyy"));
|
||||||
expect(misc.checkHasSpecialCharForDir("xxx\\yyy")).to.be.true;
|
assert.ok(misc.checkHasSpecialCharForDir("xxx\\yyy"));
|
||||||
expect(misc.checkHasSpecialCharForDir("xxx?yyy")).to.be.true;
|
assert.ok(misc.checkHasSpecialCharForDir("xxx?yyy"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Misc: Dropbox: should fix the folder name cases", () => {
|
||||||
|
it("should do nothing on empty folders", () => {
|
||||||
|
const input: any[] = [];
|
||||||
|
assert.equal(misc.fixEntityListCasesInplace(input).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should sort folders by length by side effect", () => {
|
||||||
|
const input = [
|
||||||
|
{ keyRaw: "aaaa/" },
|
||||||
|
{ keyRaw: "bbb/" },
|
||||||
|
{ keyRaw: "c/" },
|
||||||
|
{ keyRaw: "dd/" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const output = [
|
||||||
|
{ keyRaw: "c/" },
|
||||||
|
{ keyRaw: "dd/" },
|
||||||
|
{ keyRaw: "bbb/" },
|
||||||
|
{ keyRaw: "aaaa/" },
|
||||||
|
];
|
||||||
|
assert.deepEqual(misc.fixEntityListCasesInplace(input), output);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fix folder names", () => {
|
||||||
|
const input = [
|
||||||
|
{ keyRaw: "AAA/" },
|
||||||
|
{ keyRaw: "aaa/bbb/CCC.md" },
|
||||||
|
{ keyRaw: "aaa/BBB/" },
|
||||||
|
|
||||||
|
{ keyRaw: "ddd/" },
|
||||||
|
{ keyRaw: "DDD/EEE/fff.md" },
|
||||||
|
{ keyRaw: "DDD/eee/" },
|
||||||
|
|
||||||
|
{ keyRaw: "Ggg/" },
|
||||||
|
{ keyRaw: "ggG/hHH你好/Fff世界.md" },
|
||||||
|
{ keyRaw: "ggG/Hhh你好/" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const output = [
|
||||||
|
{ keyRaw: "AAA/" },
|
||||||
|
{ keyRaw: "ddd/" },
|
||||||
|
{ keyRaw: "Ggg/" },
|
||||||
|
{ keyRaw: "AAA/BBB/" },
|
||||||
|
{ keyRaw: "ddd/eee/" },
|
||||||
|
{ keyRaw: "Ggg/Hhh你好/" },
|
||||||
|
{ keyRaw: "AAA/BBB/CCC.md" },
|
||||||
|
{ keyRaw: "ddd/eee/fff.md" },
|
||||||
|
{ keyRaw: "Ggg/Hhh你好/Fff世界.md" },
|
||||||
|
];
|
||||||
|
assert.deepEqual(misc.fixEntityListCasesInplace(input), output);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import { expect } from "chai";
|
|
||||||
import type { WebdavConfig } from "../src/baseTypes";
|
|
||||||
import { applyWebdavPresetRulesInplace } from "../src/presetRules";
|
|
||||||
|
|
||||||
describe("Preset rules tests", () => {
|
|
||||||
it("should check undefined correctly", () => {
|
|
||||||
let x: Partial<WebdavConfig> | undefined = undefined;
|
|
||||||
const y = applyWebdavPresetRulesInplace(x);
|
|
||||||
expect(y.webdav === undefined);
|
|
||||||
expect(!y.changed);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should check empty object", () => {
|
|
||||||
let x: Partial<WebdavConfig> | undefined = {};
|
|
||||||
const y = applyWebdavPresetRulesInplace(x);
|
|
||||||
expect(y.webdav).deep.equals({});
|
|
||||||
expect(!y.changed);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should modify depths correctly", () => {
|
|
||||||
let x: Partial<WebdavConfig> = {
|
|
||||||
address: "https://example.teracloud.jp/dav/",
|
|
||||||
depth: "auto_unknown",
|
|
||||||
};
|
|
||||||
let y = applyWebdavPresetRulesInplace(x);
|
|
||||||
expect(x.depth === "auto_1");
|
|
||||||
expect(y.changed);
|
|
||||||
|
|
||||||
x = {
|
|
||||||
address: "https://example.teracloud.jp/dav/example",
|
|
||||||
depth: "auto_unknown",
|
|
||||||
};
|
|
||||||
y = applyWebdavPresetRulesInplace(x);
|
|
||||||
expect(x.depth === "auto_1");
|
|
||||||
expect(y.changed);
|
|
||||||
|
|
||||||
x = {
|
|
||||||
address: "https://dav.jianguoyun.com/dav/",
|
|
||||||
depth: "auto_unknown",
|
|
||||||
};
|
|
||||||
y = applyWebdavPresetRulesInplace(x);
|
|
||||||
expect(x.depth === "auto_1");
|
|
||||||
expect(y.changed);
|
|
||||||
|
|
||||||
x = {
|
|
||||||
address: "https://dav.jianguoyun.com/dav/",
|
|
||||||
depth: "auto_infinity",
|
|
||||||
};
|
|
||||||
y = applyWebdavPresetRulesInplace(x);
|
|
||||||
expect(x.depth === "auto_1");
|
|
||||||
expect(y.changed);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not modify depths if depths is set automatically correctly", () => {
|
|
||||||
let x: Partial<WebdavConfig> = {
|
|
||||||
address: "https://dav.jianguoyun.com/dav/",
|
|
||||||
depth: "auto_1",
|
|
||||||
};
|
|
||||||
let y = applyWebdavPresetRulesInplace(x);
|
|
||||||
expect(x.depth === "auto_1");
|
|
||||||
expect(!y.changed);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not modify depths if depths have been set manually", () => {
|
|
||||||
let x: Partial<WebdavConfig> = {
|
|
||||||
address: "https://example.teracloud.jp/dav/",
|
|
||||||
depth: "manual_infinity",
|
|
||||||
};
|
|
||||||
let y = applyWebdavPresetRulesInplace(x);
|
|
||||||
expect(x.depth === "manual_infinity");
|
|
||||||
expect(!y.changed);
|
|
||||||
|
|
||||||
x = {
|
|
||||||
address: "https://example.teracloud.jp/dav/example",
|
|
||||||
depth: "manual_1",
|
|
||||||
};
|
|
||||||
y = applyWebdavPresetRulesInplace(x);
|
|
||||||
expect(x.depth === "manual_1");
|
|
||||||
expect(!y.changed);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not modify depths when urls are not in preset rules", () => {
|
|
||||||
let x: Partial<WebdavConfig> = {
|
|
||||||
address: "https://teracloud.jp/dav/",
|
|
||||||
depth: "auto_unknown",
|
|
||||||
};
|
|
||||||
applyWebdavPresetRulesInplace(x);
|
|
||||||
expect(x.depth === "auto_unknown");
|
|
||||||
|
|
||||||
x = {
|
|
||||||
address: "https://dav.jianguoyun.com/dav_example",
|
|
||||||
depth: "auto_unknown",
|
|
||||||
};
|
|
||||||
applyWebdavPresetRulesInplace(x);
|
|
||||||
expect(x.depth === "auto_unknown");
|
|
||||||
|
|
||||||
x = {
|
|
||||||
address: "",
|
|
||||||
depth: "auto_unknown",
|
|
||||||
};
|
|
||||||
applyWebdavPresetRulesInplace(x);
|
|
||||||
expect(x.depth === "auto_unknown");
|
|
||||||
|
|
||||||
x = {
|
|
||||||
address: "https://dav.jianguoyun.com/dav/",
|
|
||||||
depth: "what" as any,
|
|
||||||
};
|
|
||||||
applyWebdavPresetRulesInplace(x);
|
|
||||||
expect(x.depth === ("what" as any));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user