Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,6 +62,9 @@ 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)
|
||||||
- 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).
|
||||||
@@ -72,6 +74,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 +82,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 +91,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>.
|
||||||
@@ -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
|
||||||
@@ -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 = `/*
|
||||||
@@ -54,6 +55,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.12",
|
||||||
"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.12",
|
||||||
"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",
|
||||||
|
|||||||
+8
-7
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "remotely-save",
|
"name": "remotely-save",
|
||||||
"version": "0.3.25",
|
"version": "0.4.12",
|
||||||
"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",
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"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.14.202",
|
||||||
@@ -34,13 +34,14 @@
|
|||||||
"@types/node": "^20.10.4",
|
"@types/node": "^20.10.4",
|
||||||
"@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": "^4.4.1",
|
||||||
"chai-as-promised": "^7.1.1",
|
"chai-as-promised": "^7.1.1",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"esbuild": "^0.19.9",
|
"esbuild": "^0.19.9",
|
||||||
|
"esbuild-plugin-inline-worker": "^0.1.1",
|
||||||
"jsdom": "^23.0.1",
|
"jsdom": "^23.0.1",
|
||||||
"mocha": "^10.2.0",
|
"mocha": "^10.4.0",
|
||||||
"npm-check-updates": "^16.14.12",
|
"npm-check-updates": "^16.14.12",
|
||||||
"obsidian": "^1.4.11",
|
"obsidian": "^1.4.11",
|
||||||
"prettier": "^3.1.1",
|
"prettier": "^3.1.1",
|
||||||
@@ -50,7 +51,8 @@
|
|||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"webdav-server": "^2.6.2",
|
"webdav-server": "^2.6.2",
|
||||||
"webpack": "^5.89.0",
|
"webpack": "^5.89.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.474.0",
|
||||||
@@ -58,6 +60,7 @@
|
|||||||
"@aws-sdk/signature-v4-crt": "^3.474.0",
|
"@aws-sdk/signature-v4-crt": "^3.474.0",
|
||||||
"@aws-sdk/types": "^3.468.0",
|
"@aws-sdk/types": "^3.468.0",
|
||||||
"@azure/msal-node": "^2.6.0",
|
"@azure/msal-node": "^2.6.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.3.1",
|
||||||
@@ -75,7 +78,6 @@
|
|||||||
"http-status-codes": "^2.3.0",
|
"http-status-codes": "^2.3.0",
|
||||||
"localforage": "^1.10.0",
|
"localforage": "^1.10.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"loglevel": "^1.8.1",
|
|
||||||
"lucide": "^0.298.0",
|
"lucide": "^0.298.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
@@ -90,7 +92,6 @@
|
|||||||
"url": "^0.11.3",
|
"url": "^0.11.3",
|
||||||
"util": "^0.12.5",
|
"util": "^0.12.5",
|
||||||
"webdav": "^5.3.1",
|
"webdav": "^5.3.1",
|
||||||
"webdav-fs": "^4.0.1",
|
|
||||||
"xregexp": "^5.1.1"
|
"xregexp": "^5.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+122
-37
@@ -21,10 +21,17 @@ 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
bypassCorsLocally?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DropboxConfig {
|
export interface DropboxConfig {
|
||||||
@@ -41,9 +48,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 +60,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 +83,13 @@ 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 interface RemotelySavePluginSettings {
|
export interface RemotelySavePluginSettings {
|
||||||
s3: S3Config;
|
s3: S3Config;
|
||||||
webdav: WebdavConfig;
|
webdav: WebdavConfig;
|
||||||
@@ -81,29 +101,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 +154,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 +245,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 +259,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)) ||
|
||||||
|
|||||||
+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;
|
||||||
};
|
};
|
||||||
|
|||||||
+7
-44
@@ -1,10 +1,7 @@
|
|||||||
import { TAbstractFile, TFolder, TFile, Vault } from "obsidian";
|
import { TAbstractFile, TFolder, TFile, Vault } from "obsidian";
|
||||||
|
|
||||||
import type { SyncPlanType } from "./sync";
|
import type { SyncPlanType } from "./sync";
|
||||||
import {
|
import { readAllSyncPlanRecordTextsByVault } from "./localdb";
|
||||||
readAllSyncPlanRecordTextsByVault,
|
|
||||||
readAllLogRecordTextsByVault,
|
|
||||||
} from "./localdb";
|
|
||||||
import type { InternalDBs } from "./localdb";
|
import type { InternalDBs } from "./localdb";
|
||||||
import { mkdirpInVault } from "./misc";
|
import { mkdirpInVault } from "./misc";
|
||||||
import {
|
import {
|
||||||
@@ -14,8 +11,6 @@ import {
|
|||||||
FileOrFolderMixedState,
|
FileOrFolderMixedState,
|
||||||
} from "./baseTypes";
|
} from "./baseTypes";
|
||||||
|
|
||||||
import { log } from "./moreOnLog";
|
|
||||||
|
|
||||||
const turnSyncPlanToTable = (record: string) => {
|
const turnSyncPlanToTable = (record: string) => {
|
||||||
const syncPlan: SyncPlanType = JSON.parse(record);
|
const syncPlan: SyncPlanType = JSON.parse(record);
|
||||||
const { ts, tsFmt, remoteType, mixedStates } = syncPlan;
|
const { ts, tsFmt, remoteType, mixedStates } = syncPlan;
|
||||||
@@ -78,55 +73,23 @@ const turnSyncPlanToTable = (record: string) => {
|
|||||||
export const exportVaultSyncPlansToFiles = async (
|
export const exportVaultSyncPlansToFiles = async (
|
||||||
db: InternalDBs,
|
db: InternalDBs,
|
||||||
vault: Vault,
|
vault: Vault,
|
||||||
vaultRandomID: string,
|
vaultRandomID: string
|
||||||
toFormat: "table" | "json" = "json"
|
|
||||||
) => {
|
) => {
|
||||||
log.info("exporting");
|
console.info("exporting");
|
||||||
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") {
|
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 {
|
|
||||||
const _: never = toFormat;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
const filePath = `${DEFAULT_DEBUG_FOLDER}${DEFAULT_SYNC_PLANS_HISTORY_FILE_PREFIX}${ts}.md`;
|
const filePath = `${DEFAULT_DEBUG_FOLDER}${DEFAULT_SYNC_PLANS_HISTORY_FILE_PREFIX}${ts}.md`;
|
||||||
await vault.create(filePath, md, {
|
await vault.create(filePath, md, {
|
||||||
mtime: ts,
|
mtime: ts,
|
||||||
});
|
});
|
||||||
log.info("finish exporting");
|
console.info("finish exporting");
|
||||||
};
|
|
||||||
|
|
||||||
export const exportVaultLoggerOutputToFiles = async (
|
|
||||||
db: InternalDBs,
|
|
||||||
vault: Vault,
|
|
||||||
vaultRandomID: string
|
|
||||||
) => {
|
|
||||||
await mkdirpInVault(DEFAULT_DEBUG_FOLDER, vault);
|
|
||||||
const records = await readAllLogRecordTextsByVault(db, vaultRandomID);
|
|
||||||
let md = "";
|
|
||||||
if (records.length === 0) {
|
|
||||||
md = "No logger history found.";
|
|
||||||
} else {
|
|
||||||
md =
|
|
||||||
"Logger history found:\n\n" +
|
|
||||||
"```text\n" +
|
|
||||||
records.join("\n") +
|
|
||||||
"\n```\n";
|
|
||||||
}
|
|
||||||
const ts = Date.now();
|
|
||||||
const filePath = `${DEFAULT_DEBUG_FOLDER}${DEFAULT_LOG_HISTORY_FILE_PREFIX}${ts}.md`;
|
|
||||||
await vault.create(filePath, md, {
|
|
||||||
mtime: ts,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,215 @@
|
|||||||
|
import { CipherMethodType } from "./baseTypes";
|
||||||
|
import * as openssl from "./encryptOpenSSL";
|
||||||
|
import * as rclone from "./encryptRClone";
|
||||||
|
import { isVaildText } from "./misc";
|
||||||
|
|
||||||
|
export class Cipher {
|
||||||
|
readonly password: string;
|
||||||
|
readonly method: CipherMethodType;
|
||||||
|
cipherRClone?: rclone.CipherRclone;
|
||||||
|
constructor(password: string, method: CipherMethodType) {
|
||||||
|
this.password = password ?? "";
|
||||||
|
this.method = method;
|
||||||
|
|
||||||
|
if (method === "rclone-base64") {
|
||||||
|
this.cipherRClone = new rclone.CipherRclone(password, 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeResources() {
|
||||||
|
if (this.method === "rclone-base64" && this.cipherRClone !== undefined) {
|
||||||
|
this.cipherRClone.closeResources();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* quick guess, no actual decryption here
|
||||||
|
* @param name
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
static 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
|
||||||
|
*/
|
||||||
|
static isLikelyEncryptedName(name: string): boolean {
|
||||||
|
return Cipher.isLikelyOpenSSLEncryptedName(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* quick guess, no actual decryption here, only openssl can be guessed here
|
||||||
|
* @param name
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
static isLikelyEncryptedNameNotMatchMethod(
|
||||||
|
name: string,
|
||||||
|
method: CipherMethodType
|
||||||
|
): boolean {
|
||||||
|
if (
|
||||||
|
Cipher.isLikelyOpenSSLEncryptedName(name) &&
|
||||||
|
method !== "openssl-base64"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Cipher.isLikelyOpenSSLEncryptedName(name) &&
|
||||||
|
method === "openssl-base64"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-4
@@ -7,14 +7,12 @@ import {
|
|||||||
RemotelySavePluginSettings,
|
RemotelySavePluginSettings,
|
||||||
} from "./baseTypes";
|
} from "./baseTypes";
|
||||||
|
|
||||||
import { log } from "./moreOnLog";
|
|
||||||
|
|
||||||
export const exportQrCodeUri = async (
|
export const exportQrCodeUri = async (
|
||||||
settings: RemotelySavePluginSettings,
|
settings: RemotelySavePluginSettings,
|
||||||
currentVaultName: string,
|
currentVaultName: string,
|
||||||
pluginVersion: string
|
pluginVersion: string
|
||||||
) => {
|
) => {
|
||||||
const settings2 = cloneDeep(settings);
|
const settings2: Partial<RemotelySavePluginSettings> = cloneDeep(settings);
|
||||||
delete settings2.dropbox;
|
delete settings2.dropbox;
|
||||||
delete settings2.onedrive;
|
delete settings2.onedrive;
|
||||||
delete settings2.vaultRandomID;
|
delete settings2.vaultRandomID;
|
||||||
@@ -22,7 +20,7 @@ export const exportQrCodeUri = async (
|
|||||||
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,
|
||||||
|
|||||||
+73
-56
@@ -7,29 +7,31 @@
|
|||||||
"sometext": "Here are some texts.",
|
"sometext": "Here are some texts.",
|
||||||
"syncrun_alreadyrunning": "{{pluginName}} already running in stage {{syncStatus}}!",
|
"syncrun_alreadyrunning": "{{pluginName}} 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 not-oauth2 settings for {{manifestName}} is 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?",
|
||||||
@@ -37,17 +39,18 @@
|
|||||||
"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_json": "export sync plans in json format",
|
||||||
"command_exportsyncplans_table": "export sync plans in table format",
|
|
||||||
"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",
|
||||||
@@ -61,6 +64,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.",
|
||||||
@@ -104,13 +109,14 @@
|
|||||||
"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)",
|
||||||
@@ -163,7 +169,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,6 +179,8 @@
|
|||||||
"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_connect_succ": "Great! The bucket can be accessed.",
|
"settings_s3_connect_succ": "Great! The bucket can be accessed.",
|
||||||
@@ -222,8 +230,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,6 +253,26 @@
|
|||||||
"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 not-oauth2 settings by generating a qrcode.",
|
||||||
@@ -253,38 +280,28 @@
|
|||||||
"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": "You should open a camera or scan-qrcode app, to manually scan the QR code.",
|
||||||
"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_json": "Export",
|
||||||
"settings_syncplans_button_table": "Export (table)",
|
|
||||||
"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_logtodbexport_notice": "Console logs of this vault from DB are exported!",
|
|
||||||
"settings_logtodbclear": "Delete All Console Logs In DB",
|
|
||||||
"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_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 +309,10 @@
|
|||||||
"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",
|
"syncalgov3_title": "Remotely Save has HUGE updates on the 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.",
|
"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!",
|
||||||
"syncalgov2_button_agree": "Agree",
|
"syncalgov3_checkbox_manual_backup": "I will backup my vault manually firstly.",
|
||||||
"syncalgov2_button_disagree": "Do Not Agree",
|
"syncalgov3_checkbox_requiremultidevupdate": "I understand I need to update the plugin ACROSS ALL DEVICES to make them work properly.",
|
||||||
|
"syncalgov3_button_agree": "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_button_disagree": "Do Not Agree"
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-47
@@ -12,8 +12,8 @@
|
|||||||
"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}}",
|
||||||
|
"syncrun_abort_protectmodifypercentage": "中断同步!您设置了不允许 >= {{protectModifyPercentage}}% 的变更,但是现在 {{realModifyDeleteCount}}/{{allFilesCount}}={{percent}}% 的文件会被修改或删除!如果您确认这次同步是您想要的,那么请在设置里修改允许比例。",
|
||||||
"protocol_saveqr": " {{manifestName}} 新的非 oauth2 设置保存完成。请重启插件设置页使之生效。",
|
"protocol_saveqr": " {{manifestName}} 新的非 oauth2 设置保存完成。请重启插件设置页使之生效。",
|
||||||
"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,17 +39,18 @@
|
|||||||
"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_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": "没触发过同步",
|
||||||
@@ -61,6 +64,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": "您所输入的内容含有某些特殊字符,如“?”、“/”、“\\”,它们是不允许的。",
|
||||||
@@ -104,13 +109,13 @@
|
|||||||
"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": "(不设置)",
|
||||||
@@ -163,7 +168,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,6 +178,8 @@
|
|||||||
"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_connect_succ": "很好!可以访问到对应存储桶。",
|
"settings_s3_connect_succ": "很好!可以访问到对应存储桶。",
|
||||||
@@ -222,8 +229,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,6 +252,26 @@
|
|||||||
"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 码导出非 oauth2 的设置信息。",
|
||||||
@@ -253,38 +279,28 @@
|
|||||||
"settings_import": "导入",
|
"settings_import": "导入",
|
||||||
"settings_import_desc": "您需要使用系统拍摄 app 或者扫描 QR 码的app,来扫描对应的 QR 码。",
|
"settings_import_desc": "您需要使用系统拍摄 app 或者扫描 QR 码的app,来扫描对应的 QR 码。",
|
||||||
"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_json": "导出",
|
||||||
"settings_syncplans_button_table": "导出(表格)",
|
|
||||||
"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_logtodbexport_notice": "数据库内的本资料库的终端日志已导出!",
|
|
||||||
"settings_logtodbclear": "删除数据库的所有终端日志",
|
|
||||||
"settings_logtodbclear_desc": "删除数据库内的所有已保存的终端日志。本设置会影响本设备下所有资料库的所有已保存日志。",
|
|
||||||
"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 +308,10 @@
|
|||||||
"settings_resetcache_desc": "(出于调试原因)重设本地缓存和数据库。您需要在重设之后重新载入此插件。本重设不会删除 s3,密码……等设定。",
|
"settings_resetcache_desc": "(出于调试原因)重设本地缓存和数据库。您需要在重设之后重新载入此插件。本重设不会删除 s3,密码……等设定。",
|
||||||
"settings_resetcache_button": "重设",
|
"settings_resetcache_button": "重设",
|
||||||
"settings_resetcache_notice": "本地同步缓存和数据库已被删除。请手动重新载入此插件。",
|
"settings_resetcache_notice": "本地同步缓存和数据库已被删除。请手动重新载入此插件。",
|
||||||
"syncalgov2_title": "Remotely Save 的同步算法得到优化",
|
"syncalgov3_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_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_agree": "同意",
|
"syncalgov3_checkbox_manual_backup": "我将会首先手动备份我的库(Vault)。",
|
||||||
"syncalgov2_button_disagree": "不同意",
|
"syncalgov3_checkbox_requiremultidevupdate": "我理解,我需要在所有设备上都更新此插件使之正常运行。",
|
||||||
|
"syncalgov3_button_agree": "同意",
|
||||||
"official_notice_2024_first_party": "插件 Remotely-Save 回来了,更新了一大堆功能!🎉🎉🎉请自行使用,或参阅更新文档: https://github.com/remotely-save/remotely-save/releases 。"
|
"syncalgov3_button_disagree": "不同意"
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-46
@@ -12,8 +12,8 @@
|
|||||||
"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}}",
|
||||||
|
"syncrun_abort_protectmodifypercentage": "中斷同步!您設定了不允許 >= {{protectModifyPercentage}}% 的變更,但是現在 {{realModifyDeleteCount}}/{{allFilesCount}}={{percent}}% 的檔案會被修改或刪除!如果您確認這次同步是您想要的,那麼請在設定裡修改允許比例。",
|
||||||
"protocol_saveqr": " {{manifestName}} 新的非 oauth2 設定儲存完成。請重啟外掛設定頁使之生效。",
|
"protocol_saveqr": " {{manifestName}} 新的非 oauth2 設定儲存完成。請重啟外掛設定頁使之生效。",
|
||||||
"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,17 +39,18 @@
|
|||||||
"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_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": "沒觸發過同步",
|
||||||
@@ -61,6 +64,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": "您所輸入的內容含有某些特殊字元,如“?”、“/”、“\\”,它們是不允許的。",
|
||||||
@@ -104,13 +109,13 @@
|
|||||||
"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": "(不設定)",
|
||||||
@@ -163,7 +168,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,6 +178,8 @@
|
|||||||
"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_connect_succ": "很好!可以訪問到對應儲存桶。",
|
"settings_s3_connect_succ": "很好!可以訪問到對應儲存桶。",
|
||||||
@@ -222,8 +229,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,6 +252,26 @@
|
|||||||
"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 碼匯出非 oauth2 的設定資訊。",
|
||||||
@@ -253,38 +279,28 @@
|
|||||||
"settings_import": "匯入",
|
"settings_import": "匯入",
|
||||||
"settings_import_desc": "您需要使用系統拍攝 app 或者掃描 QR 碼的app,來掃描對應的 QR 碼。",
|
"settings_import_desc": "您需要使用系統拍攝 app 或者掃描 QR 碼的app,來掃描對應的 QR 碼。",
|
||||||
"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_json": "匯出",
|
||||||
"settings_syncplans_button_table": "匯出(表格)",
|
|
||||||
"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_logtodbexport_notice": "資料庫內的本資料庫的終端日誌已匯出!",
|
|
||||||
"settings_logtodbclear": "刪除資料庫的所有終端日誌",
|
|
||||||
"settings_logtodbclear_desc": "刪除資料庫內的所有已儲存的終端日誌。本設定會影響本裝置下所有資料庫的所有已儲存日誌。",
|
|
||||||
"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 +308,10 @@
|
|||||||
"settings_resetcache_desc": "(出於除錯原因)重設本地快取和資料庫。您需要在重設之後重新載入此外掛。本重設不會刪除 s3,密碼……等設定。",
|
"settings_resetcache_desc": "(出於除錯原因)重設本地快取和資料庫。您需要在重設之後重新載入此外掛。本重設不會刪除 s3,密碼……等設定。",
|
||||||
"settings_resetcache_button": "重設",
|
"settings_resetcache_button": "重設",
|
||||||
"settings_resetcache_notice": "本地同步快取和資料庫已被刪除。請手動重新載入此外掛。",
|
"settings_resetcache_notice": "本地同步快取和資料庫已被刪除。請手動重新載入此外掛。",
|
||||||
"syncalgov2_title": "Remotely Save 的同步演算法得到最佳化",
|
"syncalgov3_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_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_agree": "同意",
|
"syncalgov3_checkbox_manual_backup": "我將會首先手動備份我的庫(Vault)。",
|
||||||
"syncalgov2_button_disagree": "不同意",
|
"syncalgov3_checkbox_requiremultidevupdate": "我理解,我需要在所有裝置上都更新此外掛使之正常執行。",
|
||||||
"official_notice_2024_first_party": "外掛 Remotely-Save 回來了,更新了一大堆功能!🎉🎉🎉請自行使用,或參閱更新文件: https://github.com/remotely-save/remotely-save/releases 。"
|
"syncalgov3_button_agree": "同意",
|
||||||
|
"syncalgov3_button_disagree": "不同意"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { TFile, TFolder, type Vault } from "obsidian";
|
||||||
|
import type { Entity, MixedEntity } from "./baseTypes";
|
||||||
|
import { listFilesInObsFolder } from "./obsFolderLister";
|
||||||
|
|
||||||
|
export const getLocalEntityList = async (
|
||||||
|
vault: Vault,
|
||||||
|
syncConfigDir: boolean,
|
||||||
|
configDir: string,
|
||||||
|
pluginID: string
|
||||||
|
) => {
|
||||||
|
const local: Entity[] = [];
|
||||||
|
|
||||||
|
const localTAbstractFiles = vault.getAllLoadedFiles();
|
||||||
|
for (const entry of localTAbstractFiles) {
|
||||||
|
let r = {} as Entity;
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
local.push(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncConfigDir) {
|
||||||
|
const syncFiles = await listFilesInObsFolder(configDir, vault, pluginID);
|
||||||
|
for (const f of syncFiles) {
|
||||||
|
local.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return local;
|
||||||
|
};
|
||||||
+214
-468
@@ -3,35 +3,34 @@ export type LocalForage = typeof localforage;
|
|||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { requireApiVersion, TAbstractFile, TFile, TFolder } from "obsidian";
|
import { requireApiVersion, TAbstractFile, TFile, TFolder } from "obsidian";
|
||||||
|
|
||||||
import { API_VER_STAT_FOLDER, SUPPORTED_SERVICES_TYPE } from "./baseTypes";
|
import { API_VER_STAT_FOLDER } 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 { statFix, toText, 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 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 +53,118 @@ 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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 +176,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 +200,19 @@ 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,
|
||||||
|
}),
|
||||||
|
|
||||||
|
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 +244,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 +283,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 +382,13 @@ export const readAllSyncPlanRecordTextsByVault = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* We remove records that are older than 7 days or 10000 records.
|
* We remove records that are older than 3 days or 100 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 * 3; // 3 days
|
||||||
const COUNT_TO_MANY = 10000;
|
const COUNT_TO_MANY = 100;
|
||||||
|
|
||||||
const currTs = Date.now();
|
const currTs = Date.now();
|
||||||
const expiredTs = currTs - MILLISECONDS_OLD;
|
const expiredTs = currTs - MILLISECONDS_OLD;
|
||||||
@@ -634,7 +408,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 +423,67 @@ 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
|
||||||
|
) => {
|
||||||
|
// console.debug('inside getAllPrevSyncRecordsByVaultAndProfile')
|
||||||
|
const keys = await db.prevSyncRecordsTbl.keys();
|
||||||
|
// console.debug(`inside getAllPrevSyncRecordsByVaultAndProfile, keys=${keys}`)
|
||||||
|
const res: Entity[] = [];
|
||||||
|
for (const key of keys) {
|
||||||
|
if (key.startsWith(`${vaultRandomID}\t${profileID}\t`)) {
|
||||||
|
const val: Entity | null = await db.prevSyncRecordsTbl.getItem(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 +494,7 @@ export const upsertLastSuccessSyncByVault = async (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getLastSuccessSyncByVault = async (
|
export const getLastSuccessSyncTimeByVault = async (
|
||||||
db: InternalDBs,
|
db: InternalDBs,
|
||||||
vaultRandomID: string
|
vaultRandomID: string
|
||||||
) => {
|
) => {
|
||||||
|
|||||||
+329
-295
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;
|
||||||
|
|||||||
+114
-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,86 @@ 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://forum.obsidian.md/t/css-to-show-status-bar-on-mobile-devices/77185
|
||||||
|
* @param op
|
||||||
|
*/
|
||||||
|
export const changeMobileStatusBar = (op: "enable" | "disable") => {
|
||||||
|
const bar = document.querySelector(
|
||||||
|
".is-mobile .app-container .status-bar"
|
||||||
|
) as HTMLElement;
|
||||||
|
if (op === "enable") {
|
||||||
|
bar.style.setProperty("display", "flex");
|
||||||
|
const navBar = document.getElementsByClassName(
|
||||||
|
"mobile-navbar"
|
||||||
|
)[0] as HTMLElement;
|
||||||
|
// thanks to community's solution
|
||||||
|
const height = window.getComputedStyle(navBar).getPropertyValue("height");
|
||||||
|
bar.style.setProperty("margin-bottom", height);
|
||||||
|
} else {
|
||||||
|
bar.style.removeProperty("display");
|
||||||
|
bar.style.removeProperty("margin-bottom");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
-20
@@ -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 (
|
||||||
@@ -48,14 +42,14 @@ 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 +57,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 +96,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 +108,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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
+65
-62
@@ -1,17 +1,18 @@
|
|||||||
import { Vault } from "obsidian";
|
import { Vault } from "obsidian";
|
||||||
import type {
|
import type {
|
||||||
|
Entity,
|
||||||
DropboxConfig,
|
DropboxConfig,
|
||||||
OnedriveConfig,
|
OnedriveConfig,
|
||||||
S3Config,
|
S3Config,
|
||||||
SUPPORTED_SERVICES_TYPE,
|
SUPPORTED_SERVICES_TYPE,
|
||||||
WebdavConfig,
|
WebdavConfig,
|
||||||
|
UploadedType,
|
||||||
} from "./baseTypes";
|
} from "./baseTypes";
|
||||||
import * as dropbox from "./remoteForDropbox";
|
import * as dropbox from "./remoteForDropbox";
|
||||||
import * as onedrive from "./remoteForOnedrive";
|
import * as onedrive from "./remoteForOnedrive";
|
||||||
import * as s3 from "./remoteForS3";
|
import * as s3 from "./remoteForS3";
|
||||||
import * as webdav from "./remoteForWebdav";
|
import * as webdav from "./remoteForWebdav";
|
||||||
|
import { Cipher } from "./encryptUnified";
|
||||||
import { log } from "./moreOnLog";
|
|
||||||
|
|
||||||
export class RemoteClient {
|
export class RemoteClient {
|
||||||
readonly serviceType: SUPPORTED_SERVICES_TYPE;
|
readonly serviceType: SUPPORTED_SERVICES_TYPE;
|
||||||
@@ -43,10 +44,10 @@ export class RemoteClient {
|
|||||||
"remember to provide vault name and callback while init webdav client"
|
"remember to provide vault name and callback while init webdav client"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const remoteBaseDir = webdavConfig.remoteBaseDir || vaultName;
|
const remoteBaseDir = webdavConfig!.remoteBaseDir || vaultName;
|
||||||
this.webdavConfig = webdavConfig;
|
this.webdavConfig = webdavConfig;
|
||||||
this.webdavClient = webdav.getWebdavClient(
|
this.webdavClient = webdav.getWebdavClient(
|
||||||
this.webdavConfig,
|
this.webdavConfig!,
|
||||||
remoteBaseDir,
|
remoteBaseDir,
|
||||||
saveUpdatedConfigFunc
|
saveUpdatedConfigFunc
|
||||||
);
|
);
|
||||||
@@ -56,10 +57,10 @@ export class RemoteClient {
|
|||||||
"remember to provide vault name and callback while init dropbox client"
|
"remember to provide vault name and callback while init dropbox client"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const remoteBaseDir = dropboxConfig.remoteBaseDir || vaultName;
|
const remoteBaseDir = dropboxConfig!.remoteBaseDir || vaultName;
|
||||||
this.dropboxConfig = dropboxConfig;
|
this.dropboxConfig = dropboxConfig;
|
||||||
this.dropboxClient = dropbox.getDropboxClient(
|
this.dropboxClient = dropbox.getDropboxClient(
|
||||||
this.dropboxConfig,
|
this.dropboxConfig!,
|
||||||
remoteBaseDir,
|
remoteBaseDir,
|
||||||
saveUpdatedConfigFunc
|
saveUpdatedConfigFunc
|
||||||
);
|
);
|
||||||
@@ -69,10 +70,10 @@ export class RemoteClient {
|
|||||||
"remember to provide vault name and callback while init onedrive client"
|
"remember to provide vault name and callback while init onedrive client"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const remoteBaseDir = onedriveConfig.remoteBaseDir || vaultName;
|
const remoteBaseDir = onedriveConfig!.remoteBaseDir || vaultName;
|
||||||
this.onedriveConfig = onedriveConfig;
|
this.onedriveConfig = onedriveConfig;
|
||||||
this.onedriveClient = onedrive.getOnedriveClient(
|
this.onedriveClient = onedrive.getOnedriveClient(
|
||||||
this.onedriveConfig,
|
this.onedriveConfig!,
|
||||||
remoteBaseDir,
|
remoteBaseDir,
|
||||||
saveUpdatedConfigFunc
|
saveUpdatedConfigFunc
|
||||||
);
|
);
|
||||||
@@ -84,17 +85,17 @@ export class RemoteClient {
|
|||||||
getRemoteMeta = async (fileOrFolderPath: string) => {
|
getRemoteMeta = async (fileOrFolderPath: string) => {
|
||||||
if (this.serviceType === "s3") {
|
if (this.serviceType === "s3") {
|
||||||
return await s3.getRemoteMeta(
|
return await s3.getRemoteMeta(
|
||||||
s3.getS3Client(this.s3Config),
|
s3.getS3Client(this.s3Config!),
|
||||||
this.s3Config,
|
this.s3Config!,
|
||||||
fileOrFolderPath
|
fileOrFolderPath
|
||||||
);
|
);
|
||||||
} else if (this.serviceType === "webdav") {
|
} else if (this.serviceType === "webdav") {
|
||||||
return await webdav.getRemoteMeta(this.webdavClient, fileOrFolderPath);
|
return await webdav.getRemoteMeta(this.webdavClient!, fileOrFolderPath);
|
||||||
} else if (this.serviceType === "dropbox") {
|
} else if (this.serviceType === "dropbox") {
|
||||||
return await dropbox.getRemoteMeta(this.dropboxClient, fileOrFolderPath);
|
return await dropbox.getRemoteMeta(this.dropboxClient!, fileOrFolderPath);
|
||||||
} else if (this.serviceType === "onedrive") {
|
} else if (this.serviceType === "onedrive") {
|
||||||
return await onedrive.getRemoteMeta(
|
return await onedrive.getRemoteMeta(
|
||||||
this.onedriveClient,
|
this.onedriveClient!,
|
||||||
fileOrFolderPath
|
fileOrFolderPath
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -104,44 +105,44 @@ export class RemoteClient {
|
|||||||
|
|
||||||
uploadToRemote = async (
|
uploadToRemote = async (
|
||||||
fileOrFolderPath: string,
|
fileOrFolderPath: string,
|
||||||
vault: Vault,
|
vault: Vault | undefined,
|
||||||
isRecursively: boolean = false,
|
isRecursively: boolean,
|
||||||
password: string = "",
|
cipher: Cipher,
|
||||||
remoteEncryptedKey: string = "",
|
remoteEncryptedKey: string = "",
|
||||||
foldersCreatedBefore: Set<string> | undefined = undefined,
|
foldersCreatedBefore: Set<string> | undefined = undefined,
|
||||||
uploadRaw: boolean = false,
|
uploadRaw: boolean = false,
|
||||||
rawContent: string | ArrayBuffer = ""
|
rawContent: string | ArrayBuffer = ""
|
||||||
) => {
|
): Promise<UploadedType> => {
|
||||||
if (this.serviceType === "s3") {
|
if (this.serviceType === "s3") {
|
||||||
return await s3.uploadToRemote(
|
return await s3.uploadToRemote(
|
||||||
s3.getS3Client(this.s3Config),
|
s3.getS3Client(this.s3Config!),
|
||||||
this.s3Config,
|
this.s3Config!,
|
||||||
fileOrFolderPath,
|
fileOrFolderPath,
|
||||||
vault,
|
vault,
|
||||||
isRecursively,
|
isRecursively,
|
||||||
password,
|
cipher,
|
||||||
remoteEncryptedKey,
|
remoteEncryptedKey,
|
||||||
uploadRaw,
|
uploadRaw,
|
||||||
rawContent
|
rawContent
|
||||||
);
|
);
|
||||||
} else if (this.serviceType === "webdav") {
|
} else if (this.serviceType === "webdav") {
|
||||||
return await webdav.uploadToRemote(
|
return await webdav.uploadToRemote(
|
||||||
this.webdavClient,
|
this.webdavClient!,
|
||||||
fileOrFolderPath,
|
fileOrFolderPath,
|
||||||
vault,
|
vault,
|
||||||
isRecursively,
|
isRecursively,
|
||||||
password,
|
cipher,
|
||||||
remoteEncryptedKey,
|
remoteEncryptedKey,
|
||||||
uploadRaw,
|
uploadRaw,
|
||||||
rawContent
|
rawContent
|
||||||
);
|
);
|
||||||
} else if (this.serviceType === "dropbox") {
|
} else if (this.serviceType === "dropbox") {
|
||||||
return await dropbox.uploadToRemote(
|
return await dropbox.uploadToRemote(
|
||||||
this.dropboxClient,
|
this.dropboxClient!,
|
||||||
fileOrFolderPath,
|
fileOrFolderPath,
|
||||||
vault,
|
vault,
|
||||||
isRecursively,
|
isRecursively,
|
||||||
password,
|
cipher,
|
||||||
remoteEncryptedKey,
|
remoteEncryptedKey,
|
||||||
foldersCreatedBefore,
|
foldersCreatedBefore,
|
||||||
uploadRaw,
|
uploadRaw,
|
||||||
@@ -149,11 +150,11 @@ export class RemoteClient {
|
|||||||
);
|
);
|
||||||
} else if (this.serviceType === "onedrive") {
|
} else if (this.serviceType === "onedrive") {
|
||||||
return await onedrive.uploadToRemote(
|
return await onedrive.uploadToRemote(
|
||||||
this.onedriveClient,
|
this.onedriveClient!,
|
||||||
fileOrFolderPath,
|
fileOrFolderPath,
|
||||||
vault,
|
vault,
|
||||||
isRecursively,
|
isRecursively,
|
||||||
password,
|
cipher,
|
||||||
remoteEncryptedKey,
|
remoteEncryptedKey,
|
||||||
foldersCreatedBefore,
|
foldersCreatedBefore,
|
||||||
uploadRaw,
|
uploadRaw,
|
||||||
@@ -164,18 +165,18 @@ export class RemoteClient {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
listAllFromRemote = async () => {
|
listAllFromRemote = async (): Promise<Entity[]> => {
|
||||||
if (this.serviceType === "s3") {
|
if (this.serviceType === "s3") {
|
||||||
return await s3.listAllFromRemote(
|
return await s3.listAllFromRemote(
|
||||||
s3.getS3Client(this.s3Config),
|
s3.getS3Client(this.s3Config!),
|
||||||
this.s3Config
|
this.s3Config!
|
||||||
);
|
);
|
||||||
} else if (this.serviceType === "webdav") {
|
} else if (this.serviceType === "webdav") {
|
||||||
return await webdav.listAllFromRemote(this.webdavClient);
|
return await webdav.listAllFromRemote(this.webdavClient!);
|
||||||
} else if (this.serviceType === "dropbox") {
|
} else if (this.serviceType === "dropbox") {
|
||||||
return await dropbox.listAllFromRemote(this.dropboxClient);
|
return await dropbox.listAllFromRemote(this.dropboxClient!);
|
||||||
} else if (this.serviceType === "onedrive") {
|
} else if (this.serviceType === "onedrive") {
|
||||||
return await onedrive.listAllFromRemote(this.onedriveClient);
|
return await onedrive.listAllFromRemote(this.onedriveClient!);
|
||||||
} else {
|
} else {
|
||||||
throw Error(`not supported service type ${this.serviceType}`);
|
throw Error(`not supported service type ${this.serviceType}`);
|
||||||
}
|
}
|
||||||
@@ -185,48 +186,48 @@ export class RemoteClient {
|
|||||||
fileOrFolderPath: string,
|
fileOrFolderPath: string,
|
||||||
vault: Vault,
|
vault: Vault,
|
||||||
mtime: number,
|
mtime: number,
|
||||||
password: string = "",
|
cipher: Cipher,
|
||||||
remoteEncryptedKey: string = "",
|
remoteEncryptedKey: string = "",
|
||||||
skipSaving: boolean = false
|
skipSaving: boolean = false
|
||||||
) => {
|
) => {
|
||||||
if (this.serviceType === "s3") {
|
if (this.serviceType === "s3") {
|
||||||
return await s3.downloadFromRemote(
|
return await s3.downloadFromRemote(
|
||||||
s3.getS3Client(this.s3Config),
|
s3.getS3Client(this.s3Config!),
|
||||||
this.s3Config,
|
this.s3Config!,
|
||||||
fileOrFolderPath,
|
fileOrFolderPath,
|
||||||
vault,
|
vault,
|
||||||
mtime,
|
mtime,
|
||||||
password,
|
cipher,
|
||||||
remoteEncryptedKey,
|
remoteEncryptedKey,
|
||||||
skipSaving
|
skipSaving
|
||||||
);
|
);
|
||||||
} else if (this.serviceType === "webdav") {
|
} else if (this.serviceType === "webdav") {
|
||||||
return await webdav.downloadFromRemote(
|
return await webdav.downloadFromRemote(
|
||||||
this.webdavClient,
|
this.webdavClient!,
|
||||||
fileOrFolderPath,
|
fileOrFolderPath,
|
||||||
vault,
|
vault,
|
||||||
mtime,
|
mtime,
|
||||||
password,
|
cipher,
|
||||||
remoteEncryptedKey,
|
remoteEncryptedKey,
|
||||||
skipSaving
|
skipSaving
|
||||||
);
|
);
|
||||||
} else if (this.serviceType === "dropbox") {
|
} else if (this.serviceType === "dropbox") {
|
||||||
return await dropbox.downloadFromRemote(
|
return await dropbox.downloadFromRemote(
|
||||||
this.dropboxClient,
|
this.dropboxClient!,
|
||||||
fileOrFolderPath,
|
fileOrFolderPath,
|
||||||
vault,
|
vault,
|
||||||
mtime,
|
mtime,
|
||||||
password,
|
cipher,
|
||||||
remoteEncryptedKey,
|
remoteEncryptedKey,
|
||||||
skipSaving
|
skipSaving
|
||||||
);
|
);
|
||||||
} else if (this.serviceType === "onedrive") {
|
} else if (this.serviceType === "onedrive") {
|
||||||
return await onedrive.downloadFromRemote(
|
return await onedrive.downloadFromRemote(
|
||||||
this.onedriveClient,
|
this.onedriveClient!,
|
||||||
fileOrFolderPath,
|
fileOrFolderPath,
|
||||||
vault,
|
vault,
|
||||||
mtime,
|
mtime,
|
||||||
password,
|
cipher,
|
||||||
remoteEncryptedKey,
|
remoteEncryptedKey,
|
||||||
skipSaving
|
skipSaving
|
||||||
);
|
);
|
||||||
@@ -237,36 +238,38 @@ export class RemoteClient {
|
|||||||
|
|
||||||
deleteFromRemote = async (
|
deleteFromRemote = async (
|
||||||
fileOrFolderPath: string,
|
fileOrFolderPath: string,
|
||||||
password: string = "",
|
cipher: Cipher,
|
||||||
remoteEncryptedKey: string = ""
|
remoteEncryptedKey: string = "",
|
||||||
|
synthesizedFolder: boolean = false
|
||||||
) => {
|
) => {
|
||||||
if (this.serviceType === "s3") {
|
if (this.serviceType === "s3") {
|
||||||
return await s3.deleteFromRemote(
|
return await s3.deleteFromRemote(
|
||||||
s3.getS3Client(this.s3Config),
|
s3.getS3Client(this.s3Config!),
|
||||||
this.s3Config,
|
this.s3Config!,
|
||||||
fileOrFolderPath,
|
fileOrFolderPath,
|
||||||
password,
|
cipher,
|
||||||
remoteEncryptedKey
|
remoteEncryptedKey,
|
||||||
|
synthesizedFolder
|
||||||
);
|
);
|
||||||
} else if (this.serviceType === "webdav") {
|
} else if (this.serviceType === "webdav") {
|
||||||
return await webdav.deleteFromRemote(
|
return await webdav.deleteFromRemote(
|
||||||
this.webdavClient,
|
this.webdavClient!,
|
||||||
fileOrFolderPath,
|
fileOrFolderPath,
|
||||||
password,
|
cipher,
|
||||||
remoteEncryptedKey
|
remoteEncryptedKey
|
||||||
);
|
);
|
||||||
} else if (this.serviceType === "dropbox") {
|
} else if (this.serviceType === "dropbox") {
|
||||||
return await dropbox.deleteFromRemote(
|
return await dropbox.deleteFromRemote(
|
||||||
this.dropboxClient,
|
this.dropboxClient!,
|
||||||
fileOrFolderPath,
|
fileOrFolderPath,
|
||||||
password,
|
cipher,
|
||||||
remoteEncryptedKey
|
remoteEncryptedKey
|
||||||
);
|
);
|
||||||
} else if (this.serviceType === "onedrive") {
|
} else if (this.serviceType === "onedrive") {
|
||||||
return await onedrive.deleteFromRemote(
|
return await onedrive.deleteFromRemote(
|
||||||
this.onedriveClient,
|
this.onedriveClient!,
|
||||||
fileOrFolderPath,
|
fileOrFolderPath,
|
||||||
password,
|
cipher,
|
||||||
remoteEncryptedKey
|
remoteEncryptedKey
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -277,17 +280,17 @@ export class RemoteClient {
|
|||||||
checkConnectivity = async (callbackFunc?: any) => {
|
checkConnectivity = async (callbackFunc?: any) => {
|
||||||
if (this.serviceType === "s3") {
|
if (this.serviceType === "s3") {
|
||||||
return await s3.checkConnectivity(
|
return await s3.checkConnectivity(
|
||||||
s3.getS3Client(this.s3Config),
|
s3.getS3Client(this.s3Config!),
|
||||||
this.s3Config,
|
this.s3Config!,
|
||||||
callbackFunc
|
callbackFunc
|
||||||
);
|
);
|
||||||
} else if (this.serviceType === "webdav") {
|
} else if (this.serviceType === "webdav") {
|
||||||
return await webdav.checkConnectivity(this.webdavClient, callbackFunc);
|
return await webdav.checkConnectivity(this.webdavClient!, callbackFunc);
|
||||||
} else if (this.serviceType === "dropbox") {
|
} else if (this.serviceType === "dropbox") {
|
||||||
return await dropbox.checkConnectivity(this.dropboxClient, callbackFunc);
|
return await dropbox.checkConnectivity(this.dropboxClient!, callbackFunc);
|
||||||
} else if (this.serviceType === "onedrive") {
|
} else if (this.serviceType === "onedrive") {
|
||||||
return await onedrive.checkConnectivity(
|
return await onedrive.checkConnectivity(
|
||||||
this.onedriveClient,
|
this.onedriveClient!,
|
||||||
callbackFunc
|
callbackFunc
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -297,9 +300,9 @@ export class RemoteClient {
|
|||||||
|
|
||||||
getUser = async () => {
|
getUser = async () => {
|
||||||
if (this.serviceType === "dropbox") {
|
if (this.serviceType === "dropbox") {
|
||||||
return await dropbox.getUserDisplayName(this.dropboxClient);
|
return await dropbox.getUserDisplayName(this.dropboxClient!);
|
||||||
} else if (this.serviceType === "onedrive") {
|
} else if (this.serviceType === "onedrive") {
|
||||||
return await onedrive.getUserDisplayName(this.onedriveClient);
|
return await onedrive.getUserDisplayName(this.onedriveClient!);
|
||||||
} else {
|
} else {
|
||||||
throw Error(`not supported service type ${this.serviceType}`);
|
throw Error(`not supported service type ${this.serviceType}`);
|
||||||
}
|
}
|
||||||
@@ -307,7 +310,7 @@ export class RemoteClient {
|
|||||||
|
|
||||||
revokeAuth = async () => {
|
revokeAuth = async () => {
|
||||||
if (this.serviceType === "dropbox") {
|
if (this.serviceType === "dropbox") {
|
||||||
return await dropbox.revokeAuth(this.dropboxClient);
|
return await dropbox.revokeAuth(this.dropboxClient!);
|
||||||
} else {
|
} else {
|
||||||
throw Error(`not supported service type ${this.serviceType}`);
|
throw Error(`not supported service type ${this.serviceType}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+172
-185
@@ -5,26 +5,26 @@ import { Vault } from "obsidian";
|
|||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import {
|
import {
|
||||||
DropboxConfig,
|
DropboxConfig,
|
||||||
RemoteItem,
|
Entity,
|
||||||
COMMAND_CALLBACK_DROPBOX,
|
COMMAND_CALLBACK_DROPBOX,
|
||||||
OAUTH2_FORCE_EXPIRE_MILLISECONDS,
|
OAUTH2_FORCE_EXPIRE_MILLISECONDS,
|
||||||
|
UploadedType,
|
||||||
} from "./baseTypes";
|
} from "./baseTypes";
|
||||||
import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
|
|
||||||
import {
|
import {
|
||||||
bufferToArrayBuffer,
|
bufferToArrayBuffer,
|
||||||
|
fixEntityListCasesInplace,
|
||||||
getFolderLevels,
|
getFolderLevels,
|
||||||
hasEmojiInText,
|
hasEmojiInText,
|
||||||
headersToRecord,
|
headersToRecord,
|
||||||
mkdirpInVault,
|
mkdirpInVault,
|
||||||
} from "./misc";
|
} from "./misc";
|
||||||
|
import { Cipher } from "./encryptUnified";
|
||||||
|
|
||||||
export { Dropbox } from "dropbox";
|
export { Dropbox } from "dropbox";
|
||||||
|
|
||||||
import { log } from "./moreOnLog";
|
|
||||||
|
|
||||||
export const DEFAULT_DROPBOX_CONFIG: DropboxConfig = {
|
export const DEFAULT_DROPBOX_CONFIG: DropboxConfig = {
|
||||||
accessToken: "",
|
accessToken: "",
|
||||||
clientID: process.env.DEFAULT_DROPBOX_APP_KEY,
|
clientID: process.env.DEFAULT_DROPBOX_APP_KEY ?? "",
|
||||||
refreshToken: "",
|
refreshToken: "",
|
||||||
accessTokenExpiresInSeconds: 0,
|
accessTokenExpiresInSeconds: 0,
|
||||||
accessTokenExpiresAtTime: 0,
|
accessTokenExpiresAtTime: 0,
|
||||||
@@ -41,9 +41,12 @@ export const getDropboxPath = (
|
|||||||
if (fileOrFolderPath === "/" || fileOrFolderPath === "") {
|
if (fileOrFolderPath === "/" || fileOrFolderPath === "") {
|
||||||
// special
|
// special
|
||||||
key = `/${remoteBaseDir}`;
|
key = `/${remoteBaseDir}`;
|
||||||
}
|
} else if (fileOrFolderPath.startsWith("/")) {
|
||||||
if (!fileOrFolderPath.startsWith("/")) {
|
console.warn(
|
||||||
// then this is original path in Obsidian
|
`why the path ${fileOrFolderPath} starts with '/'? but we just go on.`
|
||||||
|
);
|
||||||
|
key = `/${remoteBaseDir}${fileOrFolderPath}`;
|
||||||
|
} else {
|
||||||
key = `/${remoteBaseDir}/${fileOrFolderPath}`;
|
key = `/${remoteBaseDir}/${fileOrFolderPath}`;
|
||||||
}
|
}
|
||||||
if (key.endsWith("/")) {
|
if (key.endsWith("/")) {
|
||||||
@@ -66,103 +69,41 @@ const getNormPath = (fileOrFolderPath: string, remoteBaseDir: string) => {
|
|||||||
return fileOrFolderPath.slice(`/${remoteBaseDir}/`.length);
|
return fileOrFolderPath.slice(`/${remoteBaseDir}/`.length);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fromDropboxItemToRemoteItem = (
|
const fromDropboxItemToEntity = (
|
||||||
x:
|
x:
|
||||||
| files.FileMetadataReference
|
| files.FileMetadataReference
|
||||||
| files.FolderMetadataReference
|
| files.FolderMetadataReference
|
||||||
| files.DeletedMetadataReference,
|
| files.DeletedMetadataReference,
|
||||||
remoteBaseDir: string
|
remoteBaseDir: string
|
||||||
): RemoteItem => {
|
): Entity => {
|
||||||
let key = getNormPath(x.path_display, remoteBaseDir);
|
let key = getNormPath(x.path_display!, remoteBaseDir);
|
||||||
if (x[".tag"] === "folder" && !key.endsWith("/")) {
|
if (x[".tag"] === "folder" && !key.endsWith("/")) {
|
||||||
key = `${key}/`;
|
key = `${key}/`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (x[".tag"] === "folder") {
|
if (x[".tag"] === "folder") {
|
||||||
return {
|
return {
|
||||||
key: key,
|
keyRaw: key,
|
||||||
lastModified: undefined,
|
sizeRaw: 0,
|
||||||
size: 0,
|
|
||||||
remoteType: "dropbox",
|
|
||||||
etag: `${x.id}\t`,
|
etag: `${x.id}\t`,
|
||||||
} as RemoteItem;
|
} as Entity;
|
||||||
} else if (x[".tag"] === "file") {
|
} else if (x[".tag"] === "file") {
|
||||||
|
const mtimeCli = Date.parse(x.client_modified).valueOf();
|
||||||
|
const mtimeSvr = Date.parse(x.server_modified).valueOf();
|
||||||
return {
|
return {
|
||||||
key: key,
|
keyRaw: key,
|
||||||
lastModified: Date.parse(x.server_modified).valueOf(),
|
mtimeCli: mtimeCli,
|
||||||
size: x.size,
|
mtimeSvr: mtimeSvr,
|
||||||
remoteType: "dropbox",
|
sizeRaw: x.size,
|
||||||
|
hash: x.content_hash,
|
||||||
etag: `${x.id}\t${x.content_hash}`,
|
etag: `${x.id}\t${x.content_hash}`,
|
||||||
} as RemoteItem;
|
} as Entity;
|
||||||
} else if (x[".tag"] === "deleted") {
|
} else {
|
||||||
|
// x[".tag"] === "deleted"
|
||||||
throw Error("do not support deleted tag");
|
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
|
// Dropbox authorization using PKCE
|
||||||
// see https://dropbox.tech/developers/pkce--what-and-why-
|
// see https://dropbox.tech/developers/pkce--what-and-why-
|
||||||
@@ -181,7 +122,7 @@ export const getAuthUrlAndVerifier = async (
|
|||||||
: `obsidian://${COMMAND_CALLBACK_DROPBOX}`;
|
: `obsidian://${COMMAND_CALLBACK_DROPBOX}`;
|
||||||
const authUrl = (
|
const authUrl = (
|
||||||
await auth.getAuthenticationUrl(
|
await auth.getAuthenticationUrl(
|
||||||
callback,
|
callback as any,
|
||||||
undefined,
|
undefined,
|
||||||
"code",
|
"code",
|
||||||
"offline",
|
"offline",
|
||||||
@@ -210,38 +151,51 @@ export interface DropboxSuccessAuthRes {
|
|||||||
export const sendAuthReq = async (
|
export const sendAuthReq = async (
|
||||||
appKey: string,
|
appKey: string,
|
||||||
verifier: string,
|
verifier: string,
|
||||||
authCode: string
|
authCode: string,
|
||||||
|
errorCallBack: any
|
||||||
) => {
|
) => {
|
||||||
const resp1 = await fetch("https://api.dropboxapi.com/oauth2/token", {
|
try {
|
||||||
method: "POST",
|
const resp1 = await fetch("https://api.dropboxapi.com/oauth2/token", {
|
||||||
body: new URLSearchParams({
|
method: "POST",
|
||||||
code: authCode,
|
body: new URLSearchParams({
|
||||||
grant_type: "authorization_code",
|
code: authCode,
|
||||||
code_verifier: verifier,
|
grant_type: "authorization_code",
|
||||||
client_id: appKey,
|
code_verifier: verifier,
|
||||||
redirect_uri: `obsidian://${COMMAND_CALLBACK_DROPBOX}`,
|
client_id: appKey,
|
||||||
}),
|
redirect_uri: `obsidian://${COMMAND_CALLBACK_DROPBOX}`,
|
||||||
});
|
}),
|
||||||
const resp2 = (await resp1.json()) as DropboxSuccessAuthRes;
|
});
|
||||||
return resp2;
|
const resp2 = (await resp1.json()) as DropboxSuccessAuthRes;
|
||||||
|
return resp2;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
if (errorCallBack !== undefined) {
|
||||||
|
await errorCallBack(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sendRefreshTokenReq = async (
|
export const sendRefreshTokenReq = async (
|
||||||
appKey: string,
|
appKey: string,
|
||||||
refreshToken: string
|
refreshToken: string
|
||||||
) => {
|
) => {
|
||||||
log.info("start auto getting refreshed Dropbox access token.");
|
try {
|
||||||
const resp1 = await fetch("https://api.dropboxapi.com/oauth2/token", {
|
console.info("start auto getting refreshed Dropbox access token.");
|
||||||
method: "POST",
|
const resp1 = await fetch("https://api.dropboxapi.com/oauth2/token", {
|
||||||
body: new URLSearchParams({
|
method: "POST",
|
||||||
grant_type: "refresh_token",
|
body: new URLSearchParams({
|
||||||
refresh_token: refreshToken,
|
grant_type: "refresh_token",
|
||||||
client_id: appKey,
|
refresh_token: refreshToken,
|
||||||
}),
|
client_id: appKey,
|
||||||
});
|
}),
|
||||||
const resp2 = (await resp1.json()) as DropboxSuccessAuthRes;
|
});
|
||||||
log.info("finish auto getting refreshed Dropbox access token.");
|
const resp2 = (await resp1.json()) as DropboxSuccessAuthRes;
|
||||||
return resp2;
|
console.info("finish auto getting refreshed Dropbox access token.");
|
||||||
|
return resp2;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setConfigBySuccessfullAuthInplace = async (
|
export const setConfigBySuccessfullAuthInplace = async (
|
||||||
@@ -249,7 +203,7 @@ export const setConfigBySuccessfullAuthInplace = async (
|
|||||||
authRes: DropboxSuccessAuthRes,
|
authRes: DropboxSuccessAuthRes,
|
||||||
saveUpdatedConfigFunc: () => Promise<any> | undefined
|
saveUpdatedConfigFunc: () => Promise<any> | undefined
|
||||||
) => {
|
) => {
|
||||||
log.info("start updating local info of Dropbox token");
|
console.info("start updating local info of Dropbox token");
|
||||||
|
|
||||||
config.accessToken = authRes.access_token;
|
config.accessToken = authRes.access_token;
|
||||||
config.accessTokenExpiresInSeconds = parseInt(authRes.expires_in);
|
config.accessTokenExpiresInSeconds = parseInt(authRes.expires_in);
|
||||||
@@ -262,16 +216,14 @@ export const setConfigBySuccessfullAuthInplace = async (
|
|||||||
|
|
||||||
if (authRes.refresh_token !== undefined) {
|
if (authRes.refresh_token !== undefined) {
|
||||||
config.refreshToken = authRes.refresh_token;
|
config.refreshToken = authRes.refresh_token;
|
||||||
}
|
config.accountID = authRes.account_id!;
|
||||||
if (authRes.refresh_token !== undefined) {
|
|
||||||
config.accountID = authRes.account_id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (saveUpdatedConfigFunc !== undefined) {
|
if (saveUpdatedConfigFunc !== undefined) {
|
||||||
await saveUpdatedConfigFunc();
|
await saveUpdatedConfigFunc();
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("finish updating local info of Dropbox token");
|
console.info("finish updating local info of Dropbox token");
|
||||||
};
|
};
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
@@ -287,12 +239,12 @@ interface ErrSubType {
|
|||||||
async function retryReq<T>(
|
async function retryReq<T>(
|
||||||
reqFunc: () => Promise<DropboxResponse<T>>,
|
reqFunc: () => Promise<DropboxResponse<T>>,
|
||||||
extraHint: string = ""
|
extraHint: string = ""
|
||||||
): Promise<DropboxResponse<T>> {
|
): Promise<DropboxResponse<T> | undefined> {
|
||||||
const waitSeconds = [1, 2, 4, 8]; // hard code exponential backoff
|
const waitSeconds = [1, 2, 4, 8]; // hard code exponential backoff
|
||||||
for (let idx = 0; idx < waitSeconds.length; ++idx) {
|
for (let idx = 0; idx < waitSeconds.length; ++idx) {
|
||||||
try {
|
try {
|
||||||
if (idx !== 0) {
|
if (idx !== 0) {
|
||||||
log.warn(
|
console.warn(
|
||||||
`${extraHint === "" ? "" : extraHint + ": "}The ${
|
`${extraHint === "" ? "" : extraHint + ": "}The ${
|
||||||
idx + 1
|
idx + 1
|
||||||
}-th try starts at time ${Date.now()}`
|
}-th try starts at time ${Date.now()}`
|
||||||
@@ -329,7 +281,7 @@ async function retryReq<T>(
|
|||||||
const fallbackSec = waitSeconds[idx];
|
const fallbackSec = waitSeconds[idx];
|
||||||
const secMin = Math.max(svrSec, fallbackSec);
|
const secMin = Math.max(svrSec, fallbackSec);
|
||||||
const secMax = Math.max(secMin * 1.8, 2);
|
const secMax = Math.max(secMin * 1.8, 2);
|
||||||
log.warn(
|
console.warn(
|
||||||
`${
|
`${
|
||||||
extraHint === "" ? "" : extraHint + ": "
|
extraHint === "" ? "" : extraHint + ": "
|
||||||
}We have "429 too many requests" error of ${
|
}We have "429 too many requests" error of ${
|
||||||
@@ -349,7 +301,7 @@ export class WrappedDropboxClient {
|
|||||||
dropboxConfig: DropboxConfig;
|
dropboxConfig: DropboxConfig;
|
||||||
remoteBaseDir: string;
|
remoteBaseDir: string;
|
||||||
saveUpdatedConfigFunc: () => Promise<any>;
|
saveUpdatedConfigFunc: () => Promise<any>;
|
||||||
dropbox: Dropbox;
|
dropbox!: Dropbox;
|
||||||
vaultFolderExists: boolean;
|
vaultFolderExists: boolean;
|
||||||
constructor(
|
constructor(
|
||||||
dropboxConfig: DropboxConfig,
|
dropboxConfig: DropboxConfig,
|
||||||
@@ -402,9 +354,9 @@ export class WrappedDropboxClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// check vault folder
|
// check vault folder
|
||||||
// log.info(`checking remote has folder /${this.remoteBaseDir}`);
|
// console.info(`checking remote has folder /${this.remoteBaseDir}`);
|
||||||
if (this.vaultFolderExists) {
|
if (this.vaultFolderExists) {
|
||||||
// log.info(`already checked, /${this.remoteBaseDir} exist before`)
|
// console.info(`already checked, /${this.remoteBaseDir} exist before`)
|
||||||
} else {
|
} else {
|
||||||
const res = await this.dropbox.filesListFolder({
|
const res = await this.dropbox.filesListFolder({
|
||||||
path: "",
|
path: "",
|
||||||
@@ -417,7 +369,7 @@ export class WrappedDropboxClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!this.vaultFolderExists) {
|
if (!this.vaultFolderExists) {
|
||||||
log.info(`remote does not have folder /${this.remoteBaseDir}`);
|
console.info(`remote does not have folder /${this.remoteBaseDir}`);
|
||||||
|
|
||||||
if (hasEmojiInText(`/${this.remoteBaseDir}`)) {
|
if (hasEmojiInText(`/${this.remoteBaseDir}`)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -428,10 +380,10 @@ export class WrappedDropboxClient {
|
|||||||
await this.dropbox.filesCreateFolderV2({
|
await this.dropbox.filesCreateFolderV2({
|
||||||
path: `/${this.remoteBaseDir}`,
|
path: `/${this.remoteBaseDir}`,
|
||||||
});
|
});
|
||||||
log.info(`remote folder /${this.remoteBaseDir} created`);
|
console.info(`remote folder /${this.remoteBaseDir} created`);
|
||||||
this.vaultFolderExists = true;
|
this.vaultFolderExists = true;
|
||||||
} else {
|
} else {
|
||||||
// log.info(`remote folder /${this.remoteBaseDir} exists`);
|
// console.info(`remote folder /${this.remoteBaseDir} exists`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,59 +409,67 @@ export const getDropboxClient = (
|
|||||||
|
|
||||||
export const getRemoteMeta = async (
|
export const getRemoteMeta = async (
|
||||||
client: WrappedDropboxClient,
|
client: WrappedDropboxClient,
|
||||||
fileOrFolderPath: string
|
remotePath: string
|
||||||
) => {
|
) => {
|
||||||
await client.init();
|
await client.init();
|
||||||
if (fileOrFolderPath === "" || fileOrFolderPath === "/") {
|
// if (remotePath === "" || remotePath === "/") {
|
||||||
// filesGetMetadata doesn't support root folder
|
// // filesGetMetadata doesn't support root folder
|
||||||
// we instead try to list files
|
// // we instead try to list files
|
||||||
// if no error occurs, we ensemble a fake result.
|
// // if no error occurs, we ensemble a fake result.
|
||||||
const rsp = await retryReq(() =>
|
// const rsp = await retryReq(() =>
|
||||||
client.dropbox.filesListFolder({
|
// client.dropbox.filesListFolder({
|
||||||
path: `/${client.remoteBaseDir}`,
|
// path: `/${client.remoteBaseDir}`,
|
||||||
recursive: false, // don't need to recursive here
|
// recursive: false, // don't need to recursive here
|
||||||
})
|
// })
|
||||||
);
|
// );
|
||||||
if (rsp.status !== 200) {
|
// if (rsp.status !== 200) {
|
||||||
throw Error(JSON.stringify(rsp));
|
// throw Error(JSON.stringify(rsp));
|
||||||
}
|
// }
|
||||||
return {
|
// return {
|
||||||
key: fileOrFolderPath,
|
// key: remotePath,
|
||||||
lastModified: undefined,
|
// lastModified: undefined,
|
||||||
size: 0,
|
// size: 0,
|
||||||
remoteType: "dropbox",
|
// remoteType: "dropbox",
|
||||||
etag: undefined,
|
// etag: undefined,
|
||||||
} as RemoteItem;
|
// } as Entity;
|
||||||
}
|
// }
|
||||||
|
|
||||||
const key = getDropboxPath(fileOrFolderPath, client.remoteBaseDir);
|
|
||||||
|
|
||||||
const rsp = await retryReq(() =>
|
const rsp = await retryReq(() =>
|
||||||
client.dropbox.filesGetMetadata({
|
client.dropbox.filesGetMetadata({
|
||||||
path: key,
|
path: remotePath,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
if (rsp === undefined) {
|
||||||
|
throw Error("dropbox.filesGetMetadata undefinded");
|
||||||
|
}
|
||||||
if (rsp.status !== 200) {
|
if (rsp.status !== 200) {
|
||||||
throw Error(JSON.stringify(rsp));
|
throw Error(JSON.stringify(rsp));
|
||||||
}
|
}
|
||||||
return fromDropboxItemToRemoteItem(rsp.result, client.remoteBaseDir);
|
return fromDropboxItemToEntity(rsp.result, client.remoteBaseDir);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const uploadToRemote = async (
|
export const uploadToRemote = async (
|
||||||
client: WrappedDropboxClient,
|
client: WrappedDropboxClient,
|
||||||
fileOrFolderPath: string,
|
fileOrFolderPath: string,
|
||||||
vault: Vault,
|
vault: Vault | undefined,
|
||||||
isRecursively: boolean = false,
|
isRecursively: boolean,
|
||||||
password: string = "",
|
cipher: Cipher,
|
||||||
remoteEncryptedKey: string = "",
|
remoteEncryptedKey: string = "",
|
||||||
foldersCreatedBefore: Set<string> | undefined = undefined,
|
foldersCreatedBefore: Set<string> | undefined = undefined,
|
||||||
uploadRaw: boolean = false,
|
uploadRaw: boolean = false,
|
||||||
rawContent: string | ArrayBuffer = ""
|
rawContent: string | ArrayBuffer = "",
|
||||||
) => {
|
rawContentMTime: number = 0,
|
||||||
|
rawContentCTime: number = 0
|
||||||
|
): Promise<UploadedType> => {
|
||||||
await client.init();
|
await client.init();
|
||||||
|
|
||||||
let uploadFile = fileOrFolderPath;
|
let uploadFile = fileOrFolderPath;
|
||||||
if (password !== "") {
|
if (!cipher.isPasswordEmpty()) {
|
||||||
|
if (remoteEncryptedKey === undefined || remoteEncryptedKey === "") {
|
||||||
|
throw Error(
|
||||||
|
`uploadToRemote(dropbox) you have password but remoteEncryptedKey is empty!`
|
||||||
|
);
|
||||||
|
}
|
||||||
uploadFile = remoteEncryptedKey;
|
uploadFile = remoteEncryptedKey;
|
||||||
}
|
}
|
||||||
uploadFile = getDropboxPath(uploadFile, client.remoteBaseDir);
|
uploadFile = getDropboxPath(uploadFile, client.remoteBaseDir);
|
||||||
@@ -520,6 +480,15 @@ export const uploadToRemote = async (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mtime = 0;
|
||||||
|
let ctime = 0;
|
||||||
|
const s = await vault?.adapter?.stat(fileOrFolderPath);
|
||||||
|
if (s !== undefined && s !== null) {
|
||||||
|
mtime = Math.floor(s.mtime / 1000.0) * 1000;
|
||||||
|
ctime = Math.floor(s.ctime / 1000.0) * 1000;
|
||||||
|
}
|
||||||
|
const mtimeStr = new Date(mtime).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||||
|
|
||||||
const isFolder = fileOrFolderPath.endsWith("/");
|
const isFolder = fileOrFolderPath.endsWith("/");
|
||||||
|
|
||||||
if (isFolder && isRecursively) {
|
if (isFolder && isRecursively) {
|
||||||
@@ -529,8 +498,8 @@ export const uploadToRemote = async (
|
|||||||
throw Error(`you specify uploadRaw, but you also provide a folder key!`);
|
throw Error(`you specify uploadRaw, but you also provide a folder key!`);
|
||||||
}
|
}
|
||||||
// folder
|
// folder
|
||||||
if (password === "") {
|
if (cipher.isPasswordEmpty() || cipher.isFolderAware()) {
|
||||||
// if not encrypted, mkdir a remote folder
|
// if not encrypted, || encrypted isFolderAware, mkdir a remote folder
|
||||||
if (foldersCreatedBefore?.has(uploadFile)) {
|
if (foldersCreatedBefore?.has(uploadFile)) {
|
||||||
// created, pass
|
// created, pass
|
||||||
} else {
|
} else {
|
||||||
@@ -557,18 +526,26 @@ export const uploadToRemote = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const res = await getRemoteMeta(client, uploadFile);
|
const res = await getRemoteMeta(client, uploadFile);
|
||||||
return res;
|
return {
|
||||||
|
entity: res,
|
||||||
|
mtimeCli: mtime,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
// if encrypted, upload a fake file with the encrypted file name
|
// if encrypted && !isFolderAware(),
|
||||||
|
// upload a fake file with the encrypted file name
|
||||||
await retryReq(
|
await retryReq(
|
||||||
() =>
|
() =>
|
||||||
client.dropbox.filesUpload({
|
client.dropbox.filesUpload({
|
||||||
path: uploadFile,
|
path: uploadFile,
|
||||||
contents: "",
|
contents: "",
|
||||||
|
client_modified: mtimeStr,
|
||||||
}),
|
}),
|
||||||
fileOrFolderPath
|
fileOrFolderPath
|
||||||
);
|
);
|
||||||
return await getRemoteMeta(client, uploadFile);
|
return {
|
||||||
|
entity: await getRemoteMeta(client, uploadFile),
|
||||||
|
mtimeCli: mtime,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// file
|
// file
|
||||||
@@ -581,11 +558,16 @@ export const uploadToRemote = async (
|
|||||||
localContent = rawContent;
|
localContent = rawContent;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (vault === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`the vault variable is not passed but we want to read ${fileOrFolderPath} for Dropbox`
|
||||||
|
);
|
||||||
|
}
|
||||||
localContent = await vault.adapter.readBinary(fileOrFolderPath);
|
localContent = await vault.adapter.readBinary(fileOrFolderPath);
|
||||||
}
|
}
|
||||||
let remoteContent = localContent;
|
let remoteContent = localContent;
|
||||||
if (password !== "") {
|
if (!cipher.isPasswordEmpty()) {
|
||||||
remoteContent = await encryptArrayBuffer(localContent, password);
|
remoteContent = await cipher.encryptContent(localContent);
|
||||||
}
|
}
|
||||||
// in dropbox, we don't need to create folders before uploading! cool!
|
// in dropbox, we don't need to create folders before uploading! cool!
|
||||||
// TODO: filesUploadSession for larger files (>=150 MB)
|
// TODO: filesUploadSession for larger files (>=150 MB)
|
||||||
@@ -598,6 +580,7 @@ export const uploadToRemote = async (
|
|||||||
mode: {
|
mode: {
|
||||||
".tag": "overwrite",
|
".tag": "overwrite",
|
||||||
},
|
},
|
||||||
|
client_modified: mtimeStr,
|
||||||
}),
|
}),
|
||||||
fileOrFolderPath
|
fileOrFolderPath
|
||||||
);
|
);
|
||||||
@@ -611,7 +594,10 @@ export const uploadToRemote = async (
|
|||||||
foldersCreatedBefore?.add(dir);
|
foldersCreatedBefore?.add(dir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return await getRemoteMeta(client, uploadFile);
|
return {
|
||||||
|
entity: await getRemoteMeta(client, uploadFile),
|
||||||
|
mtimeCli: mtime,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -626,13 +612,13 @@ export const listAllFromRemote = async (client: WrappedDropboxClient) => {
|
|||||||
if (res.status !== 200) {
|
if (res.status !== 200) {
|
||||||
throw Error(JSON.stringify(res));
|
throw Error(JSON.stringify(res));
|
||||||
}
|
}
|
||||||
// log.info(res);
|
// console.info(res);
|
||||||
|
|
||||||
const contents = res.result.entries;
|
const contents = res.result.entries;
|
||||||
const unifiedContents = contents
|
const unifiedContents = contents
|
||||||
.filter((x) => x[".tag"] !== "deleted")
|
.filter((x) => x[".tag"] !== "deleted")
|
||||||
.filter((x) => x.path_display !== `/${client.remoteBaseDir}`)
|
.filter((x) => x.path_display !== `/${client.remoteBaseDir}`)
|
||||||
.map((x) => fromDropboxItemToRemoteItem(x, client.remoteBaseDir));
|
.map((x) => fromDropboxItemToEntity(x, client.remoteBaseDir));
|
||||||
|
|
||||||
while (res.result.has_more) {
|
while (res.result.has_more) {
|
||||||
res = await client.dropbox.filesListFolderContinue({
|
res = await client.dropbox.filesListFolderContinue({
|
||||||
@@ -646,30 +632,30 @@ export const listAllFromRemote = async (client: WrappedDropboxClient) => {
|
|||||||
const unifiedContents2 = contents2
|
const unifiedContents2 = contents2
|
||||||
.filter((x) => x[".tag"] !== "deleted")
|
.filter((x) => x[".tag"] !== "deleted")
|
||||||
.filter((x) => x.path_display !== `/${client.remoteBaseDir}`)
|
.filter((x) => x.path_display !== `/${client.remoteBaseDir}`)
|
||||||
.map((x) => fromDropboxItemToRemoteItem(x, client.remoteBaseDir));
|
.map((x) => fromDropboxItemToEntity(x, client.remoteBaseDir));
|
||||||
unifiedContents.push(...unifiedContents2);
|
unifiedContents.push(...unifiedContents2);
|
||||||
}
|
}
|
||||||
|
|
||||||
fixLastModifiedTimeInplace(unifiedContents);
|
fixEntityListCasesInplace(unifiedContents);
|
||||||
|
|
||||||
return {
|
return unifiedContents;
|
||||||
Contents: unifiedContents,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadFromRemoteRaw = async (
|
const downloadFromRemoteRaw = async (
|
||||||
client: WrappedDropboxClient,
|
client: WrappedDropboxClient,
|
||||||
fileOrFolderPath: string
|
remotePath: string
|
||||||
) => {
|
) => {
|
||||||
await client.init();
|
await client.init();
|
||||||
const key = getDropboxPath(fileOrFolderPath, client.remoteBaseDir);
|
|
||||||
const rsp = await retryReq(
|
const rsp = await retryReq(
|
||||||
() =>
|
() =>
|
||||||
client.dropbox.filesDownload({
|
client.dropbox.filesDownload({
|
||||||
path: key,
|
path: remotePath,
|
||||||
}),
|
}),
|
||||||
fileOrFolderPath
|
`downloadFromRemoteRaw=${remotePath}`
|
||||||
);
|
);
|
||||||
|
if (rsp === undefined) {
|
||||||
|
throw Error(`unknown rsp from dropbox download: ${rsp}`);
|
||||||
|
}
|
||||||
if ((rsp.result as any).fileBlob !== undefined) {
|
if ((rsp.result as any).fileBlob !== undefined) {
|
||||||
// we get a Blob
|
// we get a Blob
|
||||||
const content = (rsp.result as any).fileBlob as Blob;
|
const content = (rsp.result as any).fileBlob as Blob;
|
||||||
@@ -688,7 +674,7 @@ export const downloadFromRemote = async (
|
|||||||
fileOrFolderPath: string,
|
fileOrFolderPath: string,
|
||||||
vault: Vault,
|
vault: Vault,
|
||||||
mtime: number,
|
mtime: number,
|
||||||
password: string = "",
|
cipher: Cipher,
|
||||||
remoteEncryptedKey: string = "",
|
remoteEncryptedKey: string = "",
|
||||||
skipSaving: boolean = false
|
skipSaving: boolean = false
|
||||||
) => {
|
) => {
|
||||||
@@ -709,14 +695,14 @@ export const downloadFromRemote = async (
|
|||||||
return new ArrayBuffer(0);
|
return new ArrayBuffer(0);
|
||||||
} else {
|
} else {
|
||||||
let downloadFile = fileOrFolderPath;
|
let downloadFile = fileOrFolderPath;
|
||||||
if (password !== "") {
|
if (!cipher.isPasswordEmpty()) {
|
||||||
downloadFile = remoteEncryptedKey;
|
downloadFile = remoteEncryptedKey;
|
||||||
}
|
}
|
||||||
downloadFile = getDropboxPath(downloadFile, client.remoteBaseDir);
|
downloadFile = getDropboxPath(downloadFile, client.remoteBaseDir);
|
||||||
const remoteContent = await downloadFromRemoteRaw(client, downloadFile);
|
const remoteContent = await downloadFromRemoteRaw(client, downloadFile);
|
||||||
let localContent = remoteContent;
|
let localContent = remoteContent;
|
||||||
if (password !== "") {
|
if (!cipher.isPasswordEmpty()) {
|
||||||
localContent = await decryptArrayBuffer(remoteContent, password);
|
localContent = await cipher.decryptContent(remoteContent);
|
||||||
}
|
}
|
||||||
if (!skipSaving) {
|
if (!skipSaving) {
|
||||||
await vault.adapter.writeBinary(fileOrFolderPath, localContent, {
|
await vault.adapter.writeBinary(fileOrFolderPath, localContent, {
|
||||||
@@ -730,14 +716,14 @@ export const downloadFromRemote = async (
|
|||||||
export const deleteFromRemote = async (
|
export const deleteFromRemote = async (
|
||||||
client: WrappedDropboxClient,
|
client: WrappedDropboxClient,
|
||||||
fileOrFolderPath: string,
|
fileOrFolderPath: string,
|
||||||
password: string = "",
|
cipher: Cipher,
|
||||||
remoteEncryptedKey: string = ""
|
remoteEncryptedKey: string = ""
|
||||||
) => {
|
) => {
|
||||||
if (fileOrFolderPath === "/") {
|
if (fileOrFolderPath === "/") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let remoteFileName = fileOrFolderPath;
|
let remoteFileName = fileOrFolderPath;
|
||||||
if (password !== "") {
|
if (!cipher.isPasswordEmpty()) {
|
||||||
remoteFileName = remoteEncryptedKey;
|
remoteFileName = remoteEncryptedKey;
|
||||||
}
|
}
|
||||||
remoteFileName = getDropboxPath(remoteFileName, client.remoteBaseDir);
|
remoteFileName = getDropboxPath(remoteFileName, client.remoteBaseDir);
|
||||||
@@ -762,13 +748,14 @@ export const checkConnectivity = async (
|
|||||||
callbackFunc?: any
|
callbackFunc?: any
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const results = await getRemoteMeta(client, "/");
|
await client.init();
|
||||||
|
const results = await getRemoteMeta(client, `/${client.remoteBaseDir}`);
|
||||||
if (results === undefined) {
|
if (results === undefined) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.debug(err);
|
console.debug(err);
|
||||||
if (callbackFunc !== undefined) {
|
if (callbackFunc !== undefined) {
|
||||||
callbackFunc(err);
|
callbackFunc(err);
|
||||||
}
|
}
|
||||||
|
|||||||
+248
-136
@@ -2,6 +2,7 @@ import { CryptoProvider, PublicClientApplication } from "@azure/msal-node";
|
|||||||
import { AuthenticationProvider } from "@microsoft/microsoft-graph-client";
|
import { AuthenticationProvider } from "@microsoft/microsoft-graph-client";
|
||||||
import type {
|
import type {
|
||||||
DriveItem,
|
DriveItem,
|
||||||
|
FileSystemInfo,
|
||||||
UploadSession,
|
UploadSession,
|
||||||
User,
|
User,
|
||||||
} from "@microsoft/microsoft-graph-types";
|
} from "@microsoft/microsoft-graph-types";
|
||||||
@@ -13,25 +14,24 @@ import {
|
|||||||
DEFAULT_CONTENT_TYPE,
|
DEFAULT_CONTENT_TYPE,
|
||||||
OAUTH2_FORCE_EXPIRE_MILLISECONDS,
|
OAUTH2_FORCE_EXPIRE_MILLISECONDS,
|
||||||
OnedriveConfig,
|
OnedriveConfig,
|
||||||
RemoteItem,
|
Entity,
|
||||||
|
UploadedType,
|
||||||
} from "./baseTypes";
|
} from "./baseTypes";
|
||||||
import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
|
|
||||||
import {
|
import {
|
||||||
bufferToArrayBuffer,
|
bufferToArrayBuffer,
|
||||||
getRandomArrayBuffer,
|
getRandomArrayBuffer,
|
||||||
getRandomIntInclusive,
|
getRandomIntInclusive,
|
||||||
mkdirpInVault,
|
mkdirpInVault,
|
||||||
} from "./misc";
|
} from "./misc";
|
||||||
|
import { Cipher } from "./encryptUnified";
|
||||||
import { log } from "./moreOnLog";
|
|
||||||
|
|
||||||
const SCOPES = ["User.Read", "Files.ReadWrite.AppFolder", "offline_access"];
|
const SCOPES = ["User.Read", "Files.ReadWrite.AppFolder", "offline_access"];
|
||||||
const REDIRECT_URI = `obsidian://${COMMAND_CALLBACK_ONEDRIVE}`;
|
const REDIRECT_URI = `obsidian://${COMMAND_CALLBACK_ONEDRIVE}`;
|
||||||
|
|
||||||
export const DEFAULT_ONEDRIVE_CONFIG: OnedriveConfig = {
|
export const DEFAULT_ONEDRIVE_CONFIG: OnedriveConfig = {
|
||||||
accessToken: "",
|
accessToken: "",
|
||||||
clientID: process.env.DEFAULT_ONEDRIVE_CLIENT_ID,
|
clientID: process.env.DEFAULT_ONEDRIVE_CLIENT_ID ?? "",
|
||||||
authority: process.env.DEFAULT_ONEDRIVE_AUTHORITY,
|
authority: process.env.DEFAULT_ONEDRIVE_AUTHORITY ?? "",
|
||||||
refreshToken: "",
|
refreshToken: "",
|
||||||
accessTokenExpiresInSeconds: 0,
|
accessTokenExpiresInSeconds: 0,
|
||||||
accessTokenExpiresAtTime: 0,
|
accessTokenExpiresAtTime: 0,
|
||||||
@@ -105,7 +105,8 @@ export const sendAuthReq = async (
|
|||||||
clientID: string,
|
clientID: string,
|
||||||
authority: string,
|
authority: string,
|
||||||
authCode: string,
|
authCode: string,
|
||||||
verifier: string
|
verifier: string,
|
||||||
|
errorCallBack: any
|
||||||
) => {
|
) => {
|
||||||
// // original code snippets for references
|
// // original code snippets for references
|
||||||
// const authResponse = await pca.acquireTokenByCode({
|
// const authResponse = await pca.acquireTokenByCode({
|
||||||
@@ -114,8 +115,8 @@ export const sendAuthReq = async (
|
|||||||
// code: authCode,
|
// code: authCode,
|
||||||
// codeVerifier: verifier, // PKCE Code Verifier
|
// codeVerifier: verifier, // PKCE Code Verifier
|
||||||
// });
|
// });
|
||||||
// log.info('authResponse')
|
// console.info('authResponse')
|
||||||
// log.info(authResponse)
|
// console.info(authResponse)
|
||||||
// return authResponse;
|
// return authResponse;
|
||||||
|
|
||||||
// Because of the CORS problem,
|
// Because of the CORS problem,
|
||||||
@@ -123,28 +124,33 @@ export const sendAuthReq = async (
|
|||||||
// instead of using msal
|
// instead of using msal
|
||||||
// https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow
|
// https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow
|
||||||
// https://docs.microsoft.com/en-us/onedrive/developer/rest-api/getting-started/graph-oauth?view=odsp-graph-online#code-flow
|
// https://docs.microsoft.com/en-us/onedrive/developer/rest-api/getting-started/graph-oauth?view=odsp-graph-online#code-flow
|
||||||
const rsp1 = await request({
|
try {
|
||||||
url: `${authority}/oauth2/v2.0/token`,
|
const rsp1 = await request({
|
||||||
method: "POST",
|
url: `${authority}/oauth2/v2.0/token`,
|
||||||
contentType: "application/x-www-form-urlencoded",
|
method: "POST",
|
||||||
body: new URLSearchParams({
|
contentType: "application/x-www-form-urlencoded",
|
||||||
tenant: "consumers",
|
body: new URLSearchParams({
|
||||||
client_id: clientID,
|
tenant: "consumers",
|
||||||
scope: SCOPES.join(" "),
|
client_id: clientID,
|
||||||
code: authCode,
|
scope: SCOPES.join(" "),
|
||||||
redirect_uri: REDIRECT_URI,
|
code: authCode,
|
||||||
grant_type: "authorization_code",
|
redirect_uri: REDIRECT_URI,
|
||||||
code_verifier: verifier,
|
grant_type: "authorization_code",
|
||||||
}).toString(),
|
code_verifier: verifier,
|
||||||
});
|
}).toString(),
|
||||||
|
});
|
||||||
|
|
||||||
const rsp2 = JSON.parse(rsp1);
|
const rsp2 = JSON.parse(rsp1);
|
||||||
// log.info(rsp2);
|
// console.info(rsp2);
|
||||||
|
|
||||||
if (rsp2.error !== undefined) {
|
if (rsp2.error !== undefined) {
|
||||||
return rsp2 as AccessCodeResponseFailedType;
|
return rsp2 as AccessCodeResponseFailedType;
|
||||||
} else {
|
} else {
|
||||||
return rsp2 as AccessCodeResponseSuccessfulType;
|
return rsp2 as AccessCodeResponseSuccessfulType;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
await errorCallBack(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -154,26 +160,31 @@ export const sendRefreshTokenReq = async (
|
|||||||
refreshToken: string
|
refreshToken: string
|
||||||
) => {
|
) => {
|
||||||
// also use Obsidian request to bypass CORS issue.
|
// also use Obsidian request to bypass CORS issue.
|
||||||
const rsp1 = await request({
|
try {
|
||||||
url: `${authority}/oauth2/v2.0/token`,
|
const rsp1 = await request({
|
||||||
method: "POST",
|
url: `${authority}/oauth2/v2.0/token`,
|
||||||
contentType: "application/x-www-form-urlencoded",
|
method: "POST",
|
||||||
body: new URLSearchParams({
|
contentType: "application/x-www-form-urlencoded",
|
||||||
tenant: "consumers",
|
body: new URLSearchParams({
|
||||||
client_id: clientID,
|
tenant: "consumers",
|
||||||
scope: SCOPES.join(" "),
|
client_id: clientID,
|
||||||
refresh_token: refreshToken,
|
scope: SCOPES.join(" "),
|
||||||
grant_type: "refresh_token",
|
refresh_token: refreshToken,
|
||||||
}).toString(),
|
grant_type: "refresh_token",
|
||||||
});
|
}).toString(),
|
||||||
|
});
|
||||||
|
|
||||||
const rsp2 = JSON.parse(rsp1);
|
const rsp2 = JSON.parse(rsp1);
|
||||||
// log.info(rsp2);
|
// console.info(rsp2);
|
||||||
|
|
||||||
if (rsp2.error !== undefined) {
|
if (rsp2.error !== undefined) {
|
||||||
return rsp2 as AccessCodeResponseFailedType;
|
return rsp2 as AccessCodeResponseFailedType;
|
||||||
} else {
|
} else {
|
||||||
return rsp2 as AccessCodeResponseSuccessfulType;
|
return rsp2 as AccessCodeResponseSuccessfulType;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -182,12 +193,12 @@ export const setConfigBySuccessfullAuthInplace = async (
|
|||||||
authRes: AccessCodeResponseSuccessfulType,
|
authRes: AccessCodeResponseSuccessfulType,
|
||||||
saveUpdatedConfigFunc: () => Promise<any> | undefined
|
saveUpdatedConfigFunc: () => Promise<any> | undefined
|
||||||
) => {
|
) => {
|
||||||
log.info("start updating local info of OneDrive token");
|
console.info("start updating local info of OneDrive token");
|
||||||
config.accessToken = authRes.access_token;
|
config.accessToken = authRes.access_token;
|
||||||
config.accessTokenExpiresAtTime =
|
config.accessTokenExpiresAtTime =
|
||||||
Date.now() + authRes.expires_in - 5 * 60 * 1000;
|
Date.now() + authRes.expires_in - 5 * 60 * 1000;
|
||||||
config.accessTokenExpiresInSeconds = authRes.expires_in;
|
config.accessTokenExpiresInSeconds = authRes.expires_in;
|
||||||
config.refreshToken = authRes.refresh_token;
|
config.refreshToken = authRes.refresh_token!;
|
||||||
|
|
||||||
// manually set it expired after 80 days;
|
// manually set it expired after 80 days;
|
||||||
config.credentialsShouldBeDeletedAtTime =
|
config.credentialsShouldBeDeletedAtTime =
|
||||||
@@ -197,7 +208,7 @@ export const setConfigBySuccessfullAuthInplace = async (
|
|||||||
await saveUpdatedConfigFunc();
|
await saveUpdatedConfigFunc();
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("finish updating local info of Onedrive token");
|
console.info("finish updating local info of Onedrive token");
|
||||||
};
|
};
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
@@ -207,10 +218,6 @@ export const setConfigBySuccessfullAuthInplace = async (
|
|||||||
const getOnedrivePath = (fileOrFolderPath: string, remoteBaseDir: string) => {
|
const getOnedrivePath = (fileOrFolderPath: string, remoteBaseDir: string) => {
|
||||||
// https://docs.microsoft.com/en-us/onedrive/developer/rest-api/concepts/special-folders-appfolder?view=odsp-graph-online
|
// https://docs.microsoft.com/en-us/onedrive/developer/rest-api/concepts/special-folders-appfolder?view=odsp-graph-online
|
||||||
const prefix = `/drive/special/approot:/${remoteBaseDir}`;
|
const prefix = `/drive/special/approot:/${remoteBaseDir}`;
|
||||||
if (fileOrFolderPath.startsWith(prefix)) {
|
|
||||||
// already transformed, return as is
|
|
||||||
return fileOrFolderPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
let key = fileOrFolderPath;
|
let key = fileOrFolderPath;
|
||||||
if (fileOrFolderPath === "/" || fileOrFolderPath === "") {
|
if (fileOrFolderPath === "/" || fileOrFolderPath === "") {
|
||||||
@@ -221,7 +228,12 @@ const getOnedrivePath = (fileOrFolderPath: string, remoteBaseDir: string) => {
|
|||||||
key = key.slice(0, key.length - 1);
|
key = key.slice(0, key.length - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
key = `${prefix}/${key}`;
|
if (key.startsWith("/")) {
|
||||||
|
console.warn(`why the path ${key} starts with '/'? but we just go on.`);
|
||||||
|
key = `${prefix}${key}`;
|
||||||
|
} else {
|
||||||
|
key = `${prefix}/${key}`;
|
||||||
|
}
|
||||||
return key;
|
return key;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -242,14 +254,13 @@ const getNormPath = (fileOrFolderPath: string, remoteBaseDir: string) => {
|
|||||||
return fileOrFolderPath.slice(`${prefix}/`.length);
|
return fileOrFolderPath.slice(`${prefix}/`.length);
|
||||||
};
|
};
|
||||||
|
|
||||||
const constructFromDriveItemToRemoteItemError = (x: DriveItem) => {
|
const constructFromDriveItemToEntityError = (x: DriveItem) => {
|
||||||
return `parentPath="${x.parentReference.path}", selfName="${x.name}"`;
|
return `parentPath="${
|
||||||
|
x.parentReference?.path ?? "(no parentReference or path)"
|
||||||
|
}", selfName="${x.name}"`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fromDriveItemToRemoteItem = (
|
const fromDriveItemToEntity = (x: DriveItem, remoteBaseDir: string): Entity => {
|
||||||
x: DriveItem,
|
|
||||||
remoteBaseDir: string
|
|
||||||
): RemoteItem => {
|
|
||||||
let key = "";
|
let key = "";
|
||||||
|
|
||||||
// possible prefix:
|
// possible prefix:
|
||||||
@@ -260,14 +271,28 @@ const fromDriveItemToRemoteItem = (
|
|||||||
// e.g.: /Livefolders/应用/remotely-save/${remoteBaseDir}
|
// e.g.: /Livefolders/应用/remotely-save/${remoteBaseDir}
|
||||||
const SECOND_COMMON_PREFIX_REGEX = /^\/Livefolders\/[^\/]+\/remotely-save\//g;
|
const SECOND_COMMON_PREFIX_REGEX = /^\/Livefolders\/[^\/]+\/remotely-save\//g;
|
||||||
|
|
||||||
// another possibile prefix
|
// another report, why???
|
||||||
const THIRD_COMMON_PREFIX_RAW = `/drive/items/`;
|
// /drive/root:/something/app/remotely-save/${remoteBaseDir}
|
||||||
|
const THIRD_COMMON_PREFIX_REGEX =
|
||||||
|
/^\/drive\/root:\/[^\/]+\/app\/remotely-save\//g;
|
||||||
|
|
||||||
|
// another possibile prefix
|
||||||
|
const FOURTH_COMMON_PREFIX_RAW = `/drive/items/`;
|
||||||
|
|
||||||
|
if (
|
||||||
|
x.parentReference === undefined ||
|
||||||
|
x.parentReference === null ||
|
||||||
|
x.parentReference.path === undefined ||
|
||||||
|
x.parentReference.path === null
|
||||||
|
) {
|
||||||
|
throw Error("x.parentReference.path is undefinded or null");
|
||||||
|
}
|
||||||
const fullPathOriginal = `${x.parentReference.path}/${x.name}`;
|
const fullPathOriginal = `${x.parentReference.path}/${x.name}`;
|
||||||
const matchFirstPrefixRes = fullPathOriginal.match(FIRST_COMMON_PREFIX_REGEX);
|
const matchFirstPrefixRes = fullPathOriginal.match(FIRST_COMMON_PREFIX_REGEX);
|
||||||
const matchSecondPrefixRes = fullPathOriginal.match(
|
const matchSecondPrefixRes = fullPathOriginal.match(
|
||||||
SECOND_COMMON_PREFIX_REGEX
|
SECOND_COMMON_PREFIX_REGEX
|
||||||
);
|
);
|
||||||
|
const matchThirdPrefixRes = fullPathOriginal.match(THIRD_COMMON_PREFIX_REGEX);
|
||||||
if (
|
if (
|
||||||
matchFirstPrefixRes !== null &&
|
matchFirstPrefixRes !== null &&
|
||||||
fullPathOriginal.startsWith(`${matchFirstPrefixRes[0]}${remoteBaseDir}`)
|
fullPathOriginal.startsWith(`${matchFirstPrefixRes[0]}${remoteBaseDir}`)
|
||||||
@@ -280,10 +305,21 @@ const fromDriveItemToRemoteItem = (
|
|||||||
) {
|
) {
|
||||||
const foundPrefix = `${matchSecondPrefixRes[0]}${remoteBaseDir}`;
|
const foundPrefix = `${matchSecondPrefixRes[0]}${remoteBaseDir}`;
|
||||||
key = fullPathOriginal.substring(foundPrefix.length + 1);
|
key = fullPathOriginal.substring(foundPrefix.length + 1);
|
||||||
} else if (x.parentReference.path.startsWith(THIRD_COMMON_PREFIX_RAW)) {
|
} else if (
|
||||||
|
matchThirdPrefixRes !== null &&
|
||||||
|
fullPathOriginal.startsWith(`${matchThirdPrefixRes[0]}${remoteBaseDir}`)
|
||||||
|
) {
|
||||||
|
const foundPrefix = `${matchThirdPrefixRes[0]}${remoteBaseDir}`;
|
||||||
|
key = fullPathOriginal.substring(foundPrefix.length + 1);
|
||||||
|
} else if (x.parentReference.path.startsWith(FOURTH_COMMON_PREFIX_RAW)) {
|
||||||
// it's something like
|
// it's something like
|
||||||
// /drive/items/<some_id>!<another_id>:/${remoteBaseDir}/<subfolder>
|
// /drive/items/<some_id>!<another_id>:/${remoteBaseDir}/<subfolder>
|
||||||
// with uri encoded!
|
// with uri encoded!
|
||||||
|
if (x.name === undefined || x.name === null) {
|
||||||
|
throw Error(
|
||||||
|
`OneDrive item no name variable while matching ${FOURTH_COMMON_PREFIX_RAW}`
|
||||||
|
);
|
||||||
|
}
|
||||||
const parPath = decodeURIComponent(x.parentReference.path);
|
const parPath = decodeURIComponent(x.parentReference.path);
|
||||||
key = parPath.substring(parPath.indexOf(":") + 1);
|
key = parPath.substring(parPath.indexOf(":") + 1);
|
||||||
if (key.startsWith(`/${remoteBaseDir}/`)) {
|
if (key.startsWith(`/${remoteBaseDir}/`)) {
|
||||||
@@ -293,14 +329,14 @@ const fromDriveItemToRemoteItem = (
|
|||||||
key = x.name;
|
key = x.name;
|
||||||
} else {
|
} else {
|
||||||
throw Error(
|
throw Error(
|
||||||
`we meet file/folder and do not know how to deal with it:\n${constructFromDriveItemToRemoteItemError(
|
`we meet file/folder and do not know how to deal with it:\n${constructFromDriveItemToEntityError(
|
||||||
x
|
x
|
||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw Error(
|
throw Error(
|
||||||
`we meet file/folder and do not know how to deal with it:\n${constructFromDriveItemToRemoteItemError(
|
`we meet file/folder and do not know how to deal with it:\n${constructFromDriveItemToEntityError(
|
||||||
x
|
x
|
||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
@@ -310,11 +346,15 @@ const fromDriveItemToRemoteItem = (
|
|||||||
if (isFolder) {
|
if (isFolder) {
|
||||||
key = `${key}/`;
|
key = `${key}/`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mtimeSvr = Date.parse(x?.fileSystemInfo!.lastModifiedDateTime!);
|
||||||
|
const mtimeCli = Date.parse(x?.fileSystemInfo!.lastModifiedDateTime!);
|
||||||
return {
|
return {
|
||||||
key: key,
|
keyRaw: key,
|
||||||
lastModified: Date.parse(x.fileSystemInfo.lastModifiedDateTime),
|
mtimeSvr: mtimeSvr,
|
||||||
size: isFolder ? 0 : x.size,
|
mtimeCli: mtimeCli,
|
||||||
remoteType: "onedrive",
|
sizeRaw: isFolder ? 0 : x.size!,
|
||||||
|
// hash: ?? // TODO
|
||||||
etag: x.cTag || "", // do NOT use x.eTag because it changes if meta changes
|
etag: x.cTag || "", // do NOT use x.eTag because it changes if meta changes
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -356,12 +396,12 @@ class MyAuthProvider implements AuthenticationProvider {
|
|||||||
}
|
}
|
||||||
const r2 = r as AccessCodeResponseSuccessfulType;
|
const r2 = r as AccessCodeResponseSuccessfulType;
|
||||||
this.onedriveConfig.accessToken = r2.access_token;
|
this.onedriveConfig.accessToken = r2.access_token;
|
||||||
this.onedriveConfig.refreshToken = r2.refresh_token;
|
this.onedriveConfig.refreshToken = r2.refresh_token!;
|
||||||
this.onedriveConfig.accessTokenExpiresInSeconds = r2.expires_in;
|
this.onedriveConfig.accessTokenExpiresInSeconds = r2.expires_in;
|
||||||
this.onedriveConfig.accessTokenExpiresAtTime =
|
this.onedriveConfig.accessTokenExpiresAtTime =
|
||||||
currentTs + r2.expires_in * 1000 - 60 * 2 * 1000;
|
currentTs + r2.expires_in * 1000 - 60 * 2 * 1000;
|
||||||
await this.saveUpdatedConfigFunc();
|
await this.saveUpdatedConfigFunc();
|
||||||
log.info("Onedrive accessToken updated");
|
console.info("Onedrive accessToken updated");
|
||||||
return this.onedriveConfig.accessToken;
|
return this.onedriveConfig.accessToken;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -395,26 +435,26 @@ export class WrappedOnedriveClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// check vault folder
|
// check vault folder
|
||||||
// log.info(`checking remote has folder /${this.remoteBaseDir}`);
|
// console.info(`checking remote has folder /${this.remoteBaseDir}`);
|
||||||
if (this.vaultFolderExists) {
|
if (this.vaultFolderExists) {
|
||||||
// log.info(`already checked, /${this.remoteBaseDir} exist before`)
|
// console.info(`already checked, /${this.remoteBaseDir} exist before`)
|
||||||
} else {
|
} else {
|
||||||
const k = await this.getJson("/drive/special/approot/children");
|
const k = await this.getJson("/drive/special/approot/children");
|
||||||
// log.debug(k);
|
// console.debug(k);
|
||||||
this.vaultFolderExists =
|
this.vaultFolderExists =
|
||||||
(k.value as DriveItem[]).filter((x) => x.name === this.remoteBaseDir)
|
(k.value as DriveItem[]).filter((x) => x.name === this.remoteBaseDir)
|
||||||
.length > 0;
|
.length > 0;
|
||||||
if (!this.vaultFolderExists) {
|
if (!this.vaultFolderExists) {
|
||||||
log.info(`remote does not have folder /${this.remoteBaseDir}`);
|
console.info(`remote does not have folder /${this.remoteBaseDir}`);
|
||||||
await this.postJson("/drive/special/approot/children", {
|
await this.postJson("/drive/special/approot/children", {
|
||||||
name: `${this.remoteBaseDir}`,
|
name: `${this.remoteBaseDir}`,
|
||||||
folder: {},
|
folder: {},
|
||||||
"@microsoft.graph.conflictBehavior": "replace",
|
"@microsoft.graph.conflictBehavior": "replace",
|
||||||
});
|
});
|
||||||
log.info(`remote folder /${this.remoteBaseDir} created`);
|
console.info(`remote folder /${this.remoteBaseDir} created`);
|
||||||
this.vaultFolderExists = true;
|
this.vaultFolderExists = true;
|
||||||
} else {
|
} else {
|
||||||
// log.info(`remote folder /${this.remoteBaseDir} exists`);
|
// console.info(`remote folder /${this.remoteBaseDir} exists`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -431,12 +471,17 @@ export class WrappedOnedriveClient {
|
|||||||
const pathFrag = encodeURI(pathFragOrig);
|
const pathFrag = encodeURI(pathFragOrig);
|
||||||
theUrl = `${API_PREFIX}${pathFrag}`;
|
theUrl = `${API_PREFIX}${pathFrag}`;
|
||||||
}
|
}
|
||||||
|
// we want to support file name with hash #
|
||||||
|
// because every url we construct here do not contain the # symbol
|
||||||
|
// thus it should be safe to directly replace the character
|
||||||
|
theUrl = theUrl.replace(/#/g, "%23");
|
||||||
|
// console.debug(`building url: [${pathFragOrig}] => [${theUrl}]`)
|
||||||
return theUrl;
|
return theUrl;
|
||||||
};
|
};
|
||||||
|
|
||||||
getJson = async (pathFragOrig: string) => {
|
getJson = async (pathFragOrig: string) => {
|
||||||
const theUrl = this.buildUrl(pathFragOrig);
|
const theUrl = this.buildUrl(pathFragOrig);
|
||||||
log.debug(`getJson, theUrl=${theUrl}`);
|
console.debug(`getJson, theUrl=${theUrl}`);
|
||||||
return JSON.parse(
|
return JSON.parse(
|
||||||
await request({
|
await request({
|
||||||
url: theUrl,
|
url: theUrl,
|
||||||
@@ -452,7 +497,7 @@ export class WrappedOnedriveClient {
|
|||||||
|
|
||||||
postJson = async (pathFragOrig: string, payload: any) => {
|
postJson = async (pathFragOrig: string, payload: any) => {
|
||||||
const theUrl = this.buildUrl(pathFragOrig);
|
const theUrl = this.buildUrl(pathFragOrig);
|
||||||
log.debug(`postJson, theUrl=${theUrl}`);
|
console.debug(`postJson, theUrl=${theUrl}`);
|
||||||
return JSON.parse(
|
return JSON.parse(
|
||||||
await request({
|
await request({
|
||||||
url: theUrl,
|
url: theUrl,
|
||||||
@@ -468,7 +513,7 @@ export class WrappedOnedriveClient {
|
|||||||
|
|
||||||
patchJson = async (pathFragOrig: string, payload: any) => {
|
patchJson = async (pathFragOrig: string, payload: any) => {
|
||||||
const theUrl = this.buildUrl(pathFragOrig);
|
const theUrl = this.buildUrl(pathFragOrig);
|
||||||
log.debug(`patchJson, theUrl=${theUrl}`);
|
console.debug(`patchJson, theUrl=${theUrl}`);
|
||||||
return JSON.parse(
|
return JSON.parse(
|
||||||
await request({
|
await request({
|
||||||
url: theUrl,
|
url: theUrl,
|
||||||
@@ -484,7 +529,7 @@ export class WrappedOnedriveClient {
|
|||||||
|
|
||||||
deleteJson = async (pathFragOrig: string) => {
|
deleteJson = async (pathFragOrig: string) => {
|
||||||
const theUrl = this.buildUrl(pathFragOrig);
|
const theUrl = this.buildUrl(pathFragOrig);
|
||||||
log.debug(`deleteJson, theUrl=${theUrl}`);
|
console.debug(`deleteJson, theUrl=${theUrl}`);
|
||||||
if (VALID_REQURL) {
|
if (VALID_REQURL) {
|
||||||
await requestUrl({
|
await requestUrl({
|
||||||
url: theUrl,
|
url: theUrl,
|
||||||
@@ -505,12 +550,12 @@ export class WrappedOnedriveClient {
|
|||||||
|
|
||||||
putArrayBuffer = async (pathFragOrig: string, payload: ArrayBuffer) => {
|
putArrayBuffer = async (pathFragOrig: string, payload: ArrayBuffer) => {
|
||||||
const theUrl = this.buildUrl(pathFragOrig);
|
const theUrl = this.buildUrl(pathFragOrig);
|
||||||
log.debug(`putArrayBuffer, theUrl=${theUrl}`);
|
console.debug(`putArrayBuffer, theUrl=${theUrl}`);
|
||||||
// TODO:
|
// TODO:
|
||||||
// 20220401: On Android, requestUrl has issue that text becomes base64.
|
// 20220401: On Android, requestUrl has issue that text becomes base64.
|
||||||
// Use fetch everywhere instead!
|
// Use fetch everywhere instead!
|
||||||
if (false /*VALID_REQURL*/) {
|
if (false /*VALID_REQURL*/) {
|
||||||
await requestUrl({
|
const res = await requestUrl({
|
||||||
url: theUrl,
|
url: theUrl,
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: payload,
|
body: payload,
|
||||||
@@ -520,8 +565,9 @@ export class WrappedOnedriveClient {
|
|||||||
Authorization: `Bearer ${await this.authGetter.getAccessToken()}`,
|
Authorization: `Bearer ${await this.authGetter.getAccessToken()}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return res.json as DriveItem | UploadSession;
|
||||||
} else {
|
} else {
|
||||||
await fetch(theUrl, {
|
const res = await fetch(theUrl, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: payload,
|
body: payload,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -529,6 +575,7 @@ export class WrappedOnedriveClient {
|
|||||||
Authorization: `Bearer ${await this.authGetter.getAccessToken()}`,
|
Authorization: `Bearer ${await this.authGetter.getAccessToken()}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return (await res.json()) as DriveItem | UploadSession;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -548,7 +595,7 @@ export class WrappedOnedriveClient {
|
|||||||
size: number
|
size: number
|
||||||
) => {
|
) => {
|
||||||
const theUrl = this.buildUrl(pathFragOrig);
|
const theUrl = this.buildUrl(pathFragOrig);
|
||||||
log.debug(
|
console.debug(
|
||||||
`putUint8ArrayByRange, theUrl=${theUrl}, range=${rangeStart}-${
|
`putUint8ArrayByRange, theUrl=${theUrl}, range=${rangeStart}-${
|
||||||
rangeEnd - 1
|
rangeEnd - 1
|
||||||
}, len=${rangeEnd - rangeStart}, size=${size}`
|
}, len=${rangeEnd - rangeStart}, size=${size}`
|
||||||
@@ -613,7 +660,7 @@ export const listAllFromRemote = async (client: WrappedOnedriveClient) => {
|
|||||||
`/drive/special/approot:/${client.remoteBaseDir}:/delta`
|
`/drive/special/approot:/${client.remoteBaseDir}:/delta`
|
||||||
);
|
);
|
||||||
let driveItems = res.value as DriveItem[];
|
let driveItems = res.value as DriveItem[];
|
||||||
// log.debug(driveItems);
|
// console.debug(driveItems);
|
||||||
|
|
||||||
while (NEXT_LINK_KEY in res) {
|
while (NEXT_LINK_KEY in res) {
|
||||||
res = await client.getJson(res[NEXT_LINK_KEY]);
|
res = await client.getJson(res[NEXT_LINK_KEY]);
|
||||||
@@ -626,52 +673,64 @@ export const listAllFromRemote = async (client: WrappedOnedriveClient) => {
|
|||||||
await client.saveUpdatedConfigFunc();
|
await client.saveUpdatedConfigFunc();
|
||||||
}
|
}
|
||||||
|
|
||||||
// unify everything to RemoteItem
|
// unify everything to Entity
|
||||||
const unifiedContents = driveItems
|
const unifiedContents = driveItems
|
||||||
.map((x) => fromDriveItemToRemoteItem(x, client.remoteBaseDir))
|
.map((x) => fromDriveItemToEntity(x, client.remoteBaseDir))
|
||||||
.filter((x) => x.key !== "/");
|
.filter((x) => x.keyRaw !== "/");
|
||||||
|
|
||||||
return {
|
return unifiedContents;
|
||||||
Contents: unifiedContents,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getRemoteMeta = async (
|
export const getRemoteMeta = async (
|
||||||
client: WrappedOnedriveClient,
|
client: WrappedOnedriveClient,
|
||||||
fileOrFolderPath: string
|
remotePath: string
|
||||||
) => {
|
) => {
|
||||||
await client.init();
|
await client.init();
|
||||||
const remotePath = getOnedrivePath(fileOrFolderPath, client.remoteBaseDir);
|
// console.info(`remotePath=${remotePath}`);
|
||||||
// log.info(`remotePath=${remotePath}`);
|
|
||||||
const rsp = await client.getJson(
|
const rsp = await client.getJson(
|
||||||
`${remotePath}?$select=cTag,eTag,fileSystemInfo,folder,file,name,parentReference,size`
|
`${remotePath}?$select=cTag,eTag,fileSystemInfo,folder,file,name,parentReference,size`
|
||||||
);
|
);
|
||||||
// log.info(rsp);
|
// console.info(rsp);
|
||||||
const driveItem = rsp as DriveItem;
|
const driveItem = rsp as DriveItem;
|
||||||
const res = fromDriveItemToRemoteItem(driveItem, client.remoteBaseDir);
|
const res = fromDriveItemToEntity(driveItem, client.remoteBaseDir);
|
||||||
// log.info(res);
|
// console.info(res);
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const uploadToRemote = async (
|
export const uploadToRemote = async (
|
||||||
client: WrappedOnedriveClient,
|
client: WrappedOnedriveClient,
|
||||||
fileOrFolderPath: string,
|
fileOrFolderPath: string,
|
||||||
vault: Vault,
|
vault: Vault | undefined,
|
||||||
isRecursively: boolean = false,
|
isRecursively: boolean,
|
||||||
password: string = "",
|
cipher: Cipher,
|
||||||
remoteEncryptedKey: string = "",
|
remoteEncryptedKey: string = "",
|
||||||
foldersCreatedBefore: Set<string> | undefined = undefined,
|
foldersCreatedBefore: Set<string> | undefined = undefined,
|
||||||
uploadRaw: boolean = false,
|
uploadRaw: boolean = false,
|
||||||
rawContent: string | ArrayBuffer = ""
|
rawContent: string | ArrayBuffer = ""
|
||||||
) => {
|
): Promise<UploadedType> => {
|
||||||
await client.init();
|
await client.init();
|
||||||
|
|
||||||
let uploadFile = fileOrFolderPath;
|
let uploadFile = fileOrFolderPath;
|
||||||
if (password !== "") {
|
if (!cipher.isPasswordEmpty()) {
|
||||||
|
if (remoteEncryptedKey === undefined || remoteEncryptedKey === "") {
|
||||||
|
throw Error(
|
||||||
|
`uploadToRemote(onedrive) you have password but remoteEncryptedKey is empty!`
|
||||||
|
);
|
||||||
|
}
|
||||||
uploadFile = remoteEncryptedKey;
|
uploadFile = remoteEncryptedKey;
|
||||||
}
|
}
|
||||||
uploadFile = getOnedrivePath(uploadFile, client.remoteBaseDir);
|
uploadFile = getOnedrivePath(uploadFile, client.remoteBaseDir);
|
||||||
log.debug(`uploadFile=${uploadFile}`);
|
console.debug(`uploadFile=${uploadFile}`);
|
||||||
|
|
||||||
|
let mtime = 0;
|
||||||
|
let ctime = 0;
|
||||||
|
const s = await vault?.adapter?.stat(fileOrFolderPath);
|
||||||
|
if (s !== undefined && s !== null) {
|
||||||
|
mtime = s.mtime;
|
||||||
|
ctime = s.ctime;
|
||||||
|
}
|
||||||
|
const ctimeStr = new Date(ctime).toISOString();
|
||||||
|
const mtimeStr = new Date(mtime).toISOString();
|
||||||
|
|
||||||
const isFolder = fileOrFolderPath.endsWith("/");
|
const isFolder = fileOrFolderPath.endsWith("/");
|
||||||
|
|
||||||
@@ -682,31 +741,44 @@ export const uploadToRemote = async (
|
|||||||
throw Error(`you specify uploadRaw, but you also provide a folder key!`);
|
throw Error(`you specify uploadRaw, but you also provide a folder key!`);
|
||||||
}
|
}
|
||||||
// folder
|
// folder
|
||||||
if (password === "") {
|
if (cipher.isPasswordEmpty() || cipher.isFolderAware()) {
|
||||||
// if not encrypted, mkdir a remote folder
|
// if not encrypted, || encrypted isFolderAware, mkdir a remote folder
|
||||||
if (foldersCreatedBefore?.has(uploadFile)) {
|
if (foldersCreatedBefore?.has(uploadFile)) {
|
||||||
// created, pass
|
// created, pass
|
||||||
} else {
|
} else {
|
||||||
// https://stackoverflow.com/questions/56479865/creating-nested-folders-in-one-go-onedrive-api
|
// https://stackoverflow.com/questions/56479865/creating-nested-folders-in-one-go-onedrive-api
|
||||||
// use PATCH to create folder recursively!!!
|
// use PATCH to create folder recursively!!!
|
||||||
await client.patchJson(uploadFile, {
|
let k: any = {
|
||||||
folder: {},
|
folder: {},
|
||||||
"@microsoft.graph.conflictBehavior": "replace",
|
"@microsoft.graph.conflictBehavior": "replace",
|
||||||
});
|
};
|
||||||
|
if (mtime !== 0 && ctime !== 0) {
|
||||||
|
k = {
|
||||||
|
folder: {},
|
||||||
|
"@microsoft.graph.conflictBehavior": "replace",
|
||||||
|
fileSystemInfo: {
|
||||||
|
lastModifiedDateTime: mtimeStr,
|
||||||
|
createdDateTime: ctimeStr,
|
||||||
|
} as FileSystemInfo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await client.patchJson(uploadFile, k);
|
||||||
}
|
}
|
||||||
const res = await getRemoteMeta(client, uploadFile);
|
const res = await getRemoteMeta(client, uploadFile);
|
||||||
return res;
|
return {
|
||||||
|
entity: res,
|
||||||
|
mtimeCli: mtime,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
// if encrypted,
|
// if encrypted && !isFolderAware(),
|
||||||
// upload a fake, random-size file
|
// upload a fake, random-size file
|
||||||
// with the encrypted file name
|
// with the encrypted file name
|
||||||
const byteLengthRandom = getRandomIntInclusive(
|
const byteLengthRandom = getRandomIntInclusive(
|
||||||
1,
|
1,
|
||||||
65536 /* max allowed */
|
65536 /* max allowed */
|
||||||
);
|
);
|
||||||
const arrBufRandom = await encryptArrayBuffer(
|
const arrBufRandom = await cipher.encryptContent(
|
||||||
getRandomArrayBuffer(byteLengthRandom),
|
getRandomArrayBuffer(byteLengthRandom)
|
||||||
password
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// an encrypted folder is always small, we just use put here
|
// an encrypted folder is always small, we just use put here
|
||||||
@@ -716,9 +788,20 @@ export const uploadToRemote = async (
|
|||||||
})}`,
|
})}`,
|
||||||
arrBufRandom
|
arrBufRandom
|
||||||
);
|
);
|
||||||
// log.info(uploadResult)
|
if (mtime !== 0 && ctime !== 0) {
|
||||||
|
await client.patchJson(`${uploadFile}`, {
|
||||||
|
fileSystemInfo: {
|
||||||
|
lastModifiedDateTime: mtimeStr,
|
||||||
|
createdDateTime: ctimeStr,
|
||||||
|
} as FileSystemInfo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// console.info(uploadResult)
|
||||||
const res = await getRemoteMeta(client, uploadFile);
|
const res = await getRemoteMeta(client, uploadFile);
|
||||||
return res;
|
return {
|
||||||
|
entity: res,
|
||||||
|
mtimeCli: mtime,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// file
|
// file
|
||||||
@@ -731,11 +814,16 @@ export const uploadToRemote = async (
|
|||||||
localContent = rawContent;
|
localContent = rawContent;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (vault === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`the vault variable is not passed but we want to read ${fileOrFolderPath} for OneDrive`
|
||||||
|
);
|
||||||
|
}
|
||||||
localContent = await vault.adapter.readBinary(fileOrFolderPath);
|
localContent = await vault.adapter.readBinary(fileOrFolderPath);
|
||||||
}
|
}
|
||||||
let remoteContent = localContent;
|
let remoteContent = localContent;
|
||||||
if (password !== "") {
|
if (!cipher.isPasswordEmpty()) {
|
||||||
remoteContent = await encryptArrayBuffer(localContent, password);
|
remoteContent = await cipher.encryptContent(localContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
// no need to create parent folders firstly, cool!
|
// no need to create parent folders firstly, cool!
|
||||||
@@ -753,23 +841,45 @@ export const uploadToRemote = async (
|
|||||||
})}`,
|
})}`,
|
||||||
remoteContent
|
remoteContent
|
||||||
);
|
);
|
||||||
|
if (mtime !== 0 && ctime !== 0) {
|
||||||
|
await client.patchJson(`${uploadFile}`, {
|
||||||
|
fileSystemInfo: {
|
||||||
|
lastModifiedDateTime: mtimeStr,
|
||||||
|
createdDateTime: ctimeStr,
|
||||||
|
} as FileSystemInfo,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// upload large files!
|
// upload large files!
|
||||||
// ref: https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_createuploadsession?view=odsp-graph-online
|
// ref: https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_createuploadsession?view=odsp-graph-online
|
||||||
|
|
||||||
// 1. create uploadSession
|
// 1. create uploadSession
|
||||||
// uploadFile already starts with /drive/special/approot:/${remoteBaseDir}
|
// uploadFile already starts with /drive/special/approot:/${remoteBaseDir}
|
||||||
const s: UploadSession = await client.postJson(
|
let k: any = {
|
||||||
`${uploadFile}:/createUploadSession`,
|
item: {
|
||||||
{
|
"@microsoft.graph.conflictBehavior": "replace",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (mtime !== 0 && ctime !== 0) {
|
||||||
|
k = {
|
||||||
item: {
|
item: {
|
||||||
"@microsoft.graph.conflictBehavior": "replace",
|
"@microsoft.graph.conflictBehavior": "replace",
|
||||||
|
|
||||||
|
// this is only possible using uploadSession
|
||||||
|
fileSystemInfo: {
|
||||||
|
lastModifiedDateTime: mtimeStr,
|
||||||
|
createdDateTime: ctimeStr,
|
||||||
|
} as FileSystemInfo,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
}
|
||||||
|
const s: UploadSession = await client.postJson(
|
||||||
|
`${uploadFile}:/createUploadSession`,
|
||||||
|
k
|
||||||
);
|
);
|
||||||
const uploadUrl = s.uploadUrl;
|
const uploadUrl = s.uploadUrl!;
|
||||||
log.debug("uploadSession = ");
|
console.debug("uploadSession = ");
|
||||||
log.debug(s);
|
console.debug(s);
|
||||||
|
|
||||||
// 2. upload by ranges
|
// 2. upload by ranges
|
||||||
// convert to uint8
|
// convert to uint8
|
||||||
@@ -790,18 +900,20 @@ export const uploadToRemote = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const res = await getRemoteMeta(client, uploadFile);
|
const res = await getRemoteMeta(client, uploadFile);
|
||||||
return res;
|
return {
|
||||||
|
entity: res,
|
||||||
|
mtimeCli: mtime,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadFromRemoteRaw = async (
|
const downloadFromRemoteRaw = async (
|
||||||
client: WrappedOnedriveClient,
|
client: WrappedOnedriveClient,
|
||||||
fileOrFolderPath: string
|
remotePath: string
|
||||||
): Promise<ArrayBuffer> => {
|
): Promise<ArrayBuffer> => {
|
||||||
await client.init();
|
await client.init();
|
||||||
const key = getOnedrivePath(fileOrFolderPath, client.remoteBaseDir);
|
|
||||||
const rsp = await client.getJson(
|
const rsp = await client.getJson(
|
||||||
`${key}?$select=@microsoft.graph.downloadUrl`
|
`${remotePath}?$select=@microsoft.graph.downloadUrl`
|
||||||
);
|
);
|
||||||
const downloadUrl: string = rsp["@microsoft.graph.downloadUrl"];
|
const downloadUrl: string = rsp["@microsoft.graph.downloadUrl"];
|
||||||
if (VALID_REQURL) {
|
if (VALID_REQURL) {
|
||||||
@@ -824,7 +936,7 @@ export const downloadFromRemote = async (
|
|||||||
fileOrFolderPath: string,
|
fileOrFolderPath: string,
|
||||||
vault: Vault,
|
vault: Vault,
|
||||||
mtime: number,
|
mtime: number,
|
||||||
password: string = "",
|
cipher: Cipher,
|
||||||
remoteEncryptedKey: string = "",
|
remoteEncryptedKey: string = "",
|
||||||
skipSaving: boolean = false
|
skipSaving: boolean = false
|
||||||
) => {
|
) => {
|
||||||
@@ -842,14 +954,14 @@ export const downloadFromRemote = async (
|
|||||||
return new ArrayBuffer(0);
|
return new ArrayBuffer(0);
|
||||||
} else {
|
} else {
|
||||||
let downloadFile = fileOrFolderPath;
|
let downloadFile = fileOrFolderPath;
|
||||||
if (password !== "") {
|
if (!cipher.isPasswordEmpty()) {
|
||||||
downloadFile = remoteEncryptedKey;
|
downloadFile = remoteEncryptedKey;
|
||||||
}
|
}
|
||||||
downloadFile = getOnedrivePath(downloadFile, client.remoteBaseDir);
|
downloadFile = getOnedrivePath(downloadFile, client.remoteBaseDir);
|
||||||
const remoteContent = await downloadFromRemoteRaw(client, downloadFile);
|
const remoteContent = await downloadFromRemoteRaw(client, downloadFile);
|
||||||
let localContent = remoteContent;
|
let localContent = remoteContent;
|
||||||
if (password !== "") {
|
if (!cipher.isPasswordEmpty()) {
|
||||||
localContent = await decryptArrayBuffer(remoteContent, password);
|
localContent = await cipher.decryptContent(remoteContent);
|
||||||
}
|
}
|
||||||
if (!skipSaving) {
|
if (!skipSaving) {
|
||||||
await vault.adapter.writeBinary(fileOrFolderPath, localContent, {
|
await vault.adapter.writeBinary(fileOrFolderPath, localContent, {
|
||||||
@@ -863,14 +975,14 @@ export const downloadFromRemote = async (
|
|||||||
export const deleteFromRemote = async (
|
export const deleteFromRemote = async (
|
||||||
client: WrappedOnedriveClient,
|
client: WrappedOnedriveClient,
|
||||||
fileOrFolderPath: string,
|
fileOrFolderPath: string,
|
||||||
password: string = "",
|
cipher: Cipher,
|
||||||
remoteEncryptedKey: string = ""
|
remoteEncryptedKey: string = ""
|
||||||
) => {
|
) => {
|
||||||
if (fileOrFolderPath === "/") {
|
if (fileOrFolderPath === "/") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let remoteFileName = fileOrFolderPath;
|
let remoteFileName = fileOrFolderPath;
|
||||||
if (password !== "") {
|
if (!cipher.isPasswordEmpty()) {
|
||||||
remoteFileName = remoteEncryptedKey;
|
remoteFileName = remoteEncryptedKey;
|
||||||
}
|
}
|
||||||
remoteFileName = getOnedrivePath(remoteFileName, client.remoteBaseDir);
|
remoteFileName = getOnedrivePath(remoteFileName, client.remoteBaseDir);
|
||||||
@@ -887,7 +999,7 @@ export const checkConnectivity = async (
|
|||||||
const k = await getUserDisplayName(client);
|
const k = await getUserDisplayName(client);
|
||||||
return k !== "<unknown display name>";
|
return k !== "<unknown display name>";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.debug(err);
|
console.debug(err);
|
||||||
if (callbackFunc !== undefined) {
|
if (callbackFunc !== undefined) {
|
||||||
callbackFunc(err);
|
callbackFunc(err);
|
||||||
}
|
}
|
||||||
|
|||||||
+235
-59
@@ -22,17 +22,17 @@ import { buildQueryString } from "@smithy/querystring-builder";
|
|||||||
import { HeaderBag, HttpHandlerOptions, Provider } from "@aws-sdk/types";
|
import { HeaderBag, HttpHandlerOptions, Provider } from "@aws-sdk/types";
|
||||||
import { Buffer } from "buffer";
|
import { Buffer } from "buffer";
|
||||||
import * as mime from "mime-types";
|
import * as mime from "mime-types";
|
||||||
import { Vault, requestUrl, RequestUrlParam } from "obsidian";
|
import { Vault, requestUrl, RequestUrlParam, Platform } from "obsidian";
|
||||||
import { Readable } from "stream";
|
import { Readable } from "stream";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import AggregateError from "aggregate-error";
|
import AggregateError from "aggregate-error";
|
||||||
import {
|
import {
|
||||||
DEFAULT_CONTENT_TYPE,
|
DEFAULT_CONTENT_TYPE,
|
||||||
RemoteItem,
|
Entity,
|
||||||
S3Config,
|
S3Config,
|
||||||
|
UploadedType,
|
||||||
VALID_REQURL,
|
VALID_REQURL,
|
||||||
} from "./baseTypes";
|
} from "./baseTypes";
|
||||||
import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
|
|
||||||
import {
|
import {
|
||||||
arrayBufferToBuffer,
|
arrayBufferToBuffer,
|
||||||
bufferToArrayBuffer,
|
bufferToArrayBuffer,
|
||||||
@@ -41,7 +41,8 @@ import {
|
|||||||
|
|
||||||
export { S3Client } from "@aws-sdk/client-s3";
|
export { S3Client } from "@aws-sdk/client-s3";
|
||||||
|
|
||||||
import { log } from "./moreOnLog";
|
import PQueue from "p-queue";
|
||||||
|
import { Cipher } from "./encryptUnified";
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
// special handler using Obsidian requestUrl
|
// special handler using Obsidian requestUrl
|
||||||
@@ -54,7 +55,7 @@ import { log } from "./moreOnLog";
|
|||||||
* But this uses Obsidian requestUrl instead.
|
* But this uses Obsidian requestUrl instead.
|
||||||
*/
|
*/
|
||||||
class ObsHttpHandler extends FetchHttpHandler {
|
class ObsHttpHandler extends FetchHttpHandler {
|
||||||
requestTimeoutInMs: number;
|
requestTimeoutInMs: number | undefined;
|
||||||
constructor(options?: FetchHttpHandlerOptions) {
|
constructor(options?: FetchHttpHandlerOptions) {
|
||||||
super(options);
|
super(options);
|
||||||
this.requestTimeoutInMs =
|
this.requestTimeoutInMs =
|
||||||
@@ -94,7 +95,7 @@ class ObsHttpHandler extends FetchHttpHandler {
|
|||||||
transformedHeaders[keyLower] = request.headers[key];
|
transformedHeaders[keyLower] = request.headers[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
let contentType: string = undefined;
|
let contentType: string | undefined = undefined;
|
||||||
if (transformedHeaders["content-type"] !== undefined) {
|
if (transformedHeaders["content-type"] !== undefined) {
|
||||||
contentType = transformedHeaders["content-type"];
|
contentType = transformedHeaders["content-type"];
|
||||||
}
|
}
|
||||||
@@ -155,7 +156,7 @@ class ObsHttpHandler extends FetchHttpHandler {
|
|||||||
// other stuffs
|
// other stuffs
|
||||||
////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
export const DEFAULT_S3_CONFIG = {
|
export const DEFAULT_S3_CONFIG: S3Config = {
|
||||||
s3Endpoint: "",
|
s3Endpoint: "",
|
||||||
s3Region: "",
|
s3Region: "",
|
||||||
s3AccessKeyID: "",
|
s3AccessKeyID: "",
|
||||||
@@ -165,6 +166,7 @@ export const DEFAULT_S3_CONFIG = {
|
|||||||
partsConcurrency: 20,
|
partsConcurrency: 20,
|
||||||
forcePathStyle: false,
|
forcePathStyle: false,
|
||||||
remotePrefix: "",
|
remotePrefix: "",
|
||||||
|
useAccurateMTime: false, // it causes money, disable by default
|
||||||
};
|
};
|
||||||
|
|
||||||
export type S3ObjectType = _Object;
|
export type S3ObjectType = _Object;
|
||||||
@@ -218,28 +220,82 @@ const getLocalNoPrefixPath = (
|
|||||||
return fileOrFolderPathWithRemotePrefix.slice(`${remotePrefix}`.length);
|
return fileOrFolderPathWithRemotePrefix.slice(`${remotePrefix}`.length);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fromS3ObjectToRemoteItem = (x: S3ObjectType, remotePrefix: string) => {
|
const fromS3ObjectToEntity = (
|
||||||
return {
|
x: S3ObjectType,
|
||||||
key: getLocalNoPrefixPath(x.Key, remotePrefix),
|
remotePrefix: string,
|
||||||
lastModified: x.LastModified.valueOf(),
|
mtimeRecords: Record<string, number>,
|
||||||
size: x.Size,
|
ctimeRecords: Record<string, number>
|
||||||
remoteType: "s3",
|
) => {
|
||||||
|
// 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);
|
||||||
|
const r: Entity = {
|
||||||
|
keyRaw: key,
|
||||||
|
mtimeSvr: mtimeSvr,
|
||||||
|
mtimeCli: mtimeCli,
|
||||||
|
sizeRaw: x.Size!,
|
||||||
etag: x.ETag,
|
etag: x.ETag,
|
||||||
} as RemoteItem;
|
synthesizedFolder: false,
|
||||||
|
};
|
||||||
|
return r;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fromS3HeadObjectToRemoteItem = (
|
const fromS3HeadObjectToEntity = (
|
||||||
fileOrFolderPathWithRemotePrefix: string,
|
fileOrFolderPathWithRemotePrefix: string,
|
||||||
x: HeadObjectCommandOutput,
|
x: HeadObjectCommandOutput,
|
||||||
remotePrefix: string
|
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 {
|
return {
|
||||||
key: getLocalNoPrefixPath(fileOrFolderPathWithRemotePrefix, remotePrefix),
|
keyRaw: key,
|
||||||
lastModified: x.LastModified.valueOf(),
|
mtimeSvr: mtimeSvr,
|
||||||
size: x.ContentLength,
|
mtimeCli: mtimeCli,
|
||||||
remoteType: "s3",
|
sizeRaw: x.ContentLength,
|
||||||
etag: x.ETag,
|
etag: x.ETag,
|
||||||
} as RemoteItem;
|
} as Entity;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getS3Client = (s3Config: S3Config) => {
|
export const getS3Client = (s3Config: S3Config) => {
|
||||||
@@ -292,6 +348,7 @@ export const getRemoteMeta = async (
|
|||||||
fileOrFolderPathWithRemotePrefix: string
|
fileOrFolderPathWithRemotePrefix: string
|
||||||
) => {
|
) => {
|
||||||
if (
|
if (
|
||||||
|
s3Config.remotePrefix !== undefined &&
|
||||||
s3Config.remotePrefix !== "" &&
|
s3Config.remotePrefix !== "" &&
|
||||||
!fileOrFolderPathWithRemotePrefix.startsWith(s3Config.remotePrefix)
|
!fileOrFolderPathWithRemotePrefix.startsWith(s3Config.remotePrefix)
|
||||||
) {
|
) {
|
||||||
@@ -304,10 +361,10 @@ export const getRemoteMeta = async (
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return fromS3HeadObjectToRemoteItem(
|
return fromS3HeadObjectToEntity(
|
||||||
fileOrFolderPathWithRemotePrefix,
|
fileOrFolderPathWithRemotePrefix,
|
||||||
res,
|
res,
|
||||||
s3Config.remotePrefix
|
s3Config.remotePrefix ?? ""
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -315,18 +372,27 @@ export const uploadToRemote = async (
|
|||||||
s3Client: S3Client,
|
s3Client: S3Client,
|
||||||
s3Config: S3Config,
|
s3Config: S3Config,
|
||||||
fileOrFolderPath: string,
|
fileOrFolderPath: string,
|
||||||
vault: Vault,
|
vault: Vault | undefined,
|
||||||
isRecursively: boolean = false,
|
isRecursively: boolean,
|
||||||
password: string = "",
|
cipher: Cipher,
|
||||||
remoteEncryptedKey: string = "",
|
remoteEncryptedKey: string = "",
|
||||||
uploadRaw: boolean = false,
|
uploadRaw: boolean = false,
|
||||||
rawContent: string | ArrayBuffer = ""
|
rawContent: string | ArrayBuffer = "",
|
||||||
) => {
|
rawContentMTime: number = 0,
|
||||||
|
rawContentCTime: number = 0
|
||||||
|
): Promise<UploadedType> => {
|
||||||
|
console.debug(`uploading ${fileOrFolderPath}`);
|
||||||
let uploadFile = fileOrFolderPath;
|
let uploadFile = fileOrFolderPath;
|
||||||
if (password !== "") {
|
if (!cipher.isPasswordEmpty()) {
|
||||||
|
if (remoteEncryptedKey === undefined || remoteEncryptedKey === "") {
|
||||||
|
throw Error(
|
||||||
|
`uploadToRemote(s3) you have password but remoteEncryptedKey is empty!`
|
||||||
|
);
|
||||||
|
}
|
||||||
uploadFile = remoteEncryptedKey;
|
uploadFile = remoteEncryptedKey;
|
||||||
}
|
}
|
||||||
uploadFile = getRemoteWithPrefixPath(uploadFile, s3Config.remotePrefix);
|
uploadFile = getRemoteWithPrefixPath(uploadFile, s3Config.remotePrefix ?? "");
|
||||||
|
// console.debug(`actual uploadFile=${uploadFile}`);
|
||||||
const isFolder = fileOrFolderPath.endsWith("/");
|
const isFolder = fileOrFolderPath.endsWith("/");
|
||||||
|
|
||||||
if (isFolder && isRecursively) {
|
if (isFolder && isRecursively) {
|
||||||
@@ -336,6 +402,13 @@ export const uploadToRemote = async (
|
|||||||
throw Error(`you specify uploadRaw, but you also provide a folder key!`);
|
throw Error(`you specify uploadRaw, but you also provide a folder key!`);
|
||||||
}
|
}
|
||||||
// folder
|
// folder
|
||||||
|
let mtime = 0;
|
||||||
|
let ctime = 0;
|
||||||
|
const s = await vault?.adapter?.stat(fileOrFolderPath);
|
||||||
|
if (s !== undefined && s !== null) {
|
||||||
|
mtime = s.mtime;
|
||||||
|
ctime = s.ctime;
|
||||||
|
}
|
||||||
const contentType = DEFAULT_CONTENT_TYPE;
|
const contentType = DEFAULT_CONTENT_TYPE;
|
||||||
await s3Client.send(
|
await s3Client.send(
|
||||||
new PutObjectCommand({
|
new PutObjectCommand({
|
||||||
@@ -343,36 +416,59 @@ export const uploadToRemote = async (
|
|||||||
Key: uploadFile,
|
Key: uploadFile,
|
||||||
Body: "",
|
Body: "",
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
|
Metadata: {
|
||||||
|
MTime: `${mtime / 1000.0}`,
|
||||||
|
CTime: `${ctime / 1000.0}`,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return await getRemoteMeta(s3Client, s3Config, uploadFile);
|
const res = await getRemoteMeta(s3Client, s3Config, uploadFile);
|
||||||
|
return {
|
||||||
|
entity: res,
|
||||||
|
mtimeCli: mtime,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
// file
|
// file
|
||||||
// we ignore isRecursively parameter here
|
// we ignore isRecursively parameter here
|
||||||
let contentType = DEFAULT_CONTENT_TYPE;
|
let contentType = DEFAULT_CONTENT_TYPE;
|
||||||
if (password === "") {
|
if (cipher.isPasswordEmpty()) {
|
||||||
contentType =
|
contentType =
|
||||||
mime.contentType(
|
mime.contentType(
|
||||||
mime.lookup(fileOrFolderPath) || DEFAULT_CONTENT_TYPE
|
mime.lookup(fileOrFolderPath) || DEFAULT_CONTENT_TYPE
|
||||||
) || DEFAULT_CONTENT_TYPE;
|
) || DEFAULT_CONTENT_TYPE;
|
||||||
}
|
}
|
||||||
let localContent = undefined;
|
let localContent = undefined;
|
||||||
|
let mtime = 0;
|
||||||
|
let ctime = 0;
|
||||||
if (uploadRaw) {
|
if (uploadRaw) {
|
||||||
if (typeof rawContent === "string") {
|
if (typeof rawContent === "string") {
|
||||||
localContent = new TextEncoder().encode(rawContent).buffer;
|
localContent = new TextEncoder().encode(rawContent).buffer;
|
||||||
} else {
|
} else {
|
||||||
localContent = rawContent;
|
localContent = rawContent;
|
||||||
}
|
}
|
||||||
|
mtime = rawContentMTime;
|
||||||
|
ctime = rawContentCTime;
|
||||||
} else {
|
} else {
|
||||||
|
if (vault === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`the vault variable is not passed but we want to read ${fileOrFolderPath} for S3`
|
||||||
|
);
|
||||||
|
}
|
||||||
localContent = await vault.adapter.readBinary(fileOrFolderPath);
|
localContent = await vault.adapter.readBinary(fileOrFolderPath);
|
||||||
|
const s = await vault.adapter.stat(fileOrFolderPath);
|
||||||
|
if (s !== undefined && s !== null) {
|
||||||
|
mtime = s.mtime;
|
||||||
|
ctime = s.ctime;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let remoteContent = localContent;
|
let remoteContent = localContent;
|
||||||
if (password !== "") {
|
if (!cipher.isPasswordEmpty()) {
|
||||||
remoteContent = await encryptArrayBuffer(localContent, password);
|
remoteContent = await cipher.encryptContent(localContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bytesIn5MB = 5242880;
|
const bytesIn5MB = 5242880;
|
||||||
const body = new Uint8Array(remoteContent);
|
const body = new Uint8Array(remoteContent);
|
||||||
|
|
||||||
const upload = new Upload({
|
const upload = new Upload({
|
||||||
client: s3Client,
|
client: s3Client,
|
||||||
queueSize: s3Config.partsConcurrency, // concurrency
|
queueSize: s3Config.partsConcurrency, // concurrency
|
||||||
@@ -383,14 +479,25 @@ export const uploadToRemote = async (
|
|||||||
Key: uploadFile,
|
Key: uploadFile,
|
||||||
Body: body,
|
Body: body,
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
|
Metadata: {
|
||||||
|
MTime: `${mtime / 1000.0}`,
|
||||||
|
CTime: `${ctime / 1000.0}`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
upload.on("httpUploadProgress", (progress) => {
|
upload.on("httpUploadProgress", (progress) => {
|
||||||
// log.info(progress);
|
// console.info(progress);
|
||||||
});
|
});
|
||||||
await upload.done();
|
await upload.done();
|
||||||
|
|
||||||
return await getRemoteMeta(s3Client, s3Config, uploadFile);
|
const res = await getRemoteMeta(s3Client, s3Config, uploadFile);
|
||||||
|
// console.debug(
|
||||||
|
// `uploaded ${uploadFile} with res=${JSON.stringify(res, null, 2)}`
|
||||||
|
// );
|
||||||
|
return {
|
||||||
|
entity: res,
|
||||||
|
mtimeCli: mtime,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -407,6 +514,17 @@ const listFromRemoteRaw = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const contents = [] as _Object[];
|
const contents = [] as _Object[];
|
||||||
|
const mtimeRecords: Record<string, number> = {};
|
||||||
|
const ctimeRecords: Record<string, number> = {};
|
||||||
|
const queueHead = new PQueue({
|
||||||
|
concurrency: s3Config.partsConcurrency,
|
||||||
|
autoStart: true,
|
||||||
|
});
|
||||||
|
queueHead.on("error", (error) => {
|
||||||
|
queueHead.pause();
|
||||||
|
queueHead.clear();
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
let isTruncated = true;
|
let isTruncated = true;
|
||||||
do {
|
do {
|
||||||
@@ -420,7 +538,38 @@ const listFromRemoteRaw = async (
|
|||||||
}
|
}
|
||||||
contents.push(...rsp.Contents);
|
contents.push(...rsp.Contents);
|
||||||
|
|
||||||
isTruncated = rsp.IsTruncated;
|
if (s3Config.useAccurateMTime) {
|
||||||
|
// head requests of all objects, love it
|
||||||
|
for (const content of rsp.Contents) {
|
||||||
|
queueHead.add(async () => {
|
||||||
|
const rspHead = await s3Client.send(
|
||||||
|
new HeadObjectCommand({
|
||||||
|
Bucket: 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;
|
confCmd.ContinuationToken = rsp.NextContinuationToken;
|
||||||
if (
|
if (
|
||||||
isTruncated &&
|
isTruncated &&
|
||||||
@@ -431,21 +580,30 @@ const listFromRemoteRaw = async (
|
|||||||
}
|
}
|
||||||
} while (isTruncated);
|
} while (isTruncated);
|
||||||
|
|
||||||
|
// wait for any head requests
|
||||||
|
await queueHead.onIdle();
|
||||||
|
|
||||||
// ensemble fake rsp
|
// ensemble fake rsp
|
||||||
// in the end, we need to transform the response list
|
// in the end, we need to transform the response list
|
||||||
// back to the local contents-alike list
|
// back to the local contents-alike list
|
||||||
return {
|
return contents.map((x) =>
|
||||||
Contents: contents.map((x) =>
|
fromS3ObjectToEntity(
|
||||||
fromS3ObjectToRemoteItem(x, s3Config.remotePrefix)
|
x,
|
||||||
),
|
s3Config.remotePrefix ?? "",
|
||||||
};
|
mtimeRecords,
|
||||||
|
ctimeRecords
|
||||||
|
)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const listAllFromRemote = async (
|
export const listAllFromRemote = async (
|
||||||
s3Client: S3Client,
|
s3Client: S3Client,
|
||||||
s3Config: S3Config
|
s3Config: S3Config
|
||||||
) => {
|
) => {
|
||||||
return await listFromRemoteRaw(s3Client, s3Config, s3Config.remotePrefix);
|
const res = (
|
||||||
|
await listFromRemoteRaw(s3Client, s3Config, s3Config.remotePrefix)
|
||||||
|
).filter((x) => x.keyRaw !== "" && x.keyRaw !== "/");
|
||||||
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -456,8 +614,11 @@ export const listAllFromRemote = async (
|
|||||||
* @returns Promise<ArrayBuffer>
|
* @returns Promise<ArrayBuffer>
|
||||||
*/
|
*/
|
||||||
const getObjectBodyToArrayBuffer = async (
|
const getObjectBodyToArrayBuffer = async (
|
||||||
b: Readable | ReadableStream | Blob
|
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) {
|
if (b instanceof Readable) {
|
||||||
return (await new Promise((resolve, reject) => {
|
return (await new Promise((resolve, reject) => {
|
||||||
const chunks: Uint8Array[] = [];
|
const chunks: Uint8Array[] = [];
|
||||||
@@ -480,6 +641,7 @@ const downloadFromRemoteRaw = async (
|
|||||||
fileOrFolderPathWithRemotePrefix: string
|
fileOrFolderPathWithRemotePrefix: string
|
||||||
) => {
|
) => {
|
||||||
if (
|
if (
|
||||||
|
s3Config.remotePrefix !== undefined &&
|
||||||
s3Config.remotePrefix !== "" &&
|
s3Config.remotePrefix !== "" &&
|
||||||
!fileOrFolderPathWithRemotePrefix.startsWith(s3Config.remotePrefix)
|
!fileOrFolderPathWithRemotePrefix.startsWith(s3Config.remotePrefix)
|
||||||
) {
|
) {
|
||||||
@@ -501,8 +663,8 @@ export const downloadFromRemote = async (
|
|||||||
fileOrFolderPath: string,
|
fileOrFolderPath: string,
|
||||||
vault: Vault,
|
vault: Vault,
|
||||||
mtime: number,
|
mtime: number,
|
||||||
password: string = "",
|
cipher: Cipher,
|
||||||
remoteEncryptedKey: string = "",
|
remoteEncryptedKey: string,
|
||||||
skipSaving: boolean = false
|
skipSaving: boolean = false
|
||||||
) => {
|
) => {
|
||||||
const isFolder = fileOrFolderPath.endsWith("/");
|
const isFolder = fileOrFolderPath.endsWith("/");
|
||||||
@@ -520,18 +682,21 @@ export const downloadFromRemote = async (
|
|||||||
return new ArrayBuffer(0);
|
return new ArrayBuffer(0);
|
||||||
} else {
|
} else {
|
||||||
let downloadFile = fileOrFolderPath;
|
let downloadFile = fileOrFolderPath;
|
||||||
if (password !== "") {
|
if (!cipher.isPasswordEmpty()) {
|
||||||
downloadFile = remoteEncryptedKey;
|
downloadFile = remoteEncryptedKey;
|
||||||
}
|
}
|
||||||
downloadFile = getRemoteWithPrefixPath(downloadFile, s3Config.remotePrefix);
|
downloadFile = getRemoteWithPrefixPath(
|
||||||
|
downloadFile,
|
||||||
|
s3Config.remotePrefix ?? ""
|
||||||
|
);
|
||||||
const remoteContent = await downloadFromRemoteRaw(
|
const remoteContent = await downloadFromRemoteRaw(
|
||||||
s3Client,
|
s3Client,
|
||||||
s3Config,
|
s3Config,
|
||||||
downloadFile
|
downloadFile
|
||||||
);
|
);
|
||||||
let localContent = remoteContent;
|
let localContent = remoteContent;
|
||||||
if (password !== "") {
|
if (!cipher.isPasswordEmpty()) {
|
||||||
localContent = await decryptArrayBuffer(remoteContent, password);
|
localContent = await cipher.decryptContent(remoteContent);
|
||||||
}
|
}
|
||||||
if (!skipSaving) {
|
if (!skipSaving) {
|
||||||
await vault.adapter.writeBinary(fileOrFolderPath, localContent, {
|
await vault.adapter.writeBinary(fileOrFolderPath, localContent, {
|
||||||
@@ -553,19 +718,23 @@ export const deleteFromRemote = async (
|
|||||||
s3Client: S3Client,
|
s3Client: S3Client,
|
||||||
s3Config: S3Config,
|
s3Config: S3Config,
|
||||||
fileOrFolderPath: string,
|
fileOrFolderPath: string,
|
||||||
password: string = "",
|
cipher: Cipher,
|
||||||
remoteEncryptedKey: string = ""
|
remoteEncryptedKey: string = "",
|
||||||
|
synthesizedFolder: boolean = false
|
||||||
) => {
|
) => {
|
||||||
if (fileOrFolderPath === "/") {
|
if (fileOrFolderPath === "/") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (synthesizedFolder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let remoteFileName = fileOrFolderPath;
|
let remoteFileName = fileOrFolderPath;
|
||||||
if (password !== "") {
|
if (!cipher.isPasswordEmpty()) {
|
||||||
remoteFileName = remoteEncryptedKey;
|
remoteFileName = remoteEncryptedKey;
|
||||||
}
|
}
|
||||||
remoteFileName = getRemoteWithPrefixPath(
|
remoteFileName = getRemoteWithPrefixPath(
|
||||||
remoteFileName,
|
remoteFileName,
|
||||||
s3Config.remotePrefix
|
s3Config.remotePrefix ?? ""
|
||||||
);
|
);
|
||||||
await s3Client.send(
|
await s3Client.send(
|
||||||
new DeleteObjectCommand({
|
new DeleteObjectCommand({
|
||||||
@@ -574,9 +743,9 @@ export const deleteFromRemote = async (
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
if (fileOrFolderPath.endsWith("/") && password === "") {
|
if (fileOrFolderPath.endsWith("/") && cipher.isPasswordEmpty()) {
|
||||||
const x = await listFromRemoteRaw(s3Client, s3Config, remoteFileName);
|
const x = await listFromRemoteRaw(s3Client, s3Config, remoteFileName);
|
||||||
x.Contents.forEach(async (element) => {
|
x.forEach(async (element) => {
|
||||||
await s3Client.send(
|
await s3Client.send(
|
||||||
new DeleteObjectCommand({
|
new DeleteObjectCommand({
|
||||||
Bucket: s3Config.s3BucketName,
|
Bucket: s3Config.s3BucketName,
|
||||||
@@ -584,7 +753,7 @@ export const deleteFromRemote = async (
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
} else if (fileOrFolderPath.endsWith("/") && password !== "") {
|
} else if (fileOrFolderPath.endsWith("/") && !cipher.isPasswordEmpty()) {
|
||||||
// TODO
|
// TODO
|
||||||
} else {
|
} else {
|
||||||
// pass
|
// pass
|
||||||
@@ -609,6 +778,13 @@ export const checkConnectivity = async (
|
|||||||
callbackFunc?: any
|
callbackFunc?: any
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
|
// TODO: no universal way now, just check this in connectivity
|
||||||
|
if (Platform.isIosApp && s3Config.s3Endpoint.startsWith("http://")) {
|
||||||
|
throw Error(
|
||||||
|
`Your s3 endpoint could only be https, not http, because of the iOS restriction.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// const results = await s3Client.send(
|
// const results = await s3Client.send(
|
||||||
// new HeadBucketCommand({ Bucket: s3Config.s3BucketName })
|
// new HeadBucketCommand({ Bucket: s3Config.s3BucketName })
|
||||||
// );
|
// );
|
||||||
@@ -624,15 +800,15 @@ export const checkConnectivity = async (
|
|||||||
results.$metadata.httpStatusCode === undefined
|
results.$metadata.httpStatusCode === undefined
|
||||||
) {
|
) {
|
||||||
const err = "results or $metadata or httStatusCode is undefined";
|
const err = "results or $metadata or httStatusCode is undefined";
|
||||||
log.debug(err);
|
console.debug(err);
|
||||||
if (callbackFunc !== undefined) {
|
if (callbackFunc !== undefined) {
|
||||||
callbackFunc(err);
|
callbackFunc(err);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return results.$metadata.httpStatusCode === 200;
|
return results.$metadata.httpStatusCode === 200;
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
log.debug(err);
|
console.debug(err);
|
||||||
if (callbackFunc !== undefined) {
|
if (callbackFunc !== undefined) {
|
||||||
if (s3Config.s3Endpoint.contains(s3Config.s3BucketName)) {
|
if (s3Config.s3Endpoint.contains(s3Config.s3BucketName)) {
|
||||||
const err2 = new AggregateError([
|
const err2 = new AggregateError([
|
||||||
|
|||||||
+220
-205
@@ -1,15 +1,14 @@
|
|||||||
import { Buffer } from "buffer";
|
import { Buffer } from "buffer";
|
||||||
import { Vault, requestUrl } from "obsidian";
|
import { Platform, Vault, requestUrl } from "obsidian";
|
||||||
|
|
||||||
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 cloneDeep from "lodash/cloneDeep";
|
||||||
import { getReasonPhrase } from "http-status-codes";
|
import { getReasonPhrase } from "http-status-codes";
|
||||||
import { RemoteItem, VALID_REQURL, WebdavConfig } from "./baseTypes";
|
import { Entity, UploadedType, VALID_REQURL, WebdavConfig } from "./baseTypes";
|
||||||
import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
|
|
||||||
import { bufferToArrayBuffer, getPathFolder, mkdirpInVault } from "./misc";
|
import { bufferToArrayBuffer, getPathFolder, mkdirpInVault } from "./misc";
|
||||||
|
import { Cipher } from "./encryptUnified";
|
||||||
import { log } from "./moreOnLog";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
FileStat,
|
FileStat,
|
||||||
@@ -19,107 +18,122 @@ import type {
|
|||||||
// ResponseDataDetailed,
|
// ResponseDataDetailed,
|
||||||
} from "webdav";
|
} 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
|
// @ts-ignore
|
||||||
import { getPatcher } from "webdav/dist/web/index.js";
|
import { getPatcher } from "webdav/dist/web/index.js";
|
||||||
if (VALID_REQURL) {
|
if (VALID_REQURL) {
|
||||||
getPatcher().patch(
|
getPatcher().patch(
|
||||||
"request",
|
"request",
|
||||||
async (options: RequestOptionsWithState): Promise<Response> => {
|
async (options: RequestOptionsWithState): Promise<Response> => {
|
||||||
const transformedHeaders = { ...options.headers };
|
const transformedHeaders = objKeyToLower({ ...options.headers });
|
||||||
delete transformedHeaders["host"];
|
delete transformedHeaders["host"];
|
||||||
delete transformedHeaders["Host"];
|
|
||||||
delete transformedHeaders["content-length"];
|
delete transformedHeaders["content-length"];
|
||||||
delete transformedHeaders["Content-Length"];
|
|
||||||
|
|
||||||
const r = await requestUrl({
|
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,
|
url: options.url,
|
||||||
method: options.method,
|
method: options.method,
|
||||||
body: options.data as string | ArrayBuffer,
|
body: options.data as string | ArrayBuffer,
|
||||||
headers: transformedHeaders,
|
headers: transformedHeaders,
|
||||||
|
contentType: reqContentType,
|
||||||
|
throw: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
let contentType: string | undefined =
|
if (
|
||||||
r.headers["Content-Type"] ||
|
r.status === 401 &&
|
||||||
r.headers["content-type"] ||
|
Platform.isIosApp &&
|
||||||
options.headers["Content-Type"] ||
|
!options.url.endsWith("/") &&
|
||||||
options.headers["content-type"] ||
|
!options.url.endsWith(".md") &&
|
||||||
options.headers["Accept"] ||
|
options.method.toUpperCase() === "PROPFIND"
|
||||||
options.headers["accept"];
|
) {
|
||||||
if (contentType !== undefined) {
|
// don't ask me why,
|
||||||
contentType = contentType.toLowerCase();
|
// 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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const rspHeaders = { ...r.headers };
|
|
||||||
|
console.debug(`after request:`);
|
||||||
|
const rspHeaders = objKeyToLower({ ...r.headers });
|
||||||
|
console.debug(`rspHeaders: ${JSON.stringify(rspHeaders, null, 2)}`);
|
||||||
for (let key in rspHeaders) {
|
for (let key in rspHeaders) {
|
||||||
if (rspHeaders.hasOwnProperty(key)) {
|
if (rspHeaders.hasOwnProperty(key)) {
|
||||||
if (key === "content-disposition" || key === "Content-Disposition") {
|
// 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]);
|
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;
|
let r2: Response | undefined = undefined;
|
||||||
// if (contentType.includes("xml")) {
|
const statusText = getReasonPhrase(r.status);
|
||||||
// r2 = new Response(r.text, {
|
console.debug(`statusText: ${statusText}`);
|
||||||
// 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)) {
|
if ([101, 103, 204, 205, 304].includes(r.status)) {
|
||||||
// A null body status is a status that is 101, 103, 204, 205, or 304.
|
// A null body status is a status that is 101, 103, 204, 205, or 304.
|
||||||
// https://fetch.spec.whatwg.org/#statuses
|
// https://fetch.spec.whatwg.org/#statuses
|
||||||
// fix this: Failed to construct 'Response': Response with null body status cannot have body
|
// fix this: Failed to construct 'Response': Response with null body status cannot have body
|
||||||
r2 = new Response(null, {
|
r2 = new Response(null, {
|
||||||
status: r.status,
|
status: r.status,
|
||||||
statusText: getReasonPhrase(r.status),
|
statusText: statusText,
|
||||||
headers: rspHeaders,
|
headers: rspHeaders,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
r2 = new Response(r.arrayBuffer, {
|
r2 = new Response(r.arrayBuffer, {
|
||||||
status: r.status,
|
status: r.status,
|
||||||
statusText: getReasonPhrase(r.status),
|
statusText: statusText,
|
||||||
headers: rspHeaders,
|
headers: rspHeaders,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -138,8 +152,8 @@ export const DEFAULT_WEBDAV_CONFIG = {
|
|||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
authType: "basic",
|
authType: "basic",
|
||||||
manualRecursive: false,
|
manualRecursive: true,
|
||||||
depth: "auto_unknown",
|
depth: "manual_1",
|
||||||
remoteBaseDir: "",
|
remoteBaseDir: "",
|
||||||
} as WebdavConfig;
|
} as WebdavConfig;
|
||||||
|
|
||||||
@@ -148,8 +162,12 @@ const getWebdavPath = (fileOrFolderPath: string, remoteBaseDir: string) => {
|
|||||||
if (fileOrFolderPath === "/" || fileOrFolderPath === "") {
|
if (fileOrFolderPath === "/" || fileOrFolderPath === "") {
|
||||||
// special
|
// special
|
||||||
key = `/${remoteBaseDir}/`;
|
key = `/${remoteBaseDir}/`;
|
||||||
}
|
} else if (fileOrFolderPath.startsWith("/")) {
|
||||||
if (!fileOrFolderPath.startsWith("/")) {
|
console.warn(
|
||||||
|
`why the path ${fileOrFolderPath} starts with '/'? but we just go on.`
|
||||||
|
);
|
||||||
|
key = `/${remoteBaseDir}${fileOrFolderPath}`;
|
||||||
|
} else {
|
||||||
key = `/${remoteBaseDir}/${fileOrFolderPath}`;
|
key = `/${remoteBaseDir}/${fileOrFolderPath}`;
|
||||||
}
|
}
|
||||||
return key;
|
return key;
|
||||||
@@ -172,24 +190,25 @@ const getNormPath = (fileOrFolderPath: string, remoteBaseDir: string) => {
|
|||||||
return fileOrFolderPath.slice(`/${remoteBaseDir}/`.length);
|
return fileOrFolderPath.slice(`/${remoteBaseDir}/`.length);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fromWebdavItemToRemoteItem = (x: FileStat, remoteBaseDir: string) => {
|
const fromWebdavItemToEntity = (x: FileStat, remoteBaseDir: string) => {
|
||||||
let key = getNormPath(x.filename, remoteBaseDir);
|
let key = getNormPath(x.filename, remoteBaseDir);
|
||||||
if (x.type === "directory" && !key.endsWith("/")) {
|
if (x.type === "directory" && !key.endsWith("/")) {
|
||||||
key = `${key}/`;
|
key = `${key}/`;
|
||||||
}
|
}
|
||||||
|
const mtimeSvr = Date.parse(x.lastmod).valueOf();
|
||||||
return {
|
return {
|
||||||
key: key,
|
keyRaw: key,
|
||||||
lastModified: Date.parse(x.lastmod).valueOf(),
|
mtimeSvr: mtimeSvr,
|
||||||
size: x.size,
|
mtimeCli: mtimeSvr, // no universal way to set mtime in webdav
|
||||||
remoteType: "webdav",
|
sizeRaw: x.size,
|
||||||
etag: x.etag || undefined,
|
etag: x.etag,
|
||||||
} as RemoteItem;
|
} as Entity;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class WrappedWebdavClient {
|
export class WrappedWebdavClient {
|
||||||
webdavConfig: WebdavConfig;
|
webdavConfig: WebdavConfig;
|
||||||
remoteBaseDir: string;
|
remoteBaseDir: string;
|
||||||
client: WebDAVClient;
|
client!: WebDAVClient;
|
||||||
vaultFolderExists: boolean;
|
vaultFolderExists: boolean;
|
||||||
saveUpdatedConfigFunc: () => Promise<any>;
|
saveUpdatedConfigFunc: () => Promise<any>;
|
||||||
constructor(
|
constructor(
|
||||||
@@ -197,7 +216,8 @@ export class WrappedWebdavClient {
|
|||||||
remoteBaseDir: string,
|
remoteBaseDir: string,
|
||||||
saveUpdatedConfigFunc: () => Promise<any>
|
saveUpdatedConfigFunc: () => Promise<any>
|
||||||
) {
|
) {
|
||||||
this.webdavConfig = webdavConfig;
|
this.webdavConfig = cloneDeep(webdavConfig);
|
||||||
|
this.webdavConfig.address = encodeURI(this.webdavConfig.address);
|
||||||
this.remoteBaseDir = remoteBaseDir;
|
this.remoteBaseDir = remoteBaseDir;
|
||||||
this.vaultFolderExists = false;
|
this.vaultFolderExists = false;
|
||||||
this.saveUpdatedConfigFunc = saveUpdatedConfigFunc;
|
this.saveUpdatedConfigFunc = saveUpdatedConfigFunc;
|
||||||
@@ -205,29 +225,37 @@ export class WrappedWebdavClient {
|
|||||||
|
|
||||||
init = async () => {
|
init = async () => {
|
||||||
// init client if not inited
|
// 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 = {
|
const headers = {
|
||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
};
|
};
|
||||||
if (this.client === undefined) {
|
if (
|
||||||
if (
|
this.webdavConfig.username !== "" &&
|
||||||
this.webdavConfig.username !== "" &&
|
this.webdavConfig.password !== ""
|
||||||
this.webdavConfig.password !== ""
|
) {
|
||||||
) {
|
this.client = createClient(this.webdavConfig.address, {
|
||||||
this.client = createClient(this.webdavConfig.address, {
|
username: this.webdavConfig.username,
|
||||||
username: this.webdavConfig.username,
|
password: this.webdavConfig.password,
|
||||||
password: this.webdavConfig.password,
|
headers: headers,
|
||||||
headers: headers,
|
authType:
|
||||||
authType:
|
this.webdavConfig.authType === "digest"
|
||||||
this.webdavConfig.authType === "digest"
|
? AuthType.Digest
|
||||||
? AuthType.Digest
|
: AuthType.Password,
|
||||||
: AuthType.Password,
|
});
|
||||||
});
|
} else {
|
||||||
} else {
|
console.info("no password");
|
||||||
log.info("no password");
|
this.client = createClient(this.webdavConfig.address, {
|
||||||
this.client = createClient(this.webdavConfig.address, {
|
headers: headers,
|
||||||
headers: headers,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// check vault folder
|
// check vault folder
|
||||||
@@ -236,67 +264,30 @@ export class WrappedWebdavClient {
|
|||||||
} else {
|
} else {
|
||||||
const res = await this.client.exists(`/${this.remoteBaseDir}/`);
|
const res = await this.client.exists(`/${this.remoteBaseDir}/`);
|
||||||
if (res) {
|
if (res) {
|
||||||
// log.info("remote vault folder exits!");
|
// console.info("remote vault folder exits!");
|
||||||
this.vaultFolderExists = true;
|
this.vaultFolderExists = true;
|
||||||
} else {
|
} else {
|
||||||
log.info("remote vault folder not exists, creating");
|
console.info("remote vault folder not exists, creating");
|
||||||
await this.client.createDirectory(`/${this.remoteBaseDir}/`);
|
await this.client.createDirectory(`/${this.remoteBaseDir}/`);
|
||||||
log.info("remote vault folder created!");
|
console.info("remote vault folder created!");
|
||||||
this.vaultFolderExists = true;
|
this.vaultFolderExists = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// adjust depth parameter
|
// adjust depth parameter
|
||||||
if (this.webdavConfig.depth === "auto_unknown") {
|
if (
|
||||||
let testPassed = false;
|
this.webdavConfig.depth === "auto" ||
|
||||||
try {
|
this.webdavConfig.depth === "auto_1" ||
|
||||||
const res = await this.client.customRequest(`/${this.remoteBaseDir}/`, {
|
this.webdavConfig.depth === "auto_infinity" ||
|
||||||
method: "PROPFIND",
|
this.webdavConfig.depth === "auto_unknown"
|
||||||
headers: {
|
) {
|
||||||
Depth: "infinity",
|
this.webdavConfig.depth = "manual_1";
|
||||||
Accept: "text/plain,application/xml",
|
this.webdavConfig.manualRecursive = true;
|
||||||
},
|
if (this.saveUpdatedConfigFunc !== undefined) {
|
||||||
// responseType: "text",
|
await this.saveUpdatedConfigFunc();
|
||||||
} as any);
|
console.info(
|
||||||
if (res.status === 403) {
|
`webdav depth="auto_???" is changed to ${this.webdavConfig.depth}`
|
||||||
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}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -314,32 +305,43 @@ export const getWebdavClient = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param client
|
||||||
|
* @param remotePath It should be prefix-ed already
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
export const getRemoteMeta = async (
|
export const getRemoteMeta = async (
|
||||||
client: WrappedWebdavClient,
|
client: WrappedWebdavClient,
|
||||||
fileOrFolderPath: string
|
remotePath: string
|
||||||
) => {
|
) => {
|
||||||
await client.init();
|
await client.init();
|
||||||
const remotePath = getWebdavPath(fileOrFolderPath, client.remoteBaseDir);
|
console.debug(`getRemoteMeta remotePath = ${remotePath}`);
|
||||||
// log.info(`remotePath = ${remotePath}`);
|
|
||||||
const res = (await client.client.stat(remotePath, {
|
const res = (await client.client.stat(remotePath, {
|
||||||
details: false,
|
details: false,
|
||||||
})) as FileStat;
|
})) as FileStat;
|
||||||
return fromWebdavItemToRemoteItem(res, client.remoteBaseDir);
|
console.debug(`getRemoteMeta res=${JSON.stringify(res)}`);
|
||||||
|
return fromWebdavItemToEntity(res, client.remoteBaseDir);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const uploadToRemote = async (
|
export const uploadToRemote = async (
|
||||||
client: WrappedWebdavClient,
|
client: WrappedWebdavClient,
|
||||||
fileOrFolderPath: string,
|
fileOrFolderPath: string,
|
||||||
vault: Vault,
|
vault: Vault | undefined,
|
||||||
isRecursively: boolean = false,
|
isRecursively: boolean,
|
||||||
password: string = "",
|
cipher: Cipher,
|
||||||
remoteEncryptedKey: string = "",
|
remoteEncryptedKey: string = "",
|
||||||
uploadRaw: boolean = false,
|
uploadRaw: boolean = false,
|
||||||
rawContent: string | ArrayBuffer = ""
|
rawContent: string | ArrayBuffer = ""
|
||||||
) => {
|
): Promise<UploadedType> => {
|
||||||
await client.init();
|
await client.init();
|
||||||
let uploadFile = fileOrFolderPath;
|
let uploadFile = fileOrFolderPath;
|
||||||
if (password !== "") {
|
if (!cipher.isPasswordEmpty()) {
|
||||||
|
if (remoteEncryptedKey === undefined || remoteEncryptedKey === "") {
|
||||||
|
throw Error(
|
||||||
|
`uploadToRemote(webdav) you have password but remoteEncryptedKey is empty!`
|
||||||
|
);
|
||||||
|
}
|
||||||
uploadFile = remoteEncryptedKey;
|
uploadFile = remoteEncryptedKey;
|
||||||
}
|
}
|
||||||
uploadFile = getWebdavPath(uploadFile, client.remoteBaseDir);
|
uploadFile = getWebdavPath(uploadFile, client.remoteBaseDir);
|
||||||
@@ -353,28 +355,34 @@ export const uploadToRemote = async (
|
|||||||
throw Error(`you specify uploadRaw, but you also provide a folder key!`);
|
throw Error(`you specify uploadRaw, but you also provide a folder key!`);
|
||||||
}
|
}
|
||||||
// folder
|
// folder
|
||||||
if (password === "") {
|
if (cipher.isPasswordEmpty() || cipher.isFolderAware()) {
|
||||||
// if not encrypted, mkdir a remote folder
|
// if not encrypted, || encrypted isFolderAware, mkdir a remote folder
|
||||||
await client.client.createDirectory(uploadFile, {
|
await client.client.createDirectory(uploadFile, {
|
||||||
recursive: false, // the sync algo should guarantee no need to recursive
|
recursive: true,
|
||||||
});
|
});
|
||||||
const res = await getRemoteMeta(client, uploadFile);
|
const res = await getRemoteMeta(client, uploadFile);
|
||||||
return res;
|
return {
|
||||||
|
entity: res,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
// if encrypted, upload a fake file with the encrypted file name
|
// if encrypted && !isFolderAware(),
|
||||||
|
// upload a fake file with the encrypted file name
|
||||||
await client.client.putFileContents(uploadFile, "", {
|
await client.client.putFileContents(uploadFile, "", {
|
||||||
overwrite: true,
|
overwrite: true,
|
||||||
onUploadProgress: (progress: any) => {
|
onUploadProgress: (progress: any) => {
|
||||||
// log.info(`Uploaded ${progress.loaded} bytes of ${progress.total}`);
|
// console.info(`Uploaded ${progress.loaded} bytes of ${progress.total}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return await getRemoteMeta(client, uploadFile);
|
return {
|
||||||
|
entity: await getRemoteMeta(client, uploadFile),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// file
|
// file
|
||||||
// we ignore isRecursively parameter here
|
// we ignore isRecursively parameter here
|
||||||
let localContent = undefined;
|
let localContent: ArrayBuffer | undefined = undefined;
|
||||||
|
let mtimeCli: number | undefined = undefined;
|
||||||
if (uploadRaw) {
|
if (uploadRaw) {
|
||||||
if (typeof rawContent === "string") {
|
if (typeof rawContent === "string") {
|
||||||
localContent = new TextEncoder().encode(rawContent).buffer;
|
localContent = new TextEncoder().encode(rawContent).buffer;
|
||||||
@@ -382,26 +390,35 @@ export const uploadToRemote = async (
|
|||||||
localContent = rawContent;
|
localContent = rawContent;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (vault == undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`the vault variable is not passed but we want to read ${fileOrFolderPath} for webdav`
|
||||||
|
);
|
||||||
|
}
|
||||||
localContent = await vault.adapter.readBinary(fileOrFolderPath);
|
localContent = await vault.adapter.readBinary(fileOrFolderPath);
|
||||||
|
mtimeCli = (await vault.adapter.stat(fileOrFolderPath))?.mtime;
|
||||||
}
|
}
|
||||||
let remoteContent = localContent;
|
let remoteContent = localContent;
|
||||||
if (password !== "") {
|
if (!cipher.isPasswordEmpty()) {
|
||||||
remoteContent = await encryptArrayBuffer(localContent, password);
|
remoteContent = await cipher.encryptContent(localContent);
|
||||||
}
|
}
|
||||||
// updated 20220326: the algorithm guarantee this
|
// updated 20220326: the algorithm guarantee this
|
||||||
// // we need to create folders before uploading
|
// // we need to create folders before uploading
|
||||||
// const dir = getPathFolder(uploadFile);
|
// const dir = getPathFolder(uploadFile);
|
||||||
// if (dir !== "/" && dir !== "") {
|
// if (dir !== "/" && dir !== "") {
|
||||||
// await client.client.createDirectory(dir, { recursive: false });
|
// await client.client.createDirectory(dir, { recursive: true });
|
||||||
// }
|
// }
|
||||||
await client.client.putFileContents(uploadFile, remoteContent, {
|
await client.client.putFileContents(uploadFile, remoteContent, {
|
||||||
overwrite: true,
|
overwrite: true,
|
||||||
onUploadProgress: (progress: any) => {
|
onUploadProgress: (progress: any) => {
|
||||||
log.info(`Uploaded ${progress.loaded} bytes of ${progress.total}`);
|
console.info(`Uploaded ${progress.loaded} bytes of ${progress.total}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return await getRemoteMeta(client, uploadFile);
|
return {
|
||||||
|
entity: await getRemoteMeta(client, uploadFile),
|
||||||
|
mtimeCli: mtimeCli,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -410,7 +427,10 @@ export const listAllFromRemote = async (client: WrappedWebdavClient) => {
|
|||||||
|
|
||||||
let contents = [] as FileStat[];
|
let contents = [] as FileStat[];
|
||||||
if (
|
if (
|
||||||
|
client.webdavConfig.depth === "auto" ||
|
||||||
|
client.webdavConfig.depth === "auto_unknown" ||
|
||||||
client.webdavConfig.depth === "auto_1" ||
|
client.webdavConfig.depth === "auto_1" ||
|
||||||
|
client.webdavConfig.depth === "auto_infinity" /* don't trust auto now */ ||
|
||||||
client.webdavConfig.depth === "manual_1"
|
client.webdavConfig.depth === "manual_1"
|
||||||
) {
|
) {
|
||||||
// the remote doesn't support infinity propfind,
|
// the remote doesn't support infinity propfind,
|
||||||
@@ -418,12 +438,12 @@ export const listAllFromRemote = async (client: WrappedWebdavClient) => {
|
|||||||
const q = new Queue([`/${client.remoteBaseDir}`]);
|
const q = new Queue([`/${client.remoteBaseDir}`]);
|
||||||
const CHUNK_SIZE = 10;
|
const CHUNK_SIZE = 10;
|
||||||
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);
|
||||||
// log.debug(itemsToFetchChunks);
|
// console.debug(itemsToFetchChunks);
|
||||||
const subContents = [] as FileStat[];
|
const subContents = [] as FileStat[];
|
||||||
for (const singleChunk of itemsToFetchChunks) {
|
for (const singleChunk of itemsToFetchChunks) {
|
||||||
const r = singleChunk.map((x) => {
|
const r = singleChunk.map((x) => {
|
||||||
@@ -461,21 +481,16 @@ export const listAllFromRemote = async (client: WrappedWebdavClient) => {
|
|||||||
}
|
}
|
||||||
)) as FileStat[];
|
)) as FileStat[];
|
||||||
}
|
}
|
||||||
return {
|
return contents.map((x) => fromWebdavItemToEntity(x, client.remoteBaseDir));
|
||||||
Contents: contents.map((x) =>
|
|
||||||
fromWebdavItemToRemoteItem(x, client.remoteBaseDir)
|
|
||||||
),
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadFromRemoteRaw = async (
|
const downloadFromRemoteRaw = async (
|
||||||
client: WrappedWebdavClient,
|
client: WrappedWebdavClient,
|
||||||
fileOrFolderPath: string
|
remotePath: string
|
||||||
) => {
|
) => {
|
||||||
await client.init();
|
await client.init();
|
||||||
const p = getWebdavPath(fileOrFolderPath, client.remoteBaseDir);
|
// console.info(`getWebdavPath=${remotePath}`);
|
||||||
// console.log(`getWebdavPath=${p}`);
|
const buff = (await client.client.getFileContents(remotePath)) as BufferLike;
|
||||||
const buff = (await client.client.getFileContents(p)) as BufferLike;
|
|
||||||
if (buff instanceof ArrayBuffer) {
|
if (buff instanceof ArrayBuffer) {
|
||||||
return buff;
|
return buff;
|
||||||
} else if (buff instanceof Buffer) {
|
} else if (buff instanceof Buffer) {
|
||||||
@@ -489,7 +504,7 @@ export const downloadFromRemote = async (
|
|||||||
fileOrFolderPath: string,
|
fileOrFolderPath: string,
|
||||||
vault: Vault,
|
vault: Vault,
|
||||||
mtime: number,
|
mtime: number,
|
||||||
password: string = "",
|
cipher: Cipher,
|
||||||
remoteEncryptedKey: string = "",
|
remoteEncryptedKey: string = "",
|
||||||
skipSaving: boolean = false
|
skipSaving: boolean = false
|
||||||
) => {
|
) => {
|
||||||
@@ -510,15 +525,15 @@ export const downloadFromRemote = async (
|
|||||||
return new ArrayBuffer(0);
|
return new ArrayBuffer(0);
|
||||||
} else {
|
} else {
|
||||||
let downloadFile = fileOrFolderPath;
|
let downloadFile = fileOrFolderPath;
|
||||||
if (password !== "") {
|
if (!cipher.isPasswordEmpty()) {
|
||||||
downloadFile = remoteEncryptedKey;
|
downloadFile = remoteEncryptedKey;
|
||||||
}
|
}
|
||||||
downloadFile = getWebdavPath(downloadFile, client.remoteBaseDir);
|
downloadFile = getWebdavPath(downloadFile, client.remoteBaseDir);
|
||||||
// console.log(`downloadFile=${downloadFile}`);
|
// console.info(`downloadFile=${downloadFile}`);
|
||||||
const remoteContent = await downloadFromRemoteRaw(client, downloadFile);
|
const remoteContent = await downloadFromRemoteRaw(client, downloadFile);
|
||||||
let localContent = remoteContent;
|
let localContent = remoteContent;
|
||||||
if (password !== "") {
|
if (!cipher.isPasswordEmpty()) {
|
||||||
localContent = await decryptArrayBuffer(remoteContent, password);
|
localContent = await cipher.decryptContent(remoteContent);
|
||||||
}
|
}
|
||||||
if (!skipSaving) {
|
if (!skipSaving) {
|
||||||
await vault.adapter.writeBinary(fileOrFolderPath, localContent, {
|
await vault.adapter.writeBinary(fileOrFolderPath, localContent, {
|
||||||
@@ -532,14 +547,14 @@ export const downloadFromRemote = async (
|
|||||||
export const deleteFromRemote = async (
|
export const deleteFromRemote = async (
|
||||||
client: WrappedWebdavClient,
|
client: WrappedWebdavClient,
|
||||||
fileOrFolderPath: string,
|
fileOrFolderPath: string,
|
||||||
password: string = "",
|
cipher: Cipher,
|
||||||
remoteEncryptedKey: string = ""
|
remoteEncryptedKey: string = ""
|
||||||
) => {
|
) => {
|
||||||
if (fileOrFolderPath === "/") {
|
if (fileOrFolderPath === "/") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let remoteFileName = fileOrFolderPath;
|
let remoteFileName = fileOrFolderPath;
|
||||||
if (password !== "") {
|
if (!cipher.isPasswordEmpty()) {
|
||||||
remoteFileName = remoteEncryptedKey;
|
remoteFileName = remoteEncryptedKey;
|
||||||
}
|
}
|
||||||
remoteFileName = getWebdavPath(remoteFileName, client.remoteBaseDir);
|
remoteFileName = getWebdavPath(remoteFileName, client.remoteBaseDir);
|
||||||
@@ -547,10 +562,10 @@ export const deleteFromRemote = async (
|
|||||||
await client.init();
|
await client.init();
|
||||||
try {
|
try {
|
||||||
await client.client.deleteFile(remoteFileName);
|
await client.client.deleteFile(remoteFileName);
|
||||||
// log.info(`delete ${remoteFileName} succeeded`);
|
// console.info(`delete ${remoteFileName} succeeded`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("some error while deleting");
|
console.error("some error while deleting");
|
||||||
log.info(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -565,7 +580,7 @@ export const checkConnectivity = async (
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
const err = "Error: the url should start with http(s):// but it does not!";
|
const err = "Error: the url should start with http(s):// but it does not!";
|
||||||
log.debug(err);
|
console.error(err);
|
||||||
if (callbackFunc !== undefined) {
|
if (callbackFunc !== undefined) {
|
||||||
callbackFunc(err);
|
callbackFunc(err);
|
||||||
}
|
}
|
||||||
@@ -573,10 +588,10 @@ export const checkConnectivity = async (
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await client.init();
|
await client.init();
|
||||||
const results = await getRemoteMeta(client, "/");
|
const results = await getRemoteMeta(client, `/${client.remoteBaseDir}/`);
|
||||||
if (results === undefined) {
|
if (results === undefined) {
|
||||||
const err = "results is undefined";
|
const err = "results is undefined";
|
||||||
log.debug(err);
|
console.error(err);
|
||||||
if (callbackFunc !== undefined) {
|
if (callbackFunc !== undefined) {
|
||||||
callbackFunc(err);
|
callbackFunc(err);
|
||||||
}
|
}
|
||||||
@@ -584,7 +599,7 @@ export const checkConnectivity = async (
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.debug(err);
|
console.error(err);
|
||||||
if (callbackFunc !== undefined) {
|
if (callbackFunc !== undefined) {
|
||||||
callbackFunc(err);
|
callbackFunc(err);
|
||||||
}
|
}
|
||||||
|
|||||||
+297
-258
@@ -11,27 +11,26 @@ import {
|
|||||||
import type { TextComponent } from "obsidian";
|
import type { TextComponent } from "obsidian";
|
||||||
import { createElement, Eye, EyeOff } from "lucide";
|
import { createElement, Eye, EyeOff } from "lucide";
|
||||||
import {
|
import {
|
||||||
|
API_VER_ENSURE_REQURL_OK,
|
||||||
API_VER_REQURL,
|
API_VER_REQURL,
|
||||||
|
ConflictActionType,
|
||||||
DEFAULT_DEBUG_FOLDER,
|
DEFAULT_DEBUG_FOLDER,
|
||||||
|
EmptyFolderCleanType,
|
||||||
SUPPORTED_SERVICES_TYPE,
|
SUPPORTED_SERVICES_TYPE,
|
||||||
SUPPORTED_SERVICES_TYPE_WITH_REMOTE_BASE_DIR,
|
SUPPORTED_SERVICES_TYPE_WITH_REMOTE_BASE_DIR,
|
||||||
|
SyncDirectionType,
|
||||||
VALID_REQURL,
|
VALID_REQURL,
|
||||||
WebdavAuthType,
|
WebdavAuthType,
|
||||||
WebdavDepthType,
|
WebdavDepthType,
|
||||||
|
CipherMethodType,
|
||||||
} from "./baseTypes";
|
} from "./baseTypes";
|
||||||
import {
|
import { exportVaultSyncPlansToFiles } from "./debugMode";
|
||||||
exportVaultSyncPlansToFiles,
|
|
||||||
exportVaultLoggerOutputToFiles,
|
|
||||||
} from "./debugMode";
|
|
||||||
import { exportQrCodeUri } from "./importExport";
|
import { exportQrCodeUri } from "./importExport";
|
||||||
import {
|
import {
|
||||||
clearAllSyncMetaMapping,
|
clearAllPrevSyncRecordByVault,
|
||||||
clearAllSyncPlanRecords,
|
clearAllSyncPlanRecords,
|
||||||
destroyDBs,
|
destroyDBs,
|
||||||
clearAllLoggerOutputRecords,
|
upsertLastSuccessSyncTimeByVault,
|
||||||
insertLoggerOutputByVault,
|
|
||||||
clearExpiredLoggerOutputRecords,
|
|
||||||
upsertLastSuccessSyncByVault,
|
|
||||||
} from "./localdb";
|
} from "./localdb";
|
||||||
import type RemotelySavePlugin from "./main"; // unavoidable
|
import type RemotelySavePlugin from "./main"; // unavoidable
|
||||||
import { RemoteClient } from "./remote";
|
import { RemoteClient } from "./remote";
|
||||||
@@ -47,14 +46,11 @@ import {
|
|||||||
} from "./remoteForOnedrive";
|
} from "./remoteForOnedrive";
|
||||||
import { messyConfigToNormal } from "./configPersist";
|
import { messyConfigToNormal } from "./configPersist";
|
||||||
import type { TransItemType } from "./i18n";
|
import type { TransItemType } from "./i18n";
|
||||||
import { checkHasSpecialCharForDir } from "./misc";
|
|
||||||
import { applyWebdavPresetRulesInplace } from "./presetRules";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
applyLogWriterInplace,
|
changeMobileStatusBar,
|
||||||
log,
|
checkHasSpecialCharForDir,
|
||||||
restoreLogWritterInplace,
|
stringToFragment,
|
||||||
} from "./moreOnLog";
|
} from "./misc";
|
||||||
import { simpleTransRemotePrefix } from "./remoteForS3";
|
import { simpleTransRemotePrefix } from "./remoteForS3";
|
||||||
|
|
||||||
class PasswordModal extends Modal {
|
class PasswordModal extends Modal {
|
||||||
@@ -127,6 +123,45 @@ class PasswordModal extends Modal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class EncryptionMethodModal extends Modal {
|
||||||
|
plugin: RemotelySavePlugin;
|
||||||
|
constructor(app: App, plugin: RemotelySavePlugin) {
|
||||||
|
super(app);
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen() {
|
||||||
|
let { contentEl } = this;
|
||||||
|
|
||||||
|
const t = (x: TransItemType, vars?: any) => {
|
||||||
|
return this.plugin.i18n.t(x, vars);
|
||||||
|
};
|
||||||
|
|
||||||
|
// contentEl.setText("Add Or change password.");
|
||||||
|
contentEl.createEl("h2", { text: t("modal_encryptionmethod_title") });
|
||||||
|
t("modal_encryptionmethod_shortdesc")
|
||||||
|
.split("\n")
|
||||||
|
.forEach((val, idx) => {
|
||||||
|
contentEl.createEl("p", {
|
||||||
|
text: stringToFragment(val),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(contentEl).addButton((button) => {
|
||||||
|
button.setButtonText(t("confirm"));
|
||||||
|
button.onClick(async () => {
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
button.setClass("encryptionmethod-second-confirm");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
let { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class ChangeRemoteBaseDirModal extends Modal {
|
class ChangeRemoteBaseDirModal extends Modal {
|
||||||
readonly plugin: RemotelySavePlugin;
|
readonly plugin: RemotelySavePlugin;
|
||||||
readonly newRemoteBaseDir: string;
|
readonly newRemoteBaseDir: string;
|
||||||
@@ -411,12 +446,17 @@ class DropboxAuthModal extends Modal {
|
|||||||
const authRes = await sendAuthReqDropbox(
|
const authRes = await sendAuthReqDropbox(
|
||||||
this.plugin.settings.dropbox.clientID,
|
this.plugin.settings.dropbox.clientID,
|
||||||
verifier,
|
verifier,
|
||||||
authCode
|
authCode,
|
||||||
|
async (e: any) => {
|
||||||
|
new Notice(t("protocol_dropbox_connect_fail"));
|
||||||
|
new Notice(`${e}`);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
);
|
);
|
||||||
const self = this;
|
const self = this;
|
||||||
setConfigBySuccessfullAuthInplace(
|
setConfigBySuccessfullAuthInplace(
|
||||||
this.plugin.settings.dropbox,
|
this.plugin.settings.dropbox,
|
||||||
authRes,
|
authRes!,
|
||||||
() => self.plugin.saveSettings()
|
() => self.plugin.saveSettings()
|
||||||
);
|
);
|
||||||
const client = new RemoteClient(
|
const client = new RemoteClient(
|
||||||
@@ -714,65 +754,6 @@ class ExportSettingsQrCodeModal extends Modal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SetLogToHttpServerModal extends Modal {
|
|
||||||
plugin: RemotelySavePlugin;
|
|
||||||
serverAddr: string;
|
|
||||||
callBack: any;
|
|
||||||
constructor(
|
|
||||||
app: App,
|
|
||||||
plugin: RemotelySavePlugin,
|
|
||||||
serverAddr: string,
|
|
||||||
callBack: any
|
|
||||||
) {
|
|
||||||
super(app);
|
|
||||||
this.plugin = plugin;
|
|
||||||
this.serverAddr = serverAddr;
|
|
||||||
this.callBack = callBack;
|
|
||||||
}
|
|
||||||
|
|
||||||
onOpen() {
|
|
||||||
let { contentEl } = this;
|
|
||||||
|
|
||||||
const t = (x: TransItemType, vars?: any) => {
|
|
||||||
return this.plugin.i18n.t(x, vars);
|
|
||||||
};
|
|
||||||
|
|
||||||
contentEl.createEl("h2", { text: t("modal_logtohttpserver_title") });
|
|
||||||
|
|
||||||
const div1 = contentEl.createDiv();
|
|
||||||
div1.addClass("logtohttpserver-warning");
|
|
||||||
t("modal_logtohttpserver_desc")
|
|
||||||
.split("\n")
|
|
||||||
.forEach((val) => {
|
|
||||||
div1.createEl("p", {
|
|
||||||
text: val,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
new Setting(contentEl)
|
|
||||||
.addButton((button) => {
|
|
||||||
button.setButtonText(t("modal_logtohttpserver_secondconfirm"));
|
|
||||||
button.setClass("logtohttpserver-warning");
|
|
||||||
button.onClick(async () => {
|
|
||||||
this.callBack();
|
|
||||||
new Notice(t("modal_logtohttpserver_notice"));
|
|
||||||
this.close();
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.addButton((button) => {
|
|
||||||
button.setButtonText(t("goback"));
|
|
||||||
button.onClick(() => {
|
|
||||||
this.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onClose() {
|
|
||||||
let { contentEl } = this;
|
|
||||||
contentEl.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getEyesElements = () => {
|
const getEyesElements = () => {
|
||||||
const eyeEl = createElement(Eye);
|
const eyeEl = createElement(Eye);
|
||||||
const eyeOffEl = createElement(EyeOff);
|
const eyeOffEl = createElement(EyeOff);
|
||||||
@@ -784,7 +765,7 @@ const getEyesElements = () => {
|
|||||||
|
|
||||||
const wrapTextWithPasswordHide = (text: TextComponent) => {
|
const wrapTextWithPasswordHide = (text: TextComponent) => {
|
||||||
const { eye, eyeOff } = getEyesElements();
|
const { eye, eyeOff } = getEyesElements();
|
||||||
const hider = text.inputEl.insertAdjacentElement("afterend", createSpan());
|
const hider = text.inputEl.insertAdjacentElement("afterend", createSpan())!;
|
||||||
// the init type of hider is "hidden" === eyeOff === password
|
// the init type of hider is "hidden" === eyeOff === password
|
||||||
hider.innerHTML = eyeOff;
|
hider.innerHTML = eyeOff;
|
||||||
hider.addEventListener("click", (e) => {
|
hider.addEventListener("click", (e) => {
|
||||||
@@ -963,7 +944,7 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (VALID_REQURL) {
|
if (VALID_REQURL && !requireApiVersion(API_VER_ENSURE_REQURL_OK)) {
|
||||||
new Setting(s3Div)
|
new Setting(s3Div)
|
||||||
.setName(t("settings_s3_bypasscorslocally"))
|
.setName(t("settings_s3_bypasscorslocally"))
|
||||||
.setDesc(t("settings_s3_bypasscorslocally_desc"))
|
.setDesc(t("settings_s3_bypasscorslocally_desc"))
|
||||||
@@ -1010,6 +991,28 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
new Setting(s3Div)
|
||||||
|
.setName(t("settings_s3_accuratemtime"))
|
||||||
|
.setDesc(t("settings_s3_accuratemtime_desc"))
|
||||||
|
.addDropdown((dropdown) => {
|
||||||
|
dropdown
|
||||||
|
.addOption("disable", t("disable"))
|
||||||
|
.addOption("enable", t("enable"));
|
||||||
|
|
||||||
|
dropdown
|
||||||
|
.setValue(
|
||||||
|
`${this.plugin.settings.s3.useAccurateMTime ? "enable" : "disable"}`
|
||||||
|
)
|
||||||
|
.onChange(async (val) => {
|
||||||
|
if (val === "enable") {
|
||||||
|
this.plugin.settings.s3.useAccurateMTime = true;
|
||||||
|
} else {
|
||||||
|
this.plugin.settings.s3.useAccurateMTime = false;
|
||||||
|
}
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
let newS3RemotePrefix = this.plugin.settings.s3.remotePrefix || "";
|
let newS3RemotePrefix = this.plugin.settings.s3.remotePrefix || "";
|
||||||
new Setting(s3Div)
|
new Setting(s3Div)
|
||||||
.setName(t("settings_remoteprefix"))
|
.setName(t("settings_remoteprefix"))
|
||||||
@@ -1440,16 +1443,16 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
|||||||
.setValue(this.plugin.settings.webdav.address)
|
.setValue(this.plugin.settings.webdav.address)
|
||||||
.onChange(async (value) => {
|
.onChange(async (value) => {
|
||||||
this.plugin.settings.webdav.address = value.trim();
|
this.plugin.settings.webdav.address = value.trim();
|
||||||
|
// deprecate auto on 20240116, force to manual_1
|
||||||
if (
|
if (
|
||||||
|
this.plugin.settings.webdav.depth === "auto" ||
|
||||||
this.plugin.settings.webdav.depth === "auto_1" ||
|
this.plugin.settings.webdav.depth === "auto_1" ||
|
||||||
this.plugin.settings.webdav.depth === "auto_infinity"
|
this.plugin.settings.webdav.depth === "auto_infinity" ||
|
||||||
|
this.plugin.settings.webdav.depth === "auto_unknown"
|
||||||
) {
|
) {
|
||||||
this.plugin.settings.webdav.depth = "auto_unknown";
|
this.plugin.settings.webdav.depth = "manual_1";
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: any more elegant way?
|
|
||||||
applyWebdavPresetRulesInplace(this.plugin.settings.webdav);
|
|
||||||
|
|
||||||
// normally saved
|
// normally saved
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
})
|
})
|
||||||
@@ -1465,11 +1468,14 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
|||||||
.setValue(this.plugin.settings.webdav.username)
|
.setValue(this.plugin.settings.webdav.username)
|
||||||
.onChange(async (value) => {
|
.onChange(async (value) => {
|
||||||
this.plugin.settings.webdav.username = value.trim();
|
this.plugin.settings.webdav.username = value.trim();
|
||||||
|
// deprecate auto on 20240116, force to manual_1
|
||||||
if (
|
if (
|
||||||
|
this.plugin.settings.webdav.depth === "auto" ||
|
||||||
this.plugin.settings.webdav.depth === "auto_1" ||
|
this.plugin.settings.webdav.depth === "auto_1" ||
|
||||||
this.plugin.settings.webdav.depth === "auto_infinity"
|
this.plugin.settings.webdav.depth === "auto_infinity" ||
|
||||||
|
this.plugin.settings.webdav.depth === "auto_unknown"
|
||||||
) {
|
) {
|
||||||
this.plugin.settings.webdav.depth = "auto_unknown";
|
this.plugin.settings.webdav.depth = "manual_1";
|
||||||
}
|
}
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
});
|
});
|
||||||
@@ -1485,11 +1491,14 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
|||||||
.setValue(this.plugin.settings.webdav.password)
|
.setValue(this.plugin.settings.webdav.password)
|
||||||
.onChange(async (value) => {
|
.onChange(async (value) => {
|
||||||
this.plugin.settings.webdav.password = value.trim();
|
this.plugin.settings.webdav.password = value.trim();
|
||||||
|
// deprecate auto on 20240116, force to manual_1
|
||||||
if (
|
if (
|
||||||
|
this.plugin.settings.webdav.depth === "auto" ||
|
||||||
this.plugin.settings.webdav.depth === "auto_1" ||
|
this.plugin.settings.webdav.depth === "auto_1" ||
|
||||||
this.plugin.settings.webdav.depth === "auto_infinity"
|
this.plugin.settings.webdav.depth === "auto_infinity" ||
|
||||||
|
this.plugin.settings.webdav.depth === "auto_unknown"
|
||||||
) {
|
) {
|
||||||
this.plugin.settings.webdav.depth = "auto_unknown";
|
this.plugin.settings.webdav.depth = "manual_1";
|
||||||
}
|
}
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
});
|
});
|
||||||
@@ -1512,8 +1521,8 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
|||||||
|
|
||||||
dropdown
|
dropdown
|
||||||
.setValue(this.plugin.settings.webdav.authType)
|
.setValue(this.plugin.settings.webdav.authType)
|
||||||
.onChange(async (val: WebdavAuthType) => {
|
.onChange(async (val) => {
|
||||||
this.plugin.settings.webdav.authType = val;
|
this.plugin.settings.webdav.authType = val as WebdavAuthType;
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1522,41 +1531,23 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
|||||||
.setName(t("settings_webdav_depth"))
|
.setName(t("settings_webdav_depth"))
|
||||||
.setDesc(t("settings_webdav_depth_desc"))
|
.setDesc(t("settings_webdav_depth_desc"))
|
||||||
.addDropdown((dropdown) => {
|
.addDropdown((dropdown) => {
|
||||||
dropdown.addOption("auto", t("settings_webdav_depth_auto"));
|
|
||||||
dropdown.addOption("manual_1", t("settings_webdav_depth_1"));
|
dropdown.addOption("manual_1", t("settings_webdav_depth_1"));
|
||||||
dropdown.addOption("manual_infinity", t("settings_webdav_depth_inf"));
|
dropdown.addOption("manual_infinity", t("settings_webdav_depth_inf"));
|
||||||
|
|
||||||
let initVal = "auto";
|
dropdown
|
||||||
const autoOptions: Set<WebdavDepthType> = new Set([
|
.setValue(this.plugin.settings.webdav.depth || "manual_1")
|
||||||
"auto_unknown",
|
.onChange(async (val) => {
|
||||||
"auto_1",
|
if (val === "manual_1") {
|
||||||
"auto_infinity",
|
this.plugin.settings.webdav.depth = "manual_1";
|
||||||
]);
|
this.plugin.settings.webdav.manualRecursive = true;
|
||||||
if (autoOptions.has(this.plugin.settings.webdav.depth)) {
|
} else if (val === "manual_infinity") {
|
||||||
initVal = "auto";
|
this.plugin.settings.webdav.depth = "manual_infinity";
|
||||||
} else {
|
this.plugin.settings.webdav.manualRecursive = false;
|
||||||
initVal = this.plugin.settings.webdav.depth || "auto";
|
}
|
||||||
}
|
|
||||||
|
|
||||||
type DepthOption = "auto" | "manual_1" | "manual_infinity";
|
// normally save
|
||||||
dropdown.setValue(initVal).onChange(async (val: DepthOption) => {
|
await this.plugin.saveSettings();
|
||||||
if (val === "auto") {
|
});
|
||||||
this.plugin.settings.webdav.depth = "auto_unknown";
|
|
||||||
this.plugin.settings.webdav.manualRecursive = false;
|
|
||||||
} else if (val === "manual_1") {
|
|
||||||
this.plugin.settings.webdav.depth = "manual_1";
|
|
||||||
this.plugin.settings.webdav.manualRecursive = true;
|
|
||||||
} else if (val === "manual_infinity") {
|
|
||||||
this.plugin.settings.webdav.depth = "manual_infinity";
|
|
||||||
this.plugin.settings.webdav.manualRecursive = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: any more elegant way?
|
|
||||||
applyWebdavPresetRulesInplace(this.plugin.settings.webdav);
|
|
||||||
|
|
||||||
// normally save
|
|
||||||
await this.plugin.saveSettings();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let newWebdavRemoteBaseDir =
|
let newWebdavRemoteBaseDir =
|
||||||
@@ -1634,8 +1625,8 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
|||||||
dropdown.addOption("onedrive", t("settings_chooseservice_onedrive"));
|
dropdown.addOption("onedrive", t("settings_chooseservice_onedrive"));
|
||||||
dropdown
|
dropdown
|
||||||
.setValue(this.plugin.settings.serviceType)
|
.setValue(this.plugin.settings.serviceType)
|
||||||
.onChange(async (val: SUPPORTED_SERVICES_TYPE) => {
|
.onChange(async (val) => {
|
||||||
this.plugin.settings.serviceType = val;
|
this.plugin.settings.serviceType = val as SUPPORTED_SERVICES_TYPE;
|
||||||
s3Div.toggleClass(
|
s3Div.toggleClass(
|
||||||
"s3-hide",
|
"s3-hide",
|
||||||
this.plugin.settings.serviceType !== "s3"
|
this.plugin.settings.serviceType !== "s3"
|
||||||
@@ -1683,6 +1674,23 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
new Setting(basicDiv)
|
||||||
|
.setName(t("settings_encryptionmethod"))
|
||||||
|
.setDesc(stringToFragment(t("settings_encryptionmethod_desc")))
|
||||||
|
.addDropdown((dropdown) => {
|
||||||
|
dropdown
|
||||||
|
.addOption("rclone-base64", t("settings_encryptionmethod_rclone"))
|
||||||
|
.addOption("openssl-base64", t("settings_encryptionmethod_openssl"))
|
||||||
|
.setValue(this.plugin.settings.encryptionMethod ?? "rclone-base64")
|
||||||
|
.onChange(async (val: string) => {
|
||||||
|
this.plugin.settings.encryptionMethod = val as CipherMethodType;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
if (this.plugin.settings.password !== "") {
|
||||||
|
new EncryptionMethodModal(this.app, this.plugin).open();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
new Setting(basicDiv)
|
new Setting(basicDiv)
|
||||||
.setName(t("settings_autorun"))
|
.setName(t("settings_autorun"))
|
||||||
.setDesc(t("settings_autorun_desc"))
|
.setDesc(t("settings_autorun_desc"))
|
||||||
@@ -1712,7 +1720,7 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
|||||||
realVal > 0
|
realVal > 0
|
||||||
) {
|
) {
|
||||||
const intervalID = window.setInterval(() => {
|
const intervalID = window.setInterval(() => {
|
||||||
log.info("auto run from settings.ts");
|
console.info("auto run from settings.ts");
|
||||||
this.plugin.syncRun("auto");
|
this.plugin.syncRun("auto");
|
||||||
}, realVal);
|
}, realVal);
|
||||||
this.plugin.autoRunIntervalID = intervalID;
|
this.plugin.autoRunIntervalID = intervalID;
|
||||||
@@ -1784,7 +1792,7 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
|||||||
// then schedule a run for syncOnSaveAfterMilliseconds after it was modified
|
// then schedule a run for syncOnSaveAfterMilliseconds after it was modified
|
||||||
const lastModified = currentFile.stat.mtime;
|
const lastModified = currentFile.stat.mtime;
|
||||||
const currentTime = Date.now();
|
const currentTime = Date.now();
|
||||||
// log.debug(
|
// console.debug(
|
||||||
// `Checking if file was modified within last ${
|
// `Checking if file was modified within last ${
|
||||||
// this.plugin.settings.syncOnSaveAfterMilliseconds / 1000
|
// this.plugin.settings.syncOnSaveAfterMilliseconds / 1000
|
||||||
// } seconds, last modified: ${
|
// } seconds, last modified: ${
|
||||||
@@ -1793,13 +1801,13 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
|||||||
// );
|
// );
|
||||||
if (
|
if (
|
||||||
currentTime - lastModified <
|
currentTime - lastModified <
|
||||||
this.plugin.settings.syncOnSaveAfterMilliseconds
|
this.plugin.settings.syncOnSaveAfterMilliseconds!
|
||||||
) {
|
) {
|
||||||
if (!runScheduled) {
|
if (!runScheduled) {
|
||||||
const scheduleTimeFromNow =
|
const scheduleTimeFromNow =
|
||||||
this.plugin.settings.syncOnSaveAfterMilliseconds -
|
this.plugin.settings.syncOnSaveAfterMilliseconds! -
|
||||||
(currentTime - lastModified);
|
(currentTime - lastModified);
|
||||||
log.info(
|
console.info(
|
||||||
`schedule a run for ${scheduleTimeFromNow} milliseconds later`
|
`schedule a run for ${scheduleTimeFromNow} milliseconds later`
|
||||||
);
|
);
|
||||||
runScheduled = true;
|
runScheduled = true;
|
||||||
@@ -1842,7 +1850,7 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
|||||||
.setDesc(t("settings_enablestatusbar_info_desc"))
|
.setDesc(t("settings_enablestatusbar_info_desc"))
|
||||||
.addToggle((toggle) => {
|
.addToggle((toggle) => {
|
||||||
toggle
|
toggle
|
||||||
.setValue(this.plugin.settings.enableStatusBarInfo)
|
.setValue(this.plugin.settings.enableStatusBarInfo ?? false)
|
||||||
.onChange(async (val) => {
|
.onChange(async (val) => {
|
||||||
this.plugin.settings.enableStatusBarInfo = val;
|
this.plugin.settings.enableStatusBarInfo = val;
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
@@ -1857,7 +1865,7 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
|||||||
button.setButtonText(t("settings_resetstatusbar_button"));
|
button.setButtonText(t("settings_resetstatusbar_button"));
|
||||||
button.onClick(async () => {
|
button.onClick(async () => {
|
||||||
// reset last sync time
|
// reset last sync time
|
||||||
await upsertLastSuccessSyncByVault(
|
await upsertLastSuccessSyncTimeByVault(
|
||||||
this.plugin.db,
|
this.plugin.db,
|
||||||
this.plugin.vaultRandomID,
|
this.plugin.vaultRandomID,
|
||||||
-1
|
-1
|
||||||
@@ -1875,7 +1883,7 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
|||||||
|
|
||||||
.addTextArea((textArea) => {
|
.addTextArea((textArea) => {
|
||||||
textArea
|
textArea
|
||||||
.setValue(`${this.plugin.settings.ignorePaths.join("\n")}`)
|
.setValue(`${(this.plugin.settings.ignorePaths ?? []).join("\n")}`)
|
||||||
.onChange(async (value) => {
|
.onChange(async (value) => {
|
||||||
this.plugin.settings.ignorePaths = value
|
this.plugin.settings.ignorePaths = value
|
||||||
.trim()
|
.trim()
|
||||||
@@ -1977,13 +1985,129 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
|||||||
t("settings_deletetowhere_obsidian_trash")
|
t("settings_deletetowhere_obsidian_trash")
|
||||||
);
|
);
|
||||||
dropdown
|
dropdown
|
||||||
.setValue(this.plugin.settings.deleteToWhere)
|
.setValue(this.plugin.settings.deleteToWhere ?? "system")
|
||||||
.onChange(async (val: "system" | "obsidian") => {
|
.onChange(async (val) => {
|
||||||
this.plugin.settings.deleteToWhere = val;
|
this.plugin.settings.deleteToWhere = val as "system" | "obsidian";
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
new Setting(advDiv)
|
||||||
|
.setName(t("settings_conflictaction"))
|
||||||
|
.setDesc(t("settings_conflictaction_desc"))
|
||||||
|
.addDropdown((dropdown) => {
|
||||||
|
dropdown.addOption(
|
||||||
|
"keep_newer",
|
||||||
|
t("settings_conflictaction_keep_newer")
|
||||||
|
);
|
||||||
|
dropdown.addOption(
|
||||||
|
"keep_larger",
|
||||||
|
t("settings_conflictaction_keep_larger")
|
||||||
|
);
|
||||||
|
dropdown
|
||||||
|
.setValue(this.plugin.settings.conflictAction ?? "keep_newer")
|
||||||
|
.onChange(async (val) => {
|
||||||
|
this.plugin.settings.conflictAction = val as ConflictActionType;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(advDiv)
|
||||||
|
.setName(t("settings_cleanemptyfolder"))
|
||||||
|
.setDesc(t("settings_cleanemptyfolder_desc"))
|
||||||
|
.addDropdown((dropdown) => {
|
||||||
|
dropdown.addOption("skip", t("settings_cleanemptyfolder_skip"));
|
||||||
|
dropdown.addOption(
|
||||||
|
"clean_both",
|
||||||
|
t("settings_cleanemptyfolder_clean_both")
|
||||||
|
);
|
||||||
|
dropdown
|
||||||
|
.setValue(this.plugin.settings.howToCleanEmptyFolder ?? "skip")
|
||||||
|
.onChange(async (val) => {
|
||||||
|
this.plugin.settings.howToCleanEmptyFolder =
|
||||||
|
val as EmptyFolderCleanType;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(advDiv)
|
||||||
|
.setName(t("settings_protectmodifypercentage"))
|
||||||
|
.setDesc(t("settings_protectmodifypercentage_desc"))
|
||||||
|
.addDropdown((dropdown) => {
|
||||||
|
for (const i of Array.from({ length: 11 }, (x, i) => i * 10)) {
|
||||||
|
let desc = `${i}`;
|
||||||
|
if (i === 0) {
|
||||||
|
desc = t("settings_protectmodifypercentage_000_desc");
|
||||||
|
} else if (i === 50) {
|
||||||
|
desc = t("settings_protectmodifypercentage_050_desc");
|
||||||
|
} else if (i === 100) {
|
||||||
|
desc = t("settings_protectmodifypercentage_100_desc");
|
||||||
|
}
|
||||||
|
dropdown.addOption(`${i}`, desc);
|
||||||
|
}
|
||||||
|
dropdown
|
||||||
|
.setValue(`${this.plugin.settings.protectModifyPercentage ?? 50}`)
|
||||||
|
.onChange(async (val) => {
|
||||||
|
this.plugin.settings.protectModifyPercentage = parseInt(val);
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(advDiv)
|
||||||
|
.setName(t("setting_syncdirection"))
|
||||||
|
.setDesc(t("setting_syncdirection_desc"))
|
||||||
|
.addDropdown((dropdown) => {
|
||||||
|
dropdown.addOption(
|
||||||
|
"bidirectional",
|
||||||
|
t("setting_syncdirection_bidirectional_desc")
|
||||||
|
);
|
||||||
|
dropdown.addOption(
|
||||||
|
"incremental_push_only",
|
||||||
|
t("setting_syncdirection_incremental_push_only_desc")
|
||||||
|
);
|
||||||
|
dropdown.addOption(
|
||||||
|
"incremental_pull_only",
|
||||||
|
t("setting_syncdirection_incremental_pull_only_desc")
|
||||||
|
);
|
||||||
|
|
||||||
|
dropdown
|
||||||
|
.setValue(this.plugin.settings.syncDirection ?? "bidirectional")
|
||||||
|
.onChange(async (val) => {
|
||||||
|
this.plugin.settings.syncDirection = val as SyncDirectionType;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Platform.isMobile) {
|
||||||
|
new Setting(advDiv)
|
||||||
|
.setName(t("settings_enablemobilestatusbar"))
|
||||||
|
.setDesc(t("settings_enablemobilestatusbar_desc"))
|
||||||
|
.addDropdown(async (dropdown) => {
|
||||||
|
dropdown
|
||||||
|
.addOption("enable", t("enable"))
|
||||||
|
.addOption("disable", t("disable"));
|
||||||
|
|
||||||
|
dropdown
|
||||||
|
.setValue(
|
||||||
|
`${
|
||||||
|
this.plugin.settings.enableMobileStatusBar
|
||||||
|
? "enable"
|
||||||
|
: "disable"
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
.onChange(async (val) => {
|
||||||
|
if (val === "enable") {
|
||||||
|
this.plugin.settings.enableMobileStatusBar = true;
|
||||||
|
changeMobileStatusBar("enable");
|
||||||
|
} else {
|
||||||
|
this.plugin.settings.enableMobileStatusBar = false;
|
||||||
|
changeMobileStatusBar("disable");
|
||||||
|
}
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
//////////////////////////////////////////////////
|
//////////////////////////////////////////////////
|
||||||
// below for import and export functions
|
// below for import and export functions
|
||||||
//////////////////////////////////////////////////
|
//////////////////////////////////////////////////
|
||||||
@@ -2022,12 +2146,11 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
|||||||
dropdown.addOption("info", "info");
|
dropdown.addOption("info", "info");
|
||||||
dropdown.addOption("debug", "debug");
|
dropdown.addOption("debug", "debug");
|
||||||
dropdown
|
dropdown
|
||||||
.setValue(this.plugin.settings.currLogLevel)
|
.setValue(this.plugin.settings.currLogLevel ?? "info")
|
||||||
.onChange(async (val: string) => {
|
.onChange(async (val: string) => {
|
||||||
this.plugin.settings.currLogLevel = val;
|
this.plugin.settings.currLogLevel = val;
|
||||||
log.setLevel(val as any);
|
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
log.info(`the log level is changed to ${val}`);
|
console.info(`the log level is changed to ${val}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2038,11 +2161,39 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
|||||||
button.setButtonText(t("settings_outputsettingsconsole_button"));
|
button.setButtonText(t("settings_outputsettingsconsole_button"));
|
||||||
button.onClick(async () => {
|
button.onClick(async () => {
|
||||||
const c = messyConfigToNormal(await this.plugin.loadData());
|
const c = messyConfigToNormal(await this.plugin.loadData());
|
||||||
log.info(c);
|
console.info(c);
|
||||||
new Notice(t("settings_outputsettingsconsole_notice"));
|
new Notice(t("settings_outputsettingsconsole_notice"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
new Setting(debugDiv)
|
||||||
|
.setName(t("settings_obfuscatesettingfile"))
|
||||||
|
.setDesc(t("settings_obfuscatesettingfile_desc"))
|
||||||
|
.addDropdown(async (dropdown) => {
|
||||||
|
dropdown
|
||||||
|
.addOption("enable", t("enable"))
|
||||||
|
.addOption("disable", t("disable"));
|
||||||
|
|
||||||
|
dropdown
|
||||||
|
.setValue(
|
||||||
|
`${
|
||||||
|
this.plugin.settings.obfuscateSettingFile ? "enable" : "disable"
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
.onChange(async (val) => {
|
||||||
|
if (val === "enable") {
|
||||||
|
this.plugin.settings.obfuscateSettingFile = true;
|
||||||
|
} else {
|
||||||
|
this.plugin.settings.obfuscateSettingFile = false;
|
||||||
|
}
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(debugDiv)
|
||||||
|
.setName(t("settings_viewconsolelog"))
|
||||||
|
.setDesc(stringToFragment(t("settings_viewconsolelog_desc")));
|
||||||
|
|
||||||
new Setting(debugDiv)
|
new Setting(debugDiv)
|
||||||
.setName(t("settings_syncplans"))
|
.setName(t("settings_syncplans"))
|
||||||
.setDesc(t("settings_syncplans_desc"))
|
.setDesc(t("settings_syncplans_desc"))
|
||||||
@@ -2052,24 +2203,12 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
|||||||
await exportVaultSyncPlansToFiles(
|
await exportVaultSyncPlansToFiles(
|
||||||
this.plugin.db,
|
this.plugin.db,
|
||||||
this.app.vault,
|
this.app.vault,
|
||||||
this.plugin.vaultRandomID,
|
this.plugin.vaultRandomID
|
||||||
"json"
|
|
||||||
);
|
|
||||||
new Notice(t("settings_syncplans_notice"));
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.addButton(async (button) => {
|
|
||||||
button.setButtonText(t("settings_syncplans_button_table"));
|
|
||||||
button.onClick(async () => {
|
|
||||||
await exportVaultSyncPlansToFiles(
|
|
||||||
this.plugin.db,
|
|
||||||
this.app.vault,
|
|
||||||
this.plugin.vaultRandomID,
|
|
||||||
"table"
|
|
||||||
);
|
);
|
||||||
new Notice(t("settings_syncplans_notice"));
|
new Notice(t("settings_syncplans_notice"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
new Setting(debugDiv)
|
new Setting(debugDiv)
|
||||||
.setName(t("settings_delsyncplans"))
|
.setName(t("settings_delsyncplans"))
|
||||||
.setDesc(t("settings_delsyncplans_desc"))
|
.setDesc(t("settings_delsyncplans_desc"))
|
||||||
@@ -2082,117 +2221,16 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
|||||||
});
|
});
|
||||||
|
|
||||||
new Setting(debugDiv)
|
new Setting(debugDiv)
|
||||||
.setName(t("settings_logtodb"))
|
.setName(t("settings_delprevsync"))
|
||||||
.setDesc(t("settings_logtodb_desc"))
|
.setDesc(t("settings_delprevsync_desc"))
|
||||||
.addDropdown(async (dropdown) => {
|
|
||||||
dropdown.addOption("enable", t("enable"));
|
|
||||||
dropdown.addOption("disable", t("disable"));
|
|
||||||
dropdown
|
|
||||||
.setValue(this.plugin.settings.logToDB ? "enable" : "disable")
|
|
||||||
.onChange(async (val: string) => {
|
|
||||||
const logToDB = val === "enable";
|
|
||||||
// if (logToDB) {
|
|
||||||
// applyLogWriterInplace((...msg: any[]) => {
|
|
||||||
// insertLoggerOutputByVault(
|
|
||||||
// this.plugin.db,
|
|
||||||
// this.plugin.vaultRandomID,
|
|
||||||
// ...msg
|
|
||||||
// );
|
|
||||||
// });
|
|
||||||
// } else {
|
|
||||||
// restoreLogWritterInplace();
|
|
||||||
// }
|
|
||||||
clearExpiredLoggerOutputRecords(this.plugin.db);
|
|
||||||
this.plugin.settings.logToDB = logToDB;
|
|
||||||
await this.plugin.saveSettings();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
new Setting(debugDiv)
|
|
||||||
.setName(t("settings_logtodbexport"))
|
|
||||||
.setDesc(
|
|
||||||
t("settings_logtodbexport_desc", {
|
|
||||||
debugFolder: DEFAULT_DEBUG_FOLDER,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.addButton(async (button) => {
|
.addButton(async (button) => {
|
||||||
button.setButtonText(t("settings_logtodbexport_button"));
|
button.setButtonText(t("settings_delprevsync_button"));
|
||||||
button.onClick(async () => {
|
button.onClick(async () => {
|
||||||
await exportVaultLoggerOutputToFiles(
|
await clearAllPrevSyncRecordByVault(
|
||||||
this.plugin.db,
|
this.plugin.db,
|
||||||
this.app.vault,
|
|
||||||
this.plugin.vaultRandomID
|
this.plugin.vaultRandomID
|
||||||
);
|
);
|
||||||
new Notice(t("settings_logtodbexport_notice"));
|
new Notice(t("settings_delprevsync_notice"));
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
new Setting(debugDiv)
|
|
||||||
.setName(t("settings_logtodbclear"))
|
|
||||||
.setDesc(t("settings_logtodbclear_desc"))
|
|
||||||
.addButton(async (button) => {
|
|
||||||
button.setButtonText(t("settings_logtodbclear_button"));
|
|
||||||
button.onClick(async () => {
|
|
||||||
await clearAllLoggerOutputRecords(this.plugin.db);
|
|
||||||
new Notice(t("settings_logtodbclear_notice"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let logToHttpServer = this.plugin.debugServerTemp || "";
|
|
||||||
new Setting(debugDiv)
|
|
||||||
.setName(t("settings_logtohttpserver"))
|
|
||||||
.setDesc(t("settings_logtohttpserver_desc"))
|
|
||||||
.addText(async (text) => {
|
|
||||||
text.setValue(logToHttpServer).onChange(async (value) => {
|
|
||||||
logToHttpServer = value.trim();
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.addButton(async (button) => {
|
|
||||||
button.setButtonText(t("confirm"));
|
|
||||||
button.onClick(async () => {
|
|
||||||
if (logToHttpServer === "" || !logToHttpServer.startsWith("http")) {
|
|
||||||
this.plugin.debugServerTemp = "";
|
|
||||||
logToHttpServer = "";
|
|
||||||
// restoreLogWritterInplace();
|
|
||||||
new Notice(t("settings_logtohttpserver_reset_notice"));
|
|
||||||
} else {
|
|
||||||
new SetLogToHttpServerModal(
|
|
||||||
this.app,
|
|
||||||
this.plugin,
|
|
||||||
logToHttpServer,
|
|
||||||
() => {
|
|
||||||
this.plugin.debugServerTemp = logToHttpServer;
|
|
||||||
// applyLogWriterInplace((...msg: any[]) => {
|
|
||||||
// try {
|
|
||||||
// requestUrl({
|
|
||||||
// url: logToHttpServer,
|
|
||||||
// method: "POST",
|
|
||||||
// headers: {
|
|
||||||
// "Content-Type": "application/json",
|
|
||||||
// },
|
|
||||||
// body: JSON.stringify({
|
|
||||||
// send_time: Date.now(),
|
|
||||||
// log_text: msg,
|
|
||||||
// }),
|
|
||||||
// });
|
|
||||||
// } catch (e) {
|
|
||||||
// // pass
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
}
|
|
||||||
).open();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
new Setting(debugDiv)
|
|
||||||
.setName(t("settings_delsyncmap"))
|
|
||||||
.setDesc(t("settings_delsyncmap_desc"))
|
|
||||||
.addButton(async (button) => {
|
|
||||||
button.setButtonText(t("settings_delsyncmap_button"));
|
|
||||||
button.onClick(async () => {
|
|
||||||
await clearAllSyncMetaMapping(this.plugin.db);
|
|
||||||
new Notice(t("settings_delsyncmap_notice"));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2215,6 +2253,7 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
|||||||
button.onClick(async () => {
|
button.onClick(async () => {
|
||||||
await destroyDBs();
|
await destroyDBs();
|
||||||
new Notice(t("settings_resetcache_notice"));
|
new Notice(t("settings_resetcache_notice"));
|
||||||
|
this.plugin.unload();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+1119
-1253
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.enableSyncOnSaveIfSet();
|
||||||
|
} 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;
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ 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);
|
chai.use(chaiAsPromised);
|
||||||
const expect = chai.expect;
|
const expect = chai.expect;
|
||||||
|
|
||||||
describe("Encryption tests", () => {
|
describe("Encryption OpenSSL tests", () => {
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
global.window = {
|
global.window = {
|
||||||
crypto: require("crypto").webcrypto,
|
crypto: require("crypto").webcrypto,
|
||||||
@@ -45,8 +45,8 @@ describe("Metadata operations tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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 },
|
||||||
|
|||||||
@@ -285,3 +285,56 @@ describe("Misc: special char for dir", () => {
|
|||||||
expect(misc.checkHasSpecialCharForDir("xxx?yyy")).to.be.true;
|
expect(misc.checkHasSpecialCharForDir("xxx?yyy")).to.be.true;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Misc: Dropbox: should fix the folder name cases", () => {
|
||||||
|
it("should do nothing on empty folders", () => {
|
||||||
|
const input: any[] = [];
|
||||||
|
expect(misc.fixEntityListCasesInplace(input)).to.be.empty;
|
||||||
|
});
|
||||||
|
|
||||||
|
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/" },
|
||||||
|
];
|
||||||
|
expect(misc.fixEntityListCasesInplace(input)).to.deep.equal(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" },
|
||||||
|
];
|
||||||
|
expect(misc.fixEntityListCasesInplace(input)).to.deep.equal(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));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+2
-1
@@ -5,6 +5,7 @@
|
|||||||
"inlineSources": true,
|
"inlineSources": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"target": "es6",
|
"target": "es6",
|
||||||
|
"strict": true,
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"noImplicitAny": true,
|
"noImplicitAny": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
@@ -13,7 +14,7 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"importHelpers": true,
|
"importHelpers": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"lib": ["dom", "es5", "scripthost", "es2015"]
|
"lib": ["dom", "es5", "scripthost", "es2015", "webworker"]
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts"]
|
"include": ["**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ module.exports = {
|
|||||||
],
|
],
|
||||||
module: {
|
module: {
|
||||||
rules: [
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.worker\.ts$/,
|
||||||
|
loader: "worker-loader",
|
||||||
|
options: {
|
||||||
|
inline: "no-fallback",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
test: /\.tsx?$/,
|
test: /\.tsx?$/,
|
||||||
use: "ts-loader",
|
use: "ts-loader",
|
||||||
|
|||||||
Reference in New Issue
Block a user