Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2645ff34e6 | ||
|
|
f25a2c2992 | ||
|
|
3d1269a9f2 | ||
|
|
ed52a8542f | ||
|
|
757eb5c801 | ||
|
|
048e7b6251 | ||
|
|
b762052da3 | ||
|
|
c61efd1367 | ||
|
|
605bffa471 | ||
|
|
895e3db4c6 | ||
|
|
ce1990a35f | ||
|
|
9ea7c8e858 | ||
|
|
61a3fab219 | ||
|
|
5340e38eac | ||
|
|
8d5868b8d8 | ||
|
|
55175a6d06 | ||
|
|
f60bd25490 | ||
|
|
1d8463a3ed | ||
|
|
9fe1b1d5e6 | ||
|
|
df7b6e1848 | ||
|
|
a9126e5947 | ||
|
|
3bb7355db3 | ||
|
|
f33fa26c03 | ||
|
|
5ce350ba41 | ||
|
|
1d32f1242d | ||
|
|
d8ab054da1 | ||
|
|
b877228415 | ||
|
|
28b99557a8 | ||
|
|
ae28cf9183 | ||
|
|
8f68ac4ded | ||
|
|
220fd07a8b | ||
|
|
fe572b41c9 | ||
|
|
ca5ca5f576 | ||
|
|
0e6182b887 | ||
|
|
c5a7085fda | ||
|
|
640f1e56f1 | ||
|
|
cb6f7e572c | ||
|
|
83e0073134 | ||
|
|
cfe316f690 | ||
|
|
c472654060 | ||
|
|
46cbfcc3aa | ||
|
|
ce94a6d79c | ||
|
|
0fc0dcad64 | ||
|
|
577cdde21f | ||
|
|
583b365e72 | ||
|
|
3d213b2be8 | ||
|
|
ee224bf4f2 | ||
|
|
f1bd0b1ce6 | ||
|
|
300ed213af | ||
|
|
4be67ce491 | ||
|
|
98107ba4ea | ||
|
|
b674dd0f10 | ||
|
|
7930509e2a | ||
|
|
1c918d82da | ||
|
|
593fd7471b | ||
|
|
222f386586 | ||
|
|
dcd02457cb | ||
|
|
791c0e8df6 |
@@ -0,0 +1,43 @@
|
||||
name: "CLA Assistant"
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [opened,closed,synchronize]
|
||||
|
||||
# explicitly configure permissions, in case your GITHUB_TOKEN workflow permissions are set to read-only in repository settings
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
CLAAssistant:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "CLA Assistant"
|
||||
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
|
||||
uses: contributor-assistant/github-action@v2.3.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# the below token should have repo scope and must be manually added by you in the repository's secret
|
||||
# This token is required only if you have configured to store the signatures in a remote repository/organization
|
||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
with:
|
||||
path-to-signatures: 'signatures/version1/cla.json'
|
||||
path-to-document: 'https://github.com/remotely-save/remotely-save/blob/master/CLA.md' # e.g. a CLA or a DCO document
|
||||
# branch should not be protected
|
||||
branch: 'main'
|
||||
allowlist: bot*
|
||||
|
||||
# the followings are the optional inputs - If the optional inputs are not given, then default values will be taken
|
||||
remote-organization-name: remotely-save
|
||||
remote-repository-name: cla-signed
|
||||
#create-file-commit-message: 'For example: Creating file for storing CLA Signatures'
|
||||
#signed-commit-message: 'For example: $contributorName has signed the CLA in $owner/$repo#$pullRequestNo'
|
||||
custom-notsigned-prcomment: '<br/>Thank you for your submission, we really appreciate it. However, we ask that $you sign our [Contributor License Agreement](https://github.com/remotely-save/remotely-save/blob/master/CLA.md) before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.<br/>'
|
||||
#custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA'
|
||||
#custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.'
|
||||
#lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true)
|
||||
#use-dco-flag: true - If you are using DCO instead of CLA
|
||||
@@ -0,0 +1,23 @@
|
||||
In order to clarify the intellectual property license granted with Contributions from any person or entity, Remotely Save dev team ("Remotely Save") must have on file a signed Contributor License Agreement ("CLA") from each Contributor, indicating agreement with the license terms below. This agreement is for your protection as a Contributor as well as the protection of Remotely Save and its users. It does not change your rights to use your own Contributions for any other purpose.
|
||||
|
||||
You accept and agree to the following terms and conditions for Your Contributions (present and future) that you submit to Remotely Save. Except for the license granted herein to Remotely Save and recipients of software distributed by Remotely Save, You reserve all right, title, and interest in and to Your Contributions.
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with Remotely Save. For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to Remotely Save for inclusion in, or documentation of, any of the products owned or managed by Remotely Save (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to Remotely Save or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, Remotely Save for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution."
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of this Agreement, You hereby grant to Remotely Save and to recipients of software distributed by Remotely Save a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of this Agreement, You hereby grant to Remotely Save and to recipients of software distributed by Remotely Save a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed.
|
||||
|
||||
4. You represent that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, that your employer has waived such rights for your Contributions to Remotely Save, or that your employer has executed a separate Corporate CLA with Remotely Save.
|
||||
|
||||
5. You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions.
|
||||
|
||||
6. You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
|
||||
7. Should You wish to submit work that is not Your original creation, You may submit it to Remotely Save separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]".
|
||||
|
||||
8. You agree to notify Remotely Save of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Contributing
|
||||
|
||||
## What
|
||||
|
||||
Starting from April 29, 2024, all individual contributors' contributions are only possibly accepted after they sign the CLA.
|
||||
|
||||
We do not accept corporate contributions at this moment.
|
||||
|
||||
You can check out [CLA](./CLA.md).
|
||||
|
||||
## Steps
|
||||
|
||||
1. Make some changes to the code. Open a pull request.
|
||||
2. A rebot will check the status.
|
||||

|
||||
3. Read the [CLA](./CLA.md) carefully and make a decision.
|
||||
4. If you decide to sign the CLA, please make a comment "I have read the CLA Document and I hereby sign the CLA".
|
||||
5. If you decide to not sign the CLA, please close the PR.
|
||||
6. The robot should recheck and pass the check automatically.
|
||||

|
||||
7. Your pr will be reviewed.
|
||||
8. If you sign the CLA and submit some more PRs, the robot should pass the test automatically.
|
||||
@@ -21,6 +21,7 @@ This is yet another unofficial sync plugin for Obsidian. If you like it or find
|
||||
- Dropbox
|
||||
- OneDrive for personal
|
||||
- Webdav
|
||||
- Webdis
|
||||
- [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".
|
||||
- **[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.
|
||||
@@ -66,11 +67,13 @@ Additionally, the plugin author may occasionally visit Obsidian official forum a
|
||||
- [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)
|
||||
- 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.
|
||||
- [又拍云](./docs/remote_services/s3_upyun/README.zh-cn.md)
|
||||
- Prepare your S3 (-compatible) service information: [endpoint, region](https://docs.aws.amazon.com/general/latest/gr/s3.html), [access key id, secret access key](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/getting-your-credentials.html), bucket name.
|
||||
- If you are using AWS S3, create [policy and user](./docs/remote_services/s3_general/s3_user_policy.md).
|
||||
- Very old version of Obsidian needs [configuring CORS](./docs/remote_services/s3_general/s3_cors_configure.md).
|
||||
- Download and enable this plugin.
|
||||
- Enter your information to the settings of this plugin.
|
||||
- If you do not set the prefix in the settings, the bucket should be empty and solely for syncing a vault. You can set the prefix in the settings so that the same bucket can store multiple vaults.
|
||||
- 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.
|
||||
- **Be patient while syncing.** Especially in the first-time sync.
|
||||
@@ -107,6 +110,13 @@ Additionally, the plugin author may occasionally visit Obsidian official forum a
|
||||
- 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.
|
||||
|
||||
### Webdis
|
||||
|
||||
- Tutorials:
|
||||
- [Webdis](./docs/remote_services/webdis/README.md)
|
||||
- Mostly experimental.
|
||||
- You have to setup and protect your web server by yourself.
|
||||
|
||||
## Scheduled Auto Sync
|
||||
|
||||
- You can configure auto syncing every N minutes in settings.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:07fd075d0f73c708d77fd97bb31d6f185f094031e8275e8fd582e3a0145f6be8
|
||||
size 415757
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4c5754df8b5d6878a7fa58992fc7e37ddcce1ecf578a0fc2ec751b6f7bab6f13
|
||||
size 284700
|
||||
@@ -0,0 +1,56 @@
|
||||
# How to receive `obsidian://` in Linux
|
||||
|
||||
## Background
|
||||
|
||||
For example, when we are authorizing OneDrive, we have to jump back to Obsidian automatically using `obsidian://`.
|
||||
|
||||
## Short Desc From Official Obsidian Doc
|
||||
|
||||
Official doc has some explanation:
|
||||
|
||||
<https://help.obsidian.md/Extending+Obsidian/Obsidian+URI#Register+Obsidian+URI>
|
||||
|
||||
# Long Desc
|
||||
|
||||
Assuming the username is `somebody`, and the `.AppImage` file is downloaded to `~/Desktop`.
|
||||
|
||||
1. Download and **extract** the app image file in terminal
|
||||
|
||||
```bash
|
||||
cd /home/somebody/Desktop
|
||||
chmod +x Obsidian-x.y.z.AppImage
|
||||
./Obsidian-x.y.z.AppImage --appimage-extract
|
||||
|
||||
# you should have the folder squashfs-root
|
||||
# we want to rename it
|
||||
mv squashfs-root Obsidian
|
||||
```
|
||||
|
||||
2. Create a `.desktop` file
|
||||
|
||||
```bash
|
||||
# copy and paste the follow MULTI LINE command
|
||||
# you might need to input your password because it requires root privilege
|
||||
# remember to adjust the path
|
||||
cat > ~/Desktop/obsidian.desktop <<EOF
|
||||
[Desktop Entry]
|
||||
Name=Obsidian
|
||||
Comment=obsidian
|
||||
Exec=/home/somebody/Desktop/Obsidian/obsidian %u
|
||||
Keywords=obsidian
|
||||
StartupNotify=true
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=/home/somebody/Desktop/Obsidian/obsidian.png
|
||||
MimeType=x-scheme-handler/obsidian;
|
||||
EOF
|
||||
|
||||
# yeah we can check out the output
|
||||
cat ~/Desktop/obsidian.desktop
|
||||
## [Desktop Entry]
|
||||
## ...
|
||||
```
|
||||
|
||||
3. Right click the `obsidian.desktop` file on the Desktop, and click "Allow launching"
|
||||
|
||||
4. Double click the `obsidian.desktop` file.
|
||||
@@ -33,6 +33,7 @@ Using the principle of least privilege is crucial for security when allowing a t
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:HeadObject",
|
||||
"s3:ListBucket",
|
||||
"s3:PutObject",
|
||||
"s3:CopyObject",
|
||||
"s3:UploadPart",
|
||||
@@ -48,7 +49,10 @@ Using the principle of least privilege is crucial for security when allowing a t
|
||||
"s3:DeleteObject",
|
||||
"s3:DeleteObjects"
|
||||
],
|
||||
"Resource": "arn:aws:s3:::my-bucket/*"
|
||||
"Resource": [
|
||||
"arn:aws:s3:::my-bucket",
|
||||
"arn:aws:s3:::my-bucket/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# 又拍云
|
||||
|
||||
## 链接
|
||||
|
||||
- 官网 <https://www.upyun.com/>
|
||||
- 官网的 S3 文档 <https://help.upyun.com/knowledge-base/aws-s3%e5%85%bc%e5%ae%b9/>
|
||||
|
||||
## 注意!!!!!
|
||||
|
||||
又拍云似乎(?)文件都是默认公开的,强烈建议注意隐私问题,强烈建议设置插件加密。
|
||||
|
||||
## 步骤
|
||||
|
||||
1. 注册,新建对象存储。
|
||||
2. 参考官网文档 <https://help.upyun.com/knowledge-base/aws-s3%e5%85%bc%e5%ae%b9/>,创建操作员然后创建 S3 访问凭证。
|
||||
3. 在 Remotely Save 设置以下:
|
||||
- 服务地址(Endpoint):`s3.api.upyun.com` **一定是这个域名**
|
||||
- 区域(Region):`us-east-1`
|
||||
- Acccess Key ID:您获取到的访问凭证的 AccessKey
|
||||
- Secret Access Key:您获取到的访问凭证的 SecretAccessKey
|
||||
- 存储桶(Bucket)的名字:您创建的“服务名”
|
||||
- 是否生成文件夹 Object:不生成(默认) **一定要选择不生成**
|
||||
4. 可以在插件设置里,加上密码。
|
||||
5. 同步。
|
||||
@@ -0,0 +1,35 @@
|
||||
# Webdis
|
||||
|
||||
## Links
|
||||
|
||||
- Webdis: <https://github.com/nicolasff/webdis>
|
||||
- Redis®: <https://redis.io/>
|
||||
|
||||
## Explanation and Background
|
||||
|
||||
I like the Redis® software very much, and would like to experiment using it as a "file storage". It seems to be nature by using path as the key and the content as the value (Sort of..., see below).
|
||||
|
||||
However, Redis® works by using TCP connections, and browser js cannot establish raw TCP connections. We need a HTTP gateway, to provide the HTTP api. Wedis seems to be the most famous open source one.
|
||||
|
||||
And of course, this method should work for Redis® alternatives: Valkey, Redict, KeyDB, Dragonfly, Garnet, ...
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This app is NOT an official Redis® Ltd / Redis® OSS / Webdis product. Redis is a registered trademark of Redis Ltd.
|
||||
|
||||
**Never expose your Redis® or Webdis to public without security protection!!!** You are response for protecting your server.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Install Redis®.
|
||||
2. Install Webdis.
|
||||
3. In `webdis.json`, configure the ACL for using password and username, and / or ip filters. **Never expose your Redis® or Webdis to public without security protection!!!**.
|
||||
4. Install and configure reverse proxy, firewall, https, etc. (You have to configure HTTPS correctly if you want to use it on iOS)
|
||||
5. In Remotely Save settings, enter your server address, username, password, and adjust the "base dir". Check connection.
|
||||
6. Sync!
|
||||
7. Serveral keys and values will be generated in your Redis® database:
|
||||
|
||||
```
|
||||
rs:fs:v1:${encodeURIComponent(vaultName+'/'+folderStructure+'/'+fileName)}:meta # you can HGETALL it
|
||||
rs:fs:v1:${encodeURIComponent(vaultName+'/'+folderStructure+'/'+fileName)}:content # you can GET it
|
||||
```
|
||||
@@ -36,6 +36,7 @@ esbuild
|
||||
"net",
|
||||
"http",
|
||||
"https",
|
||||
"vm",
|
||||
// ...builtins
|
||||
],
|
||||
inject: ["./esbuild.injecthelper.mjs"],
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "remotely-save",
|
||||
"name": "Remotely Save",
|
||||
"version": "0.4.11",
|
||||
"version": "0.4.20",
|
||||
"minAppVersion": "0.13.21",
|
||||
"description": "Yet another unofficial plugin allowing users to synchronize notes between local device and the cloud service.",
|
||||
"author": "fyears",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "remotely-save",
|
||||
"name": "Remotely Save",
|
||||
"version": "0.4.11",
|
||||
"version": "0.4.20",
|
||||
"minAppVersion": "0.13.21",
|
||||
"description": "Yet another unofficial plugin allowing users to synchronize notes between local device and the cloud service.",
|
||||
"author": "fyears",
|
||||
|
||||
+30
-30
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "remotely-save",
|
||||
"version": "0.4.11",
|
||||
"version": "0.4.20",
|
||||
"description": "This is yet another sync plugin for Obsidian app.",
|
||||
"scripts": {
|
||||
"dev2": "node esbuild.config.mjs --watch",
|
||||
@@ -16,7 +16,9 @@
|
||||
"process": "process/browser",
|
||||
"stream": "stream-browserify",
|
||||
"crypto": "crypto-browserify",
|
||||
"url": "url/"
|
||||
"url": "url/",
|
||||
"fs": false,
|
||||
"vm": false
|
||||
},
|
||||
"source": "main.ts",
|
||||
"keywords": [],
|
||||
@@ -24,64 +26,62 @@
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@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/jsdom": "^21.1.6",
|
||||
"@types/lodash": "^4.14.202",
|
||||
"@types/lodash": "^4.17.0",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/mocha": "^10.0.6",
|
||||
"@types/mustache": "^4.2.5",
|
||||
"@types/node": "^20.10.4",
|
||||
"@types/node": "^20.12.7",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"builtin-modules": "^3.3.0",
|
||||
"chai": "^4.3.10",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv": "^16.3.1",
|
||||
"esbuild": "^0.19.9",
|
||||
"dotenv": "^16.4.5",
|
||||
"esbuild": "^0.20.2",
|
||||
"esbuild-plugin-inline-worker": "^0.1.1",
|
||||
"jsdom": "^23.0.1",
|
||||
"mocha": "^10.2.0",
|
||||
"npm-check-updates": "^16.14.12",
|
||||
"obsidian": "^1.4.11",
|
||||
"prettier": "^3.1.1",
|
||||
"jsdom": "^24.0.0",
|
||||
"mocha": "^10.4.0",
|
||||
"npm-check-updates": "^16.14.20",
|
||||
"obsidian": "^1.5.7",
|
||||
"prettier": "^3.2.5",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript": "^5.4.5",
|
||||
"webdav-server": "^2.6.2",
|
||||
"webpack": "^5.89.0",
|
||||
"webpack": "^5.91.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"worker-loader": "^3.0.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.474.0",
|
||||
"@aws-sdk/lib-storage": "^3.474.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.474.0",
|
||||
"@aws-sdk/types": "^3.468.0",
|
||||
"@azure/msal-node": "^2.6.0",
|
||||
"@aws-sdk/client-s3": "^3.563.0",
|
||||
"@aws-sdk/lib-storage": "^3.563.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.556.0",
|
||||
"@aws-sdk/types": "^3.535.0",
|
||||
"@azure/msal-node": "^2.7.0",
|
||||
"@fyears/rclone-crypt": "^0.0.7",
|
||||
"@fyears/tsqueue": "^1.0.1",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
"@smithy/fetch-http-handler": "^2.3.1",
|
||||
"@smithy/protocol-http": "^3.0.11",
|
||||
"@smithy/querystring-builder": "^2.0.15",
|
||||
"acorn": "^8.11.2",
|
||||
"@smithy/fetch-http-handler": "^2.5.0",
|
||||
"@smithy/protocol-http": "^3.3.0",
|
||||
"@smithy/querystring-builder": "^2.2.0",
|
||||
"acorn": "^8.11.3",
|
||||
"aggregate-error": "^5.0.0",
|
||||
"assert": "^2.1.0",
|
||||
"aws-crt": "^1.20.0",
|
||||
"aws-crt": "^1.21.2",
|
||||
"buffer": "^6.0.3",
|
||||
"crypto-browserify": "^3.12.0",
|
||||
"delay": "^6.0.0",
|
||||
"dropbox": "^10.34.0",
|
||||
"emoji-regex": "^10.3.0",
|
||||
"http-status-codes": "^2.3.0",
|
||||
"localforage": "^1.10.0",
|
||||
"localforage-getitems": "^1.4.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide": "^0.298.0",
|
||||
"lucide": "^0.376.1",
|
||||
"mime-types": "^2.1.35",
|
||||
"mustache": "^4.2.0",
|
||||
"nanoid": "^5.0.4",
|
||||
"nanoid": "^5.0.7",
|
||||
"p-queue": "^8.0.1",
|
||||
"path-browserify": "^1.0.1",
|
||||
"process": "^0.11.10",
|
||||
@@ -91,7 +91,7 @@
|
||||
"stream-browserify": "^3.0.0",
|
||||
"url": "^0.11.3",
|
||||
"util": "^0.12.5",
|
||||
"webdav": "^5.3.1",
|
||||
"webdav": "^5.6.0",
|
||||
"xregexp": "^5.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
+28
-14
@@ -3,17 +3,22 @@
|
||||
* To avoid circular dependency.
|
||||
*/
|
||||
|
||||
import { Platform, requireApiVersion } from "obsidian";
|
||||
import type { LangType, LangTypeAndAuto } from "./i18n";
|
||||
|
||||
export const DEFAULT_CONTENT_TYPE = "application/octet-stream";
|
||||
|
||||
export type SUPPORTED_SERVICES_TYPE = "s3" | "webdav" | "dropbox" | "onedrive";
|
||||
export type SUPPORTED_SERVICES_TYPE =
|
||||
| "s3"
|
||||
| "webdav"
|
||||
| "dropbox"
|
||||
| "onedrive"
|
||||
| "webdis";
|
||||
|
||||
export type SUPPORTED_SERVICES_TYPE_WITH_REMOTE_BASE_DIR =
|
||||
| "webdav"
|
||||
| "dropbox"
|
||||
| "onedrive";
|
||||
| "onedrive"
|
||||
| "webdis";
|
||||
|
||||
export interface S3Config {
|
||||
s3Endpoint: string;
|
||||
@@ -27,6 +32,9 @@ export interface S3Config {
|
||||
remotePrefix?: string;
|
||||
|
||||
useAccurateMTime?: boolean;
|
||||
reverseProxyNoSignUrl?: string;
|
||||
|
||||
generateFolderObject?: boolean;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
@@ -83,6 +91,13 @@ export interface OnedriveConfig {
|
||||
remoteBaseDir?: string;
|
||||
}
|
||||
|
||||
export interface WebdisConfig {
|
||||
address: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
remoteBaseDir?: string;
|
||||
}
|
||||
|
||||
export type SyncDirectionType =
|
||||
| "bidirectional"
|
||||
| "incremental_pull_only"
|
||||
@@ -90,11 +105,14 @@ export type SyncDirectionType =
|
||||
|
||||
export type CipherMethodType = "rclone-base64" | "openssl-base64" | "unknown";
|
||||
|
||||
export type QRExportType = "all_but_oauth2" | "dropbox" | "onedrive";
|
||||
|
||||
export interface RemotelySavePluginSettings {
|
||||
s3: S3Config;
|
||||
webdav: WebdavConfig;
|
||||
dropbox: DropboxConfig;
|
||||
onedrive: OnedriveConfig;
|
||||
webdis: WebdisConfig;
|
||||
password: string;
|
||||
serviceType: SUPPORTED_SERVICES_TYPE;
|
||||
currLogLevel?: string;
|
||||
@@ -204,6 +222,7 @@ export interface Entity {
|
||||
sizeRaw: number;
|
||||
hash?: string;
|
||||
etag?: string;
|
||||
synthesizedFolder?: boolean;
|
||||
}
|
||||
|
||||
export interface UploadedType {
|
||||
@@ -223,6 +242,8 @@ export interface MixedEntity {
|
||||
decisionBranch?: number;
|
||||
decision?: DecisionTypeForMixedEntity;
|
||||
conflictAction?: ConflictActionType;
|
||||
|
||||
sideNotes?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -253,25 +274,18 @@ export interface FileOrFolderMixedState {
|
||||
deltimeRemoteFmt?: string;
|
||||
}
|
||||
|
||||
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_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 =
|
||||
(!Platform.isAndroidApp && requireApiVersion(API_VER_REQURL)) ||
|
||||
(Platform.isAndroidApp && requireApiVersion(API_VER_REQURL_ANDROID));
|
||||
|
||||
export const DEFAULT_DEBUG_FOLDER = "_debug_remotely_save/";
|
||||
export const DEFAULT_SYNC_PLANS_HISTORY_FILE_PREFIX =
|
||||
"sync_plans_hist_exported_on_";
|
||||
export const DEFAULT_LOG_HISTORY_FILE_PREFIX = "log_hist_exported_on_";
|
||||
export const DEFAULT_PROFILER_RESULT_FILE_PREFIX =
|
||||
"profiler_results_exported_on_";
|
||||
|
||||
export type SyncTriggerSourceType =
|
||||
| "manual"
|
||||
| "auto"
|
||||
| "dry"
|
||||
| "autoOnceInit"
|
||||
| "auto"
|
||||
| "auto_once_init"
|
||||
| "auto_sync_on_save";
|
||||
|
||||
export const REMOTELY_SAVE_VERSION_2022 = "0.3.25";
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Every utils requiring Obsidian is placed here.
|
||||
*/
|
||||
|
||||
import { Platform, requireApiVersion } from "obsidian";
|
||||
|
||||
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_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 =
|
||||
(!Platform.isAndroidApp && requireApiVersion(API_VER_REQURL)) ||
|
||||
(Platform.isAndroidApp && requireApiVersion(API_VER_REQURL_ANDROID));
|
||||
+46
-70
@@ -1,95 +1,71 @@
|
||||
import { TAbstractFile, TFolder, TFile, Vault } from "obsidian";
|
||||
|
||||
import type { SyncPlanType } from "./sync";
|
||||
import { readAllSyncPlanRecordTextsByVault } from "./localdb";
|
||||
import {
|
||||
readAllProfilerResultsByVault,
|
||||
readAllSyncPlanRecordTextsByVault,
|
||||
} from "./localdb";
|
||||
import type { InternalDBs } from "./localdb";
|
||||
import { mkdirpInVault } from "./misc";
|
||||
import { mkdirpInVault, unixTimeToStr } from "./misc";
|
||||
import {
|
||||
DEFAULT_DEBUG_FOLDER,
|
||||
DEFAULT_LOG_HISTORY_FILE_PREFIX,
|
||||
DEFAULT_PROFILER_RESULT_FILE_PREFIX,
|
||||
DEFAULT_SYNC_PLANS_HISTORY_FILE_PREFIX,
|
||||
FileOrFolderMixedState,
|
||||
} from "./baseTypes";
|
||||
|
||||
const turnSyncPlanToTable = (record: string) => {
|
||||
const syncPlan: SyncPlanType = JSON.parse(record);
|
||||
const { ts, tsFmt, remoteType, mixedStates } = syncPlan;
|
||||
|
||||
type allowedHeadersType = keyof FileOrFolderMixedState;
|
||||
const headers: allowedHeadersType[] = [
|
||||
"key",
|
||||
"remoteEncryptedKey",
|
||||
"existLocal",
|
||||
"sizeLocal",
|
||||
"sizeLocalEnc",
|
||||
"mtimeLocal",
|
||||
"deltimeLocal",
|
||||
"changeLocalMtimeUsingMapping",
|
||||
"existRemote",
|
||||
"sizeRemote",
|
||||
"sizeRemoteEnc",
|
||||
"mtimeRemote",
|
||||
"deltimeRemote",
|
||||
"changeRemoteMtimeUsingMapping",
|
||||
"decision",
|
||||
"decisionBranch",
|
||||
];
|
||||
|
||||
const lines = [
|
||||
`ts: ${ts}${tsFmt !== undefined ? " / " + tsFmt : ""}`,
|
||||
`remoteType: ${remoteType}`,
|
||||
`| ${headers.join(" | ")} |`,
|
||||
`| ${headers.map((x) => "---").join(" | ")} |`,
|
||||
];
|
||||
for (const [k1, v1] of Object.entries(syncPlan.mixedStates)) {
|
||||
const k = k1 as string;
|
||||
const v = v1 as FileOrFolderMixedState;
|
||||
const singleLine = [];
|
||||
for (const h of headers) {
|
||||
const field = v[h];
|
||||
if (field === undefined) {
|
||||
singleLine.push("");
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
h === "mtimeLocal" ||
|
||||
h === "deltimeLocal" ||
|
||||
h === "mtimeRemote" ||
|
||||
h === "deltimeRemote"
|
||||
) {
|
||||
const fmt = v[(h + "Fmt") as allowedHeadersType] as string;
|
||||
const s = `${field}${fmt !== undefined ? " / " + fmt : ""}`;
|
||||
singleLine.push(s);
|
||||
} else {
|
||||
singleLine.push(field);
|
||||
}
|
||||
}
|
||||
lines.push(`| ${singleLine.join(" | ")} |`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
export const exportVaultSyncPlansToFiles = async (
|
||||
db: InternalDBs,
|
||||
vault: Vault,
|
||||
vaultRandomID: string
|
||||
vaultRandomID: string,
|
||||
howMany: number
|
||||
) => {
|
||||
console.info("exporting");
|
||||
console.info("exporting sync plans");
|
||||
await mkdirpInVault(DEFAULT_DEBUG_FOLDER, vault);
|
||||
const records = await readAllSyncPlanRecordTextsByVault(db, vaultRandomID);
|
||||
let md = "";
|
||||
if (records.length === 0) {
|
||||
md = "No sync plans history found";
|
||||
} else {
|
||||
md =
|
||||
"Sync plans found:\n\n" +
|
||||
records.map((x) => "```json\n" + x + "\n```\n").join("\n");
|
||||
if (howMany <= 0) {
|
||||
md =
|
||||
"Sync plans found:\n\n" +
|
||||
records.map((x) => "```json\n" + x + "\n```\n").join("\n");
|
||||
} else {
|
||||
md =
|
||||
"Sync plans found:\n\n" +
|
||||
records
|
||||
.map((x) => "```json\n" + x + "\n```\n")
|
||||
.slice(0, howMany)
|
||||
.join("\n");
|
||||
}
|
||||
}
|
||||
const ts = Date.now();
|
||||
const filePath = `${DEFAULT_DEBUG_FOLDER}${DEFAULT_SYNC_PLANS_HISTORY_FILE_PREFIX}${ts}.md`;
|
||||
await vault.create(filePath, md, {
|
||||
mtime: ts,
|
||||
});
|
||||
console.info("finish exporting");
|
||||
console.info("finish exporting sync plans");
|
||||
};
|
||||
|
||||
export const exportVaultProfilerResultsToFiles = async (
|
||||
db: InternalDBs,
|
||||
vault: Vault,
|
||||
vaultRandomID: string
|
||||
) => {
|
||||
console.info("exporting profiler results");
|
||||
await mkdirpInVault(DEFAULT_DEBUG_FOLDER, vault);
|
||||
const records = await readAllProfilerResultsByVault(db, vaultRandomID);
|
||||
let md = "";
|
||||
if (records.length === 0) {
|
||||
md = "No profiler results found";
|
||||
} else {
|
||||
md =
|
||||
"Profiler results found:\n\n" +
|
||||
records.map((x) => "```\n" + x + "\n```\n").join("\n");
|
||||
}
|
||||
const ts = Date.now();
|
||||
const filePath = `${DEFAULT_DEBUG_FOLDER}${DEFAULT_PROFILER_RESULT_FILE_PREFIX}${ts}.md`;
|
||||
await vault.create(filePath, md, {
|
||||
mtime: ts,
|
||||
});
|
||||
console.info("finish exporting profiler results");
|
||||
};
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Entity } from "./baseTypes";
|
||||
|
||||
export abstract class FakeFs {
|
||||
abstract kind: string;
|
||||
abstract walk(): Promise<Entity[]>;
|
||||
abstract stat(key: string): Promise<Entity>;
|
||||
abstract mkdir(key: string, mtime?: number, ctime?: number): Promise<Entity>;
|
||||
abstract writeFile(
|
||||
key: string,
|
||||
content: ArrayBuffer,
|
||||
mtime: number,
|
||||
ctime: number
|
||||
): Promise<Entity>;
|
||||
abstract readFile(key: string): Promise<ArrayBuffer>;
|
||||
abstract rm(key: string): Promise<void>;
|
||||
abstract checkConnect(callbackFunc?: any): Promise<boolean>;
|
||||
abstract getUserDisplayName(): Promise<string>;
|
||||
abstract revokeAuth(): Promise<any>;
|
||||
}
|
||||
@@ -1,23 +1,21 @@
|
||||
import { rangeDelay } from "delay";
|
||||
import { FakeFs } from "./fsAll";
|
||||
import { Dropbox, DropboxAuth } from "dropbox";
|
||||
import type { files, DropboxResponseError, DropboxResponse } from "dropbox";
|
||||
import { Vault } from "obsidian";
|
||||
import * as path from "path";
|
||||
import {
|
||||
DropboxConfig,
|
||||
Entity,
|
||||
COMMAND_CALLBACK_DROPBOX,
|
||||
OAUTH2_FORCE_EXPIRE_MILLISECONDS,
|
||||
UploadedType,
|
||||
Entity,
|
||||
} from "./baseTypes";
|
||||
import random from "lodash/random";
|
||||
import {
|
||||
bufferToArrayBuffer,
|
||||
delay,
|
||||
getFolderLevels,
|
||||
getParentFolder,
|
||||
hasEmojiInText,
|
||||
headersToRecord,
|
||||
mkdirpInVault,
|
||||
} from "./misc";
|
||||
import { Cipher } from "./encryptUnified";
|
||||
|
||||
export { Dropbox } from "dropbox";
|
||||
|
||||
@@ -32,10 +30,7 @@ export const DEFAULT_DROPBOX_CONFIG: DropboxConfig = {
|
||||
credentialsShouldBeDeletedAtTime: 0,
|
||||
};
|
||||
|
||||
export const getDropboxPath = (
|
||||
fileOrFolderPath: string,
|
||||
remoteBaseDir: string
|
||||
) => {
|
||||
const getDropboxPath = (fileOrFolderPath: string, remoteBaseDir: string) => {
|
||||
let key = fileOrFolderPath;
|
||||
if (fileOrFolderPath === "/" || fileOrFolderPath === "") {
|
||||
// special
|
||||
@@ -82,20 +77,22 @@ const fromDropboxItemToEntity = (
|
||||
|
||||
if (x[".tag"] === "folder") {
|
||||
return {
|
||||
key: key,
|
||||
keyRaw: key,
|
||||
size: 0,
|
||||
sizeRaw: 0,
|
||||
etag: `${x.id}\t`,
|
||||
} as Entity;
|
||||
} else if (x[".tag"] === "file") {
|
||||
const mtimeCli = Date.parse(x.client_modified).valueOf();
|
||||
const mtimeSvr = Date.parse(x.server_modified).valueOf();
|
||||
return {
|
||||
key: key,
|
||||
keyRaw: key,
|
||||
mtimeCli: mtimeCli,
|
||||
mtimeSvr: mtimeSvr,
|
||||
size: x.size,
|
||||
sizeRaw: x.size,
|
||||
hash: x.content_hash,
|
||||
etag: `${x.id}\t${x.content_hash}`,
|
||||
} as Entity;
|
||||
} else {
|
||||
// x[".tag"] === "deleted"
|
||||
@@ -103,6 +100,132 @@ const fromDropboxItemToEntity = (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* https://github.com/remotely-save/remotely-save/issues/567
|
||||
* https://www.dropboxforum.com/t5/Dropbox-API-Support-Feedback/Case-Sensitivity-in-API-2/td-p/191279
|
||||
* @param entities
|
||||
*/
|
||||
export const fixEntityListCasesInplace = (entities: { key?: string }[]) => {
|
||||
for (const iterator of entities) {
|
||||
if (iterator.key === undefined) {
|
||||
throw Error(`dropbox list should all have key, but meet undefined`);
|
||||
}
|
||||
}
|
||||
|
||||
entities.sort((a, b) => a.key!.length - b.key!.length);
|
||||
// console.log(JSON.stringify(entities,null,2));
|
||||
|
||||
const caseMapping: Record<string, string> = { "": "" };
|
||||
for (const e of entities) {
|
||||
// console.log(`looking for: ${JSON.stringify(e, null, 2)}`);
|
||||
|
||||
let parentFolder = getParentFolder(e.key!);
|
||||
if (parentFolder === "/") {
|
||||
parentFolder = "";
|
||||
}
|
||||
const parentFolderLower = parentFolder.toLocaleLowerCase();
|
||||
const segs = e.key!.split("/");
|
||||
if (e.key!.endsWith("/")) {
|
||||
// folder
|
||||
if (caseMapping.hasOwnProperty(parentFolderLower)) {
|
||||
const newKey = `${caseMapping[parentFolderLower]}${segs
|
||||
.slice(-2)
|
||||
.join("/")}`;
|
||||
caseMapping[newKey.toLocaleLowerCase()] = newKey;
|
||||
e.key = newKey;
|
||||
// console.log(JSON.stringify(caseMapping,null,2));
|
||||
continue;
|
||||
} else {
|
||||
throw Error(`${parentFolder} doesn't have cases record??`);
|
||||
}
|
||||
} else {
|
||||
// file
|
||||
if (caseMapping.hasOwnProperty(parentFolderLower)) {
|
||||
const newKey = `${caseMapping[parentFolderLower]}${segs
|
||||
.slice(-1)
|
||||
.join("/")}`;
|
||||
e.key = newKey;
|
||||
continue;
|
||||
} else {
|
||||
throw Error(`${parentFolder} doesn't have cases record??`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entities;
|
||||
};
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Other usual common methods
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
interface ErrSubType {
|
||||
error: {
|
||||
retry_after: number;
|
||||
};
|
||||
}
|
||||
|
||||
async function retryReq<T>(
|
||||
reqFunc: () => Promise<DropboxResponse<T>>,
|
||||
extraHint: string = ""
|
||||
): Promise<DropboxResponse<T> | undefined> {
|
||||
const waitSeconds = [1, 2, 4, 8]; // hard code exponential backoff
|
||||
for (let idx = 0; idx < waitSeconds.length; ++idx) {
|
||||
try {
|
||||
if (idx !== 0) {
|
||||
console.warn(
|
||||
`${extraHint === "" ? "" : extraHint + ": "}The ${
|
||||
idx + 1
|
||||
}-th try starts at time ${Date.now()}`
|
||||
);
|
||||
}
|
||||
return await reqFunc();
|
||||
} catch (e: unknown) {
|
||||
const err = e as DropboxResponseError<ErrSubType>;
|
||||
if (err.status === undefined) {
|
||||
// then the err is not DropboxResponseError
|
||||
throw err;
|
||||
}
|
||||
if (err.status !== 429) {
|
||||
// then the err is not "too many requests", give up
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (idx === waitSeconds.length - 1) {
|
||||
// the last retry also failed, give up
|
||||
throw new Error(
|
||||
`${
|
||||
extraHint === "" ? "" : extraHint + ": "
|
||||
}"429 too many requests", after retrying for ${
|
||||
idx + 1
|
||||
} times still failed.`
|
||||
);
|
||||
}
|
||||
|
||||
const headers = headersToRecord(err.headers);
|
||||
const svrSec =
|
||||
err.error.error.retry_after ||
|
||||
parseInt(headers["retry-after"] || "1") ||
|
||||
1;
|
||||
const fallbackSec = waitSeconds[idx];
|
||||
const secMin = Math.max(svrSec, fallbackSec);
|
||||
const secMax = Math.max(secMin * 1.8, 2);
|
||||
console.warn(
|
||||
`${
|
||||
extraHint === "" ? "" : extraHint + ": "
|
||||
}We have "429 too many requests" error of ${
|
||||
idx + 1
|
||||
}-th try, at time ${Date.now()}, and wait for ${secMin} ~ ${secMax} seconds to retry. Original info: ${JSON.stringify(
|
||||
err.error,
|
||||
null,
|
||||
2
|
||||
)}`
|
||||
);
|
||||
await delay(random(secMin * 1000, secMax * 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Dropbox authorization using PKCE
|
||||
// see https://dropbox.tech/developers/pkce--what-and-why-
|
||||
@@ -226,94 +349,33 @@ export const setConfigBySuccessfullAuthInplace = async (
|
||||
};
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Other usual common methods
|
||||
// real exported interface
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
interface ErrSubType {
|
||||
error: {
|
||||
retry_after: number;
|
||||
};
|
||||
}
|
||||
|
||||
async function retryReq<T>(
|
||||
reqFunc: () => Promise<DropboxResponse<T>>,
|
||||
extraHint: string = ""
|
||||
): Promise<DropboxResponse<T> | undefined> {
|
||||
const waitSeconds = [1, 2, 4, 8]; // hard code exponential backoff
|
||||
for (let idx = 0; idx < waitSeconds.length; ++idx) {
|
||||
try {
|
||||
if (idx !== 0) {
|
||||
console.warn(
|
||||
`${extraHint === "" ? "" : extraHint + ": "}The ${
|
||||
idx + 1
|
||||
}-th try starts at time ${Date.now()}`
|
||||
);
|
||||
}
|
||||
return await reqFunc();
|
||||
} catch (e: unknown) {
|
||||
const err = e as DropboxResponseError<ErrSubType>;
|
||||
if (err.status === undefined) {
|
||||
// then the err is not DropboxResponseError
|
||||
throw err;
|
||||
}
|
||||
if (err.status !== 429) {
|
||||
// then the err is not "too many requests", give up
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (idx === waitSeconds.length - 1) {
|
||||
// the last retry also failed, give up
|
||||
throw new Error(
|
||||
`${
|
||||
extraHint === "" ? "" : extraHint + ": "
|
||||
}"429 too many requests", after retrying for ${
|
||||
idx + 1
|
||||
} times still failed.`
|
||||
);
|
||||
}
|
||||
|
||||
const headers = headersToRecord(err.headers);
|
||||
const svrSec =
|
||||
err.error.error.retry_after ||
|
||||
parseInt(headers["retry-after"] || "1") ||
|
||||
1;
|
||||
const fallbackSec = waitSeconds[idx];
|
||||
const secMin = Math.max(svrSec, fallbackSec);
|
||||
const secMax = Math.max(secMin * 1.8, 2);
|
||||
console.warn(
|
||||
`${
|
||||
extraHint === "" ? "" : extraHint + ": "
|
||||
}We have "429 too many requests" error of ${
|
||||
idx + 1
|
||||
}-th try, at time ${Date.now()}, and wait for ${secMin} ~ ${secMax} seconds to retry. Original info: ${JSON.stringify(
|
||||
err.error,
|
||||
null,
|
||||
2
|
||||
)}`
|
||||
);
|
||||
await rangeDelay(secMin * 1000, secMax * 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class WrappedDropboxClient {
|
||||
export class FakeFsDropbox extends FakeFs {
|
||||
kind: "dropbox";
|
||||
dropboxConfig: DropboxConfig;
|
||||
remoteBaseDir: string;
|
||||
saveUpdatedConfigFunc: () => Promise<any>;
|
||||
dropbox!: Dropbox;
|
||||
vaultFolderExists: boolean;
|
||||
foldersCreatedBefore: Set<string>;
|
||||
|
||||
constructor(
|
||||
dropboxConfig: DropboxConfig,
|
||||
remoteBaseDir: string,
|
||||
vaultName: string,
|
||||
saveUpdatedConfigFunc: () => Promise<any>
|
||||
) {
|
||||
super();
|
||||
this.kind = "dropbox";
|
||||
this.dropboxConfig = dropboxConfig;
|
||||
this.remoteBaseDir = remoteBaseDir;
|
||||
this.remoteBaseDir = this.dropboxConfig.remoteBaseDir || vaultName || "";
|
||||
this.saveUpdatedConfigFunc = saveUpdatedConfigFunc;
|
||||
this.vaultFolderExists = false;
|
||||
this.foldersCreatedBefore = new Set();
|
||||
}
|
||||
|
||||
init = async () => {
|
||||
async _init() {
|
||||
// check token
|
||||
if (
|
||||
this.dropboxConfig.accessToken === "" ||
|
||||
@@ -386,387 +448,292 @@ export class WrappedDropboxClient {
|
||||
}
|
||||
}
|
||||
|
||||
return this.dropbox;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param dropboxConfig
|
||||
* @returns
|
||||
*/
|
||||
export const getDropboxClient = (
|
||||
dropboxConfig: DropboxConfig,
|
||||
remoteBaseDir: string,
|
||||
saveUpdatedConfigFunc: () => Promise<any>
|
||||
) => {
|
||||
return new WrappedDropboxClient(
|
||||
dropboxConfig,
|
||||
remoteBaseDir,
|
||||
saveUpdatedConfigFunc
|
||||
);
|
||||
};
|
||||
|
||||
export const getRemoteMeta = async (
|
||||
client: WrappedDropboxClient,
|
||||
remotePath: string
|
||||
) => {
|
||||
await client.init();
|
||||
// if (remotePath === "" || remotePath === "/") {
|
||||
// // filesGetMetadata doesn't support root folder
|
||||
// // we instead try to list files
|
||||
// // if no error occurs, we ensemble a fake result.
|
||||
// const rsp = await retryReq(() =>
|
||||
// client.dropbox.filesListFolder({
|
||||
// path: `/${client.remoteBaseDir}`,
|
||||
// recursive: false, // don't need to recursive here
|
||||
// })
|
||||
// );
|
||||
// if (rsp.status !== 200) {
|
||||
// throw Error(JSON.stringify(rsp));
|
||||
// }
|
||||
// return {
|
||||
// key: remotePath,
|
||||
// lastModified: undefined,
|
||||
// size: 0,
|
||||
// remoteType: "dropbox",
|
||||
// etag: undefined,
|
||||
// } as Entity;
|
||||
// }
|
||||
|
||||
const rsp = await retryReq(() =>
|
||||
client.dropbox.filesGetMetadata({
|
||||
path: remotePath,
|
||||
})
|
||||
);
|
||||
if (rsp === undefined) {
|
||||
throw Error("dropbox.filesGetMetadata undefinded");
|
||||
return this;
|
||||
}
|
||||
if (rsp.status !== 200) {
|
||||
throw Error(JSON.stringify(rsp));
|
||||
|
||||
async walk(): Promise<Entity[]> {
|
||||
await this._init();
|
||||
|
||||
let res = await this.dropbox.filesListFolder({
|
||||
path: `/${this.remoteBaseDir}`,
|
||||
recursive: true,
|
||||
include_deleted: false,
|
||||
limit: 1000,
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
throw Error(JSON.stringify(res));
|
||||
}
|
||||
// console.info(res);
|
||||
|
||||
const contents = res.result.entries;
|
||||
const unifiedContents = contents
|
||||
.filter((x) => x[".tag"] !== "deleted")
|
||||
.filter((x) => x.path_display !== `/${this.remoteBaseDir}`)
|
||||
.map((x) => fromDropboxItemToEntity(x, this.remoteBaseDir));
|
||||
|
||||
while (res.result.has_more) {
|
||||
res = await this.dropbox.filesListFolderContinue({
|
||||
cursor: res.result.cursor,
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
throw Error(JSON.stringify(res));
|
||||
}
|
||||
|
||||
const contents2 = res.result.entries;
|
||||
const unifiedContents2 = contents2
|
||||
.filter((x) => x[".tag"] !== "deleted")
|
||||
.filter((x) => x.path_display !== `/${this.remoteBaseDir}`)
|
||||
.map((x) => fromDropboxItemToEntity(x, this.remoteBaseDir));
|
||||
unifiedContents.push(...unifiedContents2);
|
||||
}
|
||||
|
||||
fixEntityListCasesInplace(unifiedContents);
|
||||
|
||||
return unifiedContents;
|
||||
}
|
||||
return fromDropboxItemToEntity(rsp.result, client.remoteBaseDir);
|
||||
};
|
||||
|
||||
export const uploadToRemote = async (
|
||||
client: WrappedDropboxClient,
|
||||
fileOrFolderPath: string,
|
||||
vault: Vault | undefined,
|
||||
isRecursively: boolean,
|
||||
cipher: Cipher,
|
||||
remoteEncryptedKey: string = "",
|
||||
foldersCreatedBefore: Set<string> | undefined = undefined,
|
||||
uploadRaw: boolean = false,
|
||||
rawContent: string | ArrayBuffer = "",
|
||||
rawContentMTime: number = 0,
|
||||
rawContentCTime: number = 0
|
||||
): Promise<UploadedType> => {
|
||||
await client.init();
|
||||
async stat(key: string): Promise<Entity> {
|
||||
await this._init();
|
||||
return await this._statFromRoot(getDropboxPath(key, this.remoteBaseDir));
|
||||
}
|
||||
|
||||
let uploadFile = fileOrFolderPath;
|
||||
if (!cipher.isPasswordEmpty()) {
|
||||
if (remoteEncryptedKey === undefined || remoteEncryptedKey === "") {
|
||||
throw Error(
|
||||
`uploadToRemote(dropbox) you have password but remoteEncryptedKey is empty!`
|
||||
async _statFromRoot(key: string): Promise<Entity> {
|
||||
// if (key === "" || key === "/") {
|
||||
// // filesGetMetadata doesn't support root folder
|
||||
// // we instead try to list files
|
||||
// // if no error occurs, we ensemble a fake result.
|
||||
// const rsp = await retryReq(() =>
|
||||
// client.dropbox.filesListFolder({
|
||||
// path: `/${client.key}`,
|
||||
// recursive: false, // don't need to recursive here
|
||||
// })
|
||||
// );
|
||||
// if (rsp.status !== 200) {
|
||||
// throw Error(JSON.stringify(rsp));
|
||||
// }
|
||||
// return {
|
||||
// key: remotePath,
|
||||
// lastModified: undefined,
|
||||
// size: 0,
|
||||
// remoteType: "dropbox",
|
||||
// etag: undefined,
|
||||
// } as Entity;
|
||||
// }
|
||||
|
||||
const rsp = await retryReq(() =>
|
||||
this.dropbox.filesGetMetadata({
|
||||
path: key,
|
||||
})
|
||||
);
|
||||
if (rsp === undefined) {
|
||||
throw Error("dropbox.filesGetMetadata undefinded");
|
||||
}
|
||||
if (rsp.status !== 200) {
|
||||
throw Error(JSON.stringify(rsp));
|
||||
}
|
||||
return fromDropboxItemToEntity(rsp.result, this.remoteBaseDir);
|
||||
}
|
||||
|
||||
async mkdir(key: string, mtime?: number, ctime?: number): Promise<Entity> {
|
||||
if (!key.endsWith("/")) {
|
||||
throw Error(`you should not call mkdir on ${key}`);
|
||||
}
|
||||
await this._init();
|
||||
|
||||
const uploadFile = getDropboxPath(key, this.remoteBaseDir);
|
||||
|
||||
return await this._mkdirFromRoot(uploadFile, mtime, ctime);
|
||||
}
|
||||
|
||||
async _mkdirFromRoot(
|
||||
key: string,
|
||||
mtime?: number,
|
||||
ctime?: number
|
||||
): Promise<Entity> {
|
||||
if (hasEmojiInText(key)) {
|
||||
throw new Error(
|
||||
`${key}: Error: Dropbox does not support emoji in file / folder names.`
|
||||
);
|
||||
}
|
||||
uploadFile = remoteEncryptedKey;
|
||||
if (this.foldersCreatedBefore?.has(key)) {
|
||||
// created, pass
|
||||
} else {
|
||||
try {
|
||||
await retryReq(
|
||||
() =>
|
||||
this.dropbox.filesCreateFolderV2({
|
||||
path: key,
|
||||
}),
|
||||
key // just a hint
|
||||
);
|
||||
this.foldersCreatedBefore?.add(key);
|
||||
} catch (e: unknown) {
|
||||
const err = e as DropboxResponseError<files.CreateFolderError>;
|
||||
if (err.status === undefined) {
|
||||
throw err;
|
||||
}
|
||||
if (err.status === 409) {
|
||||
// pass
|
||||
this.foldersCreatedBefore?.add(key);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
return await this._statFromRoot(key);
|
||||
}
|
||||
uploadFile = getDropboxPath(uploadFile, client.remoteBaseDir);
|
||||
|
||||
if (hasEmojiInText(uploadFile)) {
|
||||
throw new Error(
|
||||
`${uploadFile}: Error: Dropbox does not support emoji in file / folder names.`
|
||||
async writeFile(
|
||||
key: string,
|
||||
content: ArrayBuffer,
|
||||
mtime: number,
|
||||
ctime: number
|
||||
): Promise<Entity> {
|
||||
if (key.endsWith("/")) {
|
||||
throw Error(`you should not call writeFile on ${key}`);
|
||||
}
|
||||
|
||||
await this._init();
|
||||
const uploadFile = getDropboxPath(key, this.remoteBaseDir);
|
||||
|
||||
return await this._writeFileFromRoot(
|
||||
uploadFile,
|
||||
content,
|
||||
mtime,
|
||||
ctime,
|
||||
key
|
||||
);
|
||||
}
|
||||
|
||||
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("/");
|
||||
|
||||
if (isFolder && isRecursively) {
|
||||
throw Error("upload function doesn't implement recursive function yet!");
|
||||
} else if (isFolder && !isRecursively) {
|
||||
if (uploadRaw) {
|
||||
throw Error(`you specify uploadRaw, but you also provide a folder key!`);
|
||||
}
|
||||
// folder
|
||||
if (cipher.isPasswordEmpty() || cipher.isFolderAware()) {
|
||||
// if not encrypted, || encrypted isFolderAware, mkdir a remote folder
|
||||
if (foldersCreatedBefore?.has(uploadFile)) {
|
||||
// created, pass
|
||||
} else {
|
||||
try {
|
||||
await retryReq(
|
||||
() =>
|
||||
client.dropbox.filesCreateFolderV2({
|
||||
path: uploadFile,
|
||||
}),
|
||||
fileOrFolderPath
|
||||
);
|
||||
foldersCreatedBefore?.add(uploadFile);
|
||||
} catch (e: unknown) {
|
||||
const err = e as DropboxResponseError<files.CreateFolderError>;
|
||||
if (err.status === undefined) {
|
||||
throw err;
|
||||
}
|
||||
if (err.status === 409) {
|
||||
// pass
|
||||
foldersCreatedBefore?.add(uploadFile);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
const res = await getRemoteMeta(client, uploadFile);
|
||||
return {
|
||||
entity: res,
|
||||
mtimeCli: mtime,
|
||||
};
|
||||
} else {
|
||||
// if encrypted && !isFolderAware(),
|
||||
// upload a fake file with the encrypted file name
|
||||
await retryReq(
|
||||
() =>
|
||||
client.dropbox.filesUpload({
|
||||
path: uploadFile,
|
||||
contents: "",
|
||||
client_modified: mtimeStr,
|
||||
}),
|
||||
fileOrFolderPath
|
||||
async _writeFileFromRoot(
|
||||
key: string,
|
||||
content: ArrayBuffer,
|
||||
mtime: number,
|
||||
ctime: number,
|
||||
origKey: string
|
||||
): Promise<Entity> {
|
||||
if (hasEmojiInText(origKey)) {
|
||||
throw new Error(
|
||||
`${origKey}: Error: Dropbox does not support emoji in file / folder names.`
|
||||
);
|
||||
return {
|
||||
entity: await getRemoteMeta(client, uploadFile),
|
||||
mtimeCli: mtime,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// file
|
||||
// we ignore isRecursively parameter here
|
||||
let localContent = undefined;
|
||||
if (uploadRaw) {
|
||||
if (typeof rawContent === "string") {
|
||||
localContent = new TextEncoder().encode(rawContent).buffer;
|
||||
} else {
|
||||
localContent = rawContent;
|
||||
}
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
let remoteContent = localContent;
|
||||
if (!cipher.isPasswordEmpty()) {
|
||||
remoteContent = await cipher.encryptContent(localContent);
|
||||
}
|
||||
|
||||
const mtimeFixed = Math.floor(mtime / 1000.0) * 1000;
|
||||
const ctimeFixed = Math.floor(ctime / 1000.0) * 1000;
|
||||
const mtimeStr = new Date(mtimeFixed)
|
||||
.toISOString()
|
||||
.replace(/\.\d{3}Z$/, "Z");
|
||||
|
||||
// in dropbox, we don't need to create folders before uploading! cool!
|
||||
// TODO: filesUploadSession for larger files (>=150 MB)
|
||||
|
||||
await retryReq(
|
||||
() =>
|
||||
client.dropbox.filesUpload({
|
||||
path: uploadFile,
|
||||
contents: remoteContent,
|
||||
this.dropbox.filesUpload({
|
||||
path: key,
|
||||
contents: content,
|
||||
mode: {
|
||||
".tag": "overwrite",
|
||||
},
|
||||
client_modified: mtimeStr,
|
||||
}),
|
||||
fileOrFolderPath
|
||||
origKey // hint
|
||||
);
|
||||
|
||||
// we want to mark that parent folders are created
|
||||
if (foldersCreatedBefore !== undefined) {
|
||||
const dirs = getFolderLevels(uploadFile).map((x) =>
|
||||
getDropboxPath(x, client.remoteBaseDir)
|
||||
if (this.foldersCreatedBefore !== undefined) {
|
||||
const dirs = getFolderLevels(origKey).map((x) =>
|
||||
getDropboxPath(x, this.remoteBaseDir)
|
||||
);
|
||||
for (const dir of dirs) {
|
||||
foldersCreatedBefore?.add(dir);
|
||||
this.foldersCreatedBefore?.add(dir);
|
||||
}
|
||||
}
|
||||
return {
|
||||
entity: await getRemoteMeta(client, uploadFile),
|
||||
mtimeCli: mtime,
|
||||
};
|
||||
return await this._statFromRoot(key);
|
||||
}
|
||||
};
|
||||
|
||||
export const listAllFromRemote = async (client: WrappedDropboxClient) => {
|
||||
await client.init();
|
||||
let res = await client.dropbox.filesListFolder({
|
||||
path: `/${client.remoteBaseDir}`,
|
||||
recursive: true,
|
||||
include_deleted: false,
|
||||
limit: 1000,
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
throw Error(JSON.stringify(res));
|
||||
}
|
||||
// console.info(res);
|
||||
|
||||
const contents = res.result.entries;
|
||||
const unifiedContents = contents
|
||||
.filter((x) => x[".tag"] !== "deleted")
|
||||
.filter((x) => x.path_display !== `/${client.remoteBaseDir}`)
|
||||
.map((x) => fromDropboxItemToEntity(x, client.remoteBaseDir));
|
||||
|
||||
while (res.result.has_more) {
|
||||
res = await client.dropbox.filesListFolderContinue({
|
||||
cursor: res.result.cursor,
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
throw Error(JSON.stringify(res));
|
||||
async readFile(key: string): Promise<ArrayBuffer> {
|
||||
await this._init();
|
||||
if (key.endsWith("/")) {
|
||||
throw new Error(`you should not call readFile on folder ${key}`);
|
||||
}
|
||||
|
||||
const contents2 = res.result.entries;
|
||||
const unifiedContents2 = contents2
|
||||
.filter((x) => x[".tag"] !== "deleted")
|
||||
.filter((x) => x.path_display !== `/${client.remoteBaseDir}`)
|
||||
.map((x) => fromDropboxItemToEntity(x, client.remoteBaseDir));
|
||||
unifiedContents.push(...unifiedContents2);
|
||||
const downloadFile = getDropboxPath(key, this.remoteBaseDir);
|
||||
return await this._readFileFromRoot(downloadFile);
|
||||
}
|
||||
|
||||
return unifiedContents;
|
||||
};
|
||||
|
||||
const downloadFromRemoteRaw = async (
|
||||
client: WrappedDropboxClient,
|
||||
remotePath: string
|
||||
) => {
|
||||
await client.init();
|
||||
const rsp = await retryReq(
|
||||
() =>
|
||||
client.dropbox.filesDownload({
|
||||
path: remotePath,
|
||||
}),
|
||||
`downloadFromRemoteRaw=${remotePath}`
|
||||
);
|
||||
if (rsp === undefined) {
|
||||
throw Error(`unknown rsp from dropbox download: ${rsp}`);
|
||||
}
|
||||
if ((rsp.result as any).fileBlob !== undefined) {
|
||||
// we get a Blob
|
||||
const content = (rsp.result as any).fileBlob as Blob;
|
||||
return await content.arrayBuffer();
|
||||
} else if ((rsp.result as any).fileBinary !== undefined) {
|
||||
// we get a Buffer
|
||||
const content = (rsp.result as any).fileBinary as Buffer;
|
||||
return bufferToArrayBuffer(content);
|
||||
} else {
|
||||
throw Error(`unknown rsp from dropbox download: ${rsp}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadFromRemote = async (
|
||||
client: WrappedDropboxClient,
|
||||
fileOrFolderPath: string,
|
||||
vault: Vault,
|
||||
mtime: number,
|
||||
cipher: Cipher,
|
||||
remoteEncryptedKey: string = "",
|
||||
skipSaving: boolean = false
|
||||
) => {
|
||||
await client.init();
|
||||
|
||||
const isFolder = fileOrFolderPath.endsWith("/");
|
||||
|
||||
if (!skipSaving) {
|
||||
await mkdirpInVault(fileOrFolderPath, vault);
|
||||
}
|
||||
|
||||
// the file is always local file
|
||||
// we need to encrypt it
|
||||
|
||||
if (isFolder) {
|
||||
// mkdirp locally is enough
|
||||
// do nothing here
|
||||
return new ArrayBuffer(0);
|
||||
} else {
|
||||
let downloadFile = fileOrFolderPath;
|
||||
if (!cipher.isPasswordEmpty()) {
|
||||
downloadFile = remoteEncryptedKey;
|
||||
}
|
||||
downloadFile = getDropboxPath(downloadFile, client.remoteBaseDir);
|
||||
const remoteContent = await downloadFromRemoteRaw(client, downloadFile);
|
||||
let localContent = remoteContent;
|
||||
if (!cipher.isPasswordEmpty()) {
|
||||
localContent = await cipher.decryptContent(remoteContent);
|
||||
}
|
||||
if (!skipSaving) {
|
||||
await vault.adapter.writeBinary(fileOrFolderPath, localContent, {
|
||||
mtime: mtime,
|
||||
});
|
||||
}
|
||||
return localContent;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteFromRemote = async (
|
||||
client: WrappedDropboxClient,
|
||||
fileOrFolderPath: string,
|
||||
cipher: Cipher,
|
||||
remoteEncryptedKey: string = ""
|
||||
) => {
|
||||
if (fileOrFolderPath === "/") {
|
||||
return;
|
||||
}
|
||||
let remoteFileName = fileOrFolderPath;
|
||||
if (!cipher.isPasswordEmpty()) {
|
||||
remoteFileName = remoteEncryptedKey;
|
||||
}
|
||||
remoteFileName = getDropboxPath(remoteFileName, client.remoteBaseDir);
|
||||
|
||||
await client.init();
|
||||
try {
|
||||
await retryReq(
|
||||
async _readFileFromRoot(key: string): Promise<ArrayBuffer> {
|
||||
const rsp = await retryReq(
|
||||
() =>
|
||||
client.dropbox.filesDeleteV2({
|
||||
path: remoteFileName,
|
||||
this.dropbox.filesDownload({
|
||||
path: key,
|
||||
}),
|
||||
fileOrFolderPath
|
||||
`downloadFromRemoteRaw=${key}`
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("some error while deleting");
|
||||
console.error(err);
|
||||
if (rsp === undefined) {
|
||||
throw Error(`unknown rsp from dropbox download: ${rsp}`);
|
||||
}
|
||||
if ((rsp.result as any).fileBlob !== undefined) {
|
||||
// we get a Blob
|
||||
const content = (rsp.result as any).fileBlob as Blob;
|
||||
return await content.arrayBuffer();
|
||||
} else if ((rsp.result as any).fileBinary !== undefined) {
|
||||
// we get a Buffer
|
||||
const content = (rsp.result as any).fileBinary as Buffer;
|
||||
return bufferToArrayBuffer(content);
|
||||
} else {
|
||||
throw Error(`unknown rsp from dropbox download: ${rsp}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const checkConnectivity = async (
|
||||
client: WrappedDropboxClient,
|
||||
callbackFunc?: any
|
||||
) => {
|
||||
try {
|
||||
await client.init();
|
||||
const results = await getRemoteMeta(client, `/${client.remoteBaseDir}`);
|
||||
if (results === undefined) {
|
||||
async rm(key: string): Promise<void> {
|
||||
if (key === "/") {
|
||||
return;
|
||||
}
|
||||
const remoteFileName = getDropboxPath(key, this.remoteBaseDir);
|
||||
|
||||
await this._init();
|
||||
try {
|
||||
await retryReq(
|
||||
() =>
|
||||
this.dropbox.filesDeleteV2({
|
||||
path: remoteFileName,
|
||||
}),
|
||||
key // just a hint here
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("some error while deleting");
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async checkConnect(callbackFunc?: any): Promise<boolean> {
|
||||
try {
|
||||
await this._init();
|
||||
const results = await this._statFromRoot(`/${this.remoteBaseDir}`);
|
||||
if (results === undefined) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.debug(err);
|
||||
callbackFunc?.(err);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.debug(err);
|
||||
if (callbackFunc !== undefined) {
|
||||
callbackFunc(err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getUserDisplayName = async (client: WrappedDropboxClient) => {
|
||||
await client.init();
|
||||
const acct = await client.dropbox.usersGetCurrentAccount();
|
||||
return acct.result.name.display_name;
|
||||
};
|
||||
async getUserDisplayName() {
|
||||
await this._init();
|
||||
const acct = await this.dropbox.usersGetCurrentAccount();
|
||||
return acct.result.name.display_name;
|
||||
}
|
||||
|
||||
export const revokeAuth = async (client: WrappedDropboxClient) => {
|
||||
await client.init();
|
||||
await client.dropbox.authTokenRevoke();
|
||||
};
|
||||
async revokeAuth() {
|
||||
try {
|
||||
await this._init();
|
||||
await this.dropbox.authTokenRevoke();
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
import { CipherMethodType, Entity } from "./baseTypes";
|
||||
import * as openssl from "./encryptOpenSSL";
|
||||
import * as rclone from "./encryptRClone";
|
||||
import { isVaildText } from "./misc";
|
||||
|
||||
import { FakeFs } from "./fsAll";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
|
||||
/**
|
||||
* quick guess, no actual decryption here
|
||||
* @param name
|
||||
* @returns
|
||||
*/
|
||||
function isLikelyOpenSSLEncryptedName(name: string): boolean {
|
||||
if (
|
||||
name.startsWith(openssl.MAGIC_ENCRYPTED_PREFIX_BASE32) ||
|
||||
name.startsWith(openssl.MAGIC_ENCRYPTED_PREFIX_BASE64URL)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* quick guess, no actual decryption here
|
||||
* @param name
|
||||
* @returns
|
||||
*/
|
||||
function isLikelyEncryptedName(name: string): boolean {
|
||||
return isLikelyOpenSSLEncryptedName(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* quick guess, no actual decryption here, only openssl can be guessed here
|
||||
* @param name
|
||||
* @returns
|
||||
*/
|
||||
function isLikelyEncryptedNameNotMatchMethod(
|
||||
name: string,
|
||||
method: CipherMethodType
|
||||
): boolean {
|
||||
if (isLikelyOpenSSLEncryptedName(name) && method !== "openssl-base64") {
|
||||
return true;
|
||||
}
|
||||
if (!isLikelyOpenSSLEncryptedName(name) && method === "openssl-base64") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface PasswordCheckType {
|
||||
ok: boolean;
|
||||
reason:
|
||||
| "empty_remote"
|
||||
| "unknown_encryption_method"
|
||||
| "remote_encrypted_local_no_password"
|
||||
| "password_matched"
|
||||
| "password_or_method_not_matched_or_remote_not_encrypted"
|
||||
| "likely_no_password_both_sides"
|
||||
| "encryption_method_not_matched";
|
||||
}
|
||||
|
||||
/**
|
||||
* Useful if isPasswordEmpty()
|
||||
*/
|
||||
function copyEntityAndCopyKeyEncSizeEnc(entity: Entity) {
|
||||
const res = cloneDeep(entity);
|
||||
res["keyEnc"] = res["keyRaw"];
|
||||
res["sizeEnc"] = res["sizeRaw"];
|
||||
return res;
|
||||
}
|
||||
|
||||
export class FakeFsEncrypt extends FakeFs {
|
||||
innerFs: FakeFs;
|
||||
readonly password: string;
|
||||
readonly method: CipherMethodType;
|
||||
cipherRClone?: rclone.CipherRclone;
|
||||
cacheMapOrigToEnc: Record<string, string>;
|
||||
hasCacheMap: boolean;
|
||||
kind: string;
|
||||
innerWalkResultCache?: Entity[];
|
||||
innerWalkResultCacheTime?: number;
|
||||
|
||||
constructor(innerFs: FakeFs, password: string, method: CipherMethodType) {
|
||||
super();
|
||||
this.innerFs = innerFs;
|
||||
this.password = password ?? "";
|
||||
this.method = method;
|
||||
this.cacheMapOrigToEnc = {};
|
||||
this.hasCacheMap = false;
|
||||
|
||||
this.kind = `encrypt(${this.innerFs.kind},${method})`;
|
||||
|
||||
if (method === "rclone-base64") {
|
||||
this.cipherRClone = new rclone.CipherRclone(password, 5);
|
||||
}
|
||||
}
|
||||
|
||||
isPasswordEmpty() {
|
||||
return this.password === "";
|
||||
}
|
||||
|
||||
isFolderAware() {
|
||||
if (this.method === "openssl-base64") {
|
||||
return false;
|
||||
}
|
||||
if (this.method === "rclone-base64") {
|
||||
return true;
|
||||
}
|
||||
throw Error(`no idea about isFolderAware for method=${this.method}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* we want a little caching here.
|
||||
*/
|
||||
async _getInnerWalkResult(): Promise<Entity[]> {
|
||||
let innerWalkResult: Entity[] | undefined = undefined;
|
||||
if (
|
||||
this.innerWalkResultCacheTime !== undefined &&
|
||||
this.innerWalkResultCacheTime >= Date.now() - 1000
|
||||
) {
|
||||
innerWalkResult = this.innerWalkResultCache!;
|
||||
} else {
|
||||
innerWalkResult = await this.innerFs.walk();
|
||||
this.innerWalkResultCache = innerWalkResult;
|
||||
this.innerWalkResultCacheTime = Date.now();
|
||||
}
|
||||
return innerWalkResult;
|
||||
}
|
||||
|
||||
async isPasswordOk(): Promise<PasswordCheckType> {
|
||||
const innerWalkResult = await this._getInnerWalkResult();
|
||||
|
||||
if (innerWalkResult === undefined || innerWalkResult.length === 0) {
|
||||
// remote empty
|
||||
return {
|
||||
ok: true,
|
||||
reason: "empty_remote",
|
||||
};
|
||||
}
|
||||
const santyCheckKey = innerWalkResult[0].keyRaw;
|
||||
|
||||
if (this.isPasswordEmpty()) {
|
||||
// TODO: no way to distinguish remote rclone encrypted
|
||||
// if local has no password??
|
||||
if (isLikelyEncryptedName(santyCheckKey)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "remote_encrypted_local_no_password",
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
ok: true,
|
||||
reason: "likely_no_password_both_sides",
|
||||
};
|
||||
}
|
||||
} else {
|
||||
if (this.method === "unknown") {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "unknown_encryption_method",
|
||||
};
|
||||
}
|
||||
if (isLikelyEncryptedNameNotMatchMethod(santyCheckKey, this.method)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "encryption_method_not_matched",
|
||||
};
|
||||
}
|
||||
try {
|
||||
const k = await this._decryptName(santyCheckKey);
|
||||
if (k === undefined) {
|
||||
throw Error(`decryption failed`);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
reason: "password_matched",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "password_or_method_not_matched_or_remote_not_encrypted",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async walk(): Promise<Entity[]> {
|
||||
const innerWalkResult = await this._getInnerWalkResult();
|
||||
|
||||
const res: Entity[] = [];
|
||||
|
||||
if (this.isPasswordEmpty()) {
|
||||
for (const innerEntity of innerWalkResult) {
|
||||
res.push(copyEntityAndCopyKeyEncSizeEnc(innerEntity));
|
||||
this.cacheMapOrigToEnc[innerEntity.key!] = innerEntity.key!;
|
||||
}
|
||||
this.hasCacheMap = true;
|
||||
return res;
|
||||
} else {
|
||||
for (const innerEntity of innerWalkResult) {
|
||||
const key = await this._decryptName(innerEntity.keyRaw);
|
||||
const size = key.endsWith("/") ? 0 : undefined;
|
||||
res.push({
|
||||
key: key,
|
||||
keyRaw: innerEntity.keyRaw,
|
||||
keyEnc: innerEntity.key!,
|
||||
mtimeCli: innerEntity.mtimeCli,
|
||||
mtimeSvr: innerEntity.mtimeSvr,
|
||||
size: size,
|
||||
sizeEnc: innerEntity.size!,
|
||||
sizeRaw: innerEntity.sizeRaw,
|
||||
hash: undefined,
|
||||
synthesizedFolder: innerEntity.synthesizedFolder,
|
||||
});
|
||||
|
||||
this.cacheMapOrigToEnc[key] = innerEntity.keyRaw;
|
||||
}
|
||||
this.hasCacheMap = true;
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
async stat(key: string): Promise<Entity> {
|
||||
if (!this.hasCacheMap) {
|
||||
throw new Error("You have to build the cacheMap firstly for stat");
|
||||
}
|
||||
const keyEnc = this.cacheMapOrigToEnc[key];
|
||||
if (keyEnc === undefined) {
|
||||
throw new Error(`no encrypted key ${key} before!`);
|
||||
}
|
||||
|
||||
const innerEntity = await this.innerFs.stat(keyEnc);
|
||||
if (this.isPasswordEmpty()) {
|
||||
return copyEntityAndCopyKeyEncSizeEnc(innerEntity);
|
||||
} else {
|
||||
return {
|
||||
key: key,
|
||||
keyRaw: innerEntity.keyRaw,
|
||||
keyEnc: innerEntity.key!,
|
||||
mtimeCli: innerEntity.mtimeCli,
|
||||
mtimeSvr: innerEntity.mtimeSvr,
|
||||
size: undefined,
|
||||
sizeEnc: innerEntity.size!,
|
||||
sizeRaw: innerEntity.sizeRaw,
|
||||
hash: undefined,
|
||||
synthesizedFolder: innerEntity.synthesizedFolder,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async mkdir(key: string, mtime?: number, ctime?: number): Promise<Entity> {
|
||||
if (!this.hasCacheMap) {
|
||||
throw new Error("You have to build the cacheMap firstly for mkdir");
|
||||
}
|
||||
|
||||
if (!key.endsWith("/")) {
|
||||
throw new Error(`should not call mkdir on ${key}`);
|
||||
}
|
||||
|
||||
let keyEnc = this.cacheMapOrigToEnc[key];
|
||||
if (keyEnc === undefined) {
|
||||
if (this.isPasswordEmpty()) {
|
||||
keyEnc = key;
|
||||
} else {
|
||||
keyEnc = await this._encryptName(key);
|
||||
}
|
||||
this.cacheMapOrigToEnc[key] = keyEnc;
|
||||
}
|
||||
|
||||
if (this.isPasswordEmpty() || this.isFolderAware()) {
|
||||
const innerEntity = await this.innerFs.mkdir(keyEnc, mtime, ctime);
|
||||
return copyEntityAndCopyKeyEncSizeEnc(innerEntity);
|
||||
} else {
|
||||
const now = Date.now();
|
||||
const innerEntity = await this.innerFs.writeFile(
|
||||
keyEnc,
|
||||
new ArrayBuffer(0),
|
||||
mtime ?? now,
|
||||
ctime ?? now
|
||||
);
|
||||
return {
|
||||
key: key,
|
||||
keyRaw: innerEntity.keyRaw,
|
||||
keyEnc: innerEntity.key!,
|
||||
mtimeCli: innerEntity.mtimeCli,
|
||||
mtimeSvr: innerEntity.mtimeSvr,
|
||||
size: 0,
|
||||
sizeEnc: innerEntity.size!,
|
||||
sizeRaw: innerEntity.sizeRaw,
|
||||
hash: undefined,
|
||||
synthesizedFolder: innerEntity.synthesizedFolder,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async writeFile(
|
||||
key: string,
|
||||
content: ArrayBuffer,
|
||||
mtime: number,
|
||||
ctime: number
|
||||
): Promise<Entity> {
|
||||
if (!this.hasCacheMap) {
|
||||
throw new Error("You have to build the cacheMap firstly for readFile");
|
||||
}
|
||||
let keyEnc = this.cacheMapOrigToEnc[key];
|
||||
if (keyEnc === undefined) {
|
||||
if (this.isPasswordEmpty()) {
|
||||
keyEnc = key;
|
||||
} else {
|
||||
keyEnc = await this._encryptName(key);
|
||||
}
|
||||
this.cacheMapOrigToEnc[key] = keyEnc;
|
||||
}
|
||||
|
||||
if (this.isPasswordEmpty()) {
|
||||
const innerEntity = await this.innerFs.writeFile(
|
||||
keyEnc,
|
||||
content,
|
||||
mtime,
|
||||
ctime
|
||||
);
|
||||
return copyEntityAndCopyKeyEncSizeEnc(innerEntity);
|
||||
} else {
|
||||
const contentEnc = await this._encryptContent(content);
|
||||
const innerEntity = await this.innerFs.writeFile(
|
||||
keyEnc,
|
||||
contentEnc,
|
||||
mtime,
|
||||
ctime
|
||||
);
|
||||
return {
|
||||
key: key,
|
||||
keyRaw: innerEntity.keyRaw,
|
||||
keyEnc: innerEntity.key!,
|
||||
mtimeCli: innerEntity.mtimeCli,
|
||||
mtimeSvr: innerEntity.mtimeSvr,
|
||||
size: undefined,
|
||||
sizeEnc: innerEntity.size!,
|
||||
sizeRaw: innerEntity.sizeRaw,
|
||||
hash: undefined,
|
||||
synthesizedFolder: innerEntity.synthesizedFolder,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async readFile(key: string): Promise<ArrayBuffer> {
|
||||
if (!this.hasCacheMap) {
|
||||
throw new Error("You have to build the cacheMap firstly for readFile");
|
||||
}
|
||||
const keyEnc = this.cacheMapOrigToEnc[key];
|
||||
if (keyEnc === undefined) {
|
||||
throw new Error(`no encrypted key ${key} before! cannot readFile`);
|
||||
}
|
||||
|
||||
const contentEnc = await this.innerFs.readFile(keyEnc);
|
||||
if (this.isPasswordEmpty()) {
|
||||
return contentEnc;
|
||||
} else {
|
||||
const res = await this._decryptContent(contentEnc);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
async rm(key: string): Promise<void> {
|
||||
if (!this.hasCacheMap) {
|
||||
throw new Error("You have to build the cacheMap firstly for rm");
|
||||
}
|
||||
const keyEnc = this.cacheMapOrigToEnc[key];
|
||||
if (keyEnc === undefined) {
|
||||
throw new Error(`no encrypted key ${key} before! cannot rm`);
|
||||
}
|
||||
return await this.innerFs.rm(keyEnc);
|
||||
}
|
||||
|
||||
async checkConnect(callbackFunc?: any): Promise<boolean> {
|
||||
return await this.innerFs.checkConnect(callbackFunc);
|
||||
}
|
||||
|
||||
async closeResources() {
|
||||
if (this.method === "rclone-base64" && this.cipherRClone !== undefined) {
|
||||
this.cipherRClone.closeResources();
|
||||
}
|
||||
}
|
||||
|
||||
async encryptEntity(input: Entity): Promise<Entity> {
|
||||
if (input.key === undefined) {
|
||||
// input.key should always have value
|
||||
throw Error(`input ${input.keyRaw} is abnormal without key`);
|
||||
}
|
||||
|
||||
if (this.isPasswordEmpty()) {
|
||||
return copyEntityAndCopyKeyEncSizeEnc(input);
|
||||
}
|
||||
|
||||
// below is for having password
|
||||
const local = cloneDeep(input);
|
||||
if (local.sizeEnc === undefined && local.size !== undefined) {
|
||||
// it's not filled yet, we fill it
|
||||
// local.size is possibly undefined if it's "prevSync" Entity
|
||||
// but local.key should always have value
|
||||
local.sizeEnc = this._getSizeFromOrigToEnc(local.size);
|
||||
}
|
||||
|
||||
if (local.keyEnc === undefined || local.keyEnc === "") {
|
||||
let keyEnc = this.cacheMapOrigToEnc[input.key];
|
||||
if (keyEnc !== undefined && keyEnc !== "" && keyEnc !== local.key) {
|
||||
// we can reuse remote encrypted key if any
|
||||
local.keyEnc = keyEnc;
|
||||
} else {
|
||||
// we assign a new encrypted key because of no remote
|
||||
keyEnc = await this._encryptName(input.key);
|
||||
local.keyEnc = keyEnc;
|
||||
// remember to add back to cache!
|
||||
this.cacheMapOrigToEnc[input.key] = keyEnc;
|
||||
}
|
||||
}
|
||||
return local;
|
||||
}
|
||||
|
||||
async _encryptContent(content: ArrayBuffer) {
|
||||
// console.debug("start encryptContent");
|
||||
if (this.password === "") {
|
||||
return content;
|
||||
}
|
||||
if (this.method === "openssl-base64") {
|
||||
const res = await openssl.encryptArrayBuffer(content, this.password);
|
||||
if (res === undefined) {
|
||||
throw Error(`cannot encrypt content`);
|
||||
}
|
||||
return res;
|
||||
} else if (this.method === "rclone-base64") {
|
||||
const res =
|
||||
await this.cipherRClone!.encryptContentByCallingWorker(content);
|
||||
if (res === undefined) {
|
||||
throw Error(`cannot encrypt content`);
|
||||
}
|
||||
return res;
|
||||
} else {
|
||||
throw Error(`not supported encrypt method=${this.method}`);
|
||||
}
|
||||
}
|
||||
|
||||
async _decryptContent(content: ArrayBuffer) {
|
||||
// console.debug("start decryptContent");
|
||||
if (this.password === "") {
|
||||
return content;
|
||||
}
|
||||
if (this.method === "openssl-base64") {
|
||||
const res = await openssl.decryptArrayBuffer(content, this.password);
|
||||
if (res === undefined) {
|
||||
throw Error(`cannot decrypt content`);
|
||||
}
|
||||
return res;
|
||||
} else if (this.method === "rclone-base64") {
|
||||
const res =
|
||||
await this.cipherRClone!.decryptContentByCallingWorker(content);
|
||||
if (res === undefined) {
|
||||
throw Error(`cannot decrypt content`);
|
||||
}
|
||||
return res;
|
||||
} else {
|
||||
throw Error(`not supported decrypt method=${this.method}`);
|
||||
}
|
||||
}
|
||||
|
||||
async _encryptName(name: string) {
|
||||
// console.debug("start encryptName");
|
||||
if (this.password === "") {
|
||||
return name;
|
||||
}
|
||||
if (this.method === "openssl-base64") {
|
||||
const res = await openssl.encryptStringToBase64url(name, this.password);
|
||||
if (res === undefined) {
|
||||
throw Error(`cannot encrypt name=${name}`);
|
||||
}
|
||||
return res;
|
||||
} else if (this.method === "rclone-base64") {
|
||||
const res = await this.cipherRClone!.encryptNameByCallingWorker(name);
|
||||
if (res === undefined) {
|
||||
throw Error(`cannot encrypt name=${name}`);
|
||||
}
|
||||
return res;
|
||||
} else {
|
||||
throw Error(`not supported encrypt method=${this.method}`);
|
||||
}
|
||||
}
|
||||
|
||||
async _decryptName(name: string): Promise<string> {
|
||||
// console.debug("start decryptName");
|
||||
if (this.password === "") {
|
||||
return name;
|
||||
}
|
||||
if (this.method === "openssl-base64") {
|
||||
if (name.startsWith(openssl.MAGIC_ENCRYPTED_PREFIX_BASE32)) {
|
||||
// backward compitable with the openssl-base32
|
||||
try {
|
||||
const res = await openssl.decryptBase32ToString(name, this.password);
|
||||
if (res !== undefined && isVaildText(res)) {
|
||||
return res;
|
||||
} else {
|
||||
throw Error(`cannot decrypt name=${name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
throw Error(`cannot decrypt name=${name}`);
|
||||
}
|
||||
} else if (name.startsWith(openssl.MAGIC_ENCRYPTED_PREFIX_BASE64URL)) {
|
||||
try {
|
||||
const res = await openssl.decryptBase64urlToString(
|
||||
name,
|
||||
this.password
|
||||
);
|
||||
if (res !== undefined && isVaildText(res)) {
|
||||
return res;
|
||||
} else {
|
||||
throw Error(`cannot decrypt name=${name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
throw Error(`cannot decrypt name=${name}`);
|
||||
}
|
||||
} else {
|
||||
throw Error(
|
||||
`method=${this.method} but the name=${name}, likely mismatch`
|
||||
);
|
||||
}
|
||||
} else if (this.method === "rclone-base64") {
|
||||
const res = await this.cipherRClone!.decryptNameByCallingWorker(name);
|
||||
if (res === undefined) {
|
||||
throw Error(`cannot decrypt name=${name}`);
|
||||
}
|
||||
return res;
|
||||
} else {
|
||||
throw Error(`not supported decrypt method=${this.method}`);
|
||||
}
|
||||
}
|
||||
|
||||
_getSizeFromOrigToEnc(x: number) {
|
||||
if (this.password === "") {
|
||||
return x;
|
||||
}
|
||||
if (this.method === "openssl-base64") {
|
||||
return openssl.getSizeFromOrigToEnc(x);
|
||||
} else if (this.method === "rclone-base64") {
|
||||
return rclone.getSizeFromOrigToEnc(x);
|
||||
} else {
|
||||
throw Error(`not supported encrypt method=${this.method}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getUserDisplayName(): Promise<string> {
|
||||
return await this.innerFs.getUserDisplayName();
|
||||
}
|
||||
|
||||
async revokeAuth(): Promise<any> {
|
||||
return await this.innerFs.revokeAuth();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { RemotelySavePluginSettings } from "./baseTypes";
|
||||
import { FakeFs } from "./fsAll";
|
||||
import { FakeFsDropbox } from "./fsDropbox";
|
||||
import { FakeFsOnedrive } from "./fsOnedrive";
|
||||
import { FakeFsS3 } from "./fsS3";
|
||||
import { FakeFsWebdav } from "./fsWebdav";
|
||||
import { FakeFsWebdis } from "./fsWebdis";
|
||||
|
||||
/**
|
||||
* To avoid circular dependency, we need a new file here.
|
||||
*/
|
||||
export function getClient(
|
||||
settings: RemotelySavePluginSettings,
|
||||
vaultName: string,
|
||||
saveUpdatedConfigFunc: () => Promise<any>
|
||||
): FakeFs {
|
||||
switch (settings.serviceType) {
|
||||
case "s3":
|
||||
return new FakeFsS3(settings.s3);
|
||||
break;
|
||||
case "webdav":
|
||||
return new FakeFsWebdav(
|
||||
settings.webdav,
|
||||
vaultName,
|
||||
saveUpdatedConfigFunc
|
||||
);
|
||||
break;
|
||||
case "dropbox":
|
||||
return new FakeFsDropbox(
|
||||
settings.dropbox,
|
||||
vaultName,
|
||||
saveUpdatedConfigFunc
|
||||
);
|
||||
break;
|
||||
case "onedrive":
|
||||
return new FakeFsOnedrive(
|
||||
settings.onedrive,
|
||||
vaultName,
|
||||
saveUpdatedConfigFunc
|
||||
);
|
||||
break;
|
||||
case "webdis":
|
||||
return new FakeFsWebdis(
|
||||
settings.webdis,
|
||||
vaultName,
|
||||
saveUpdatedConfigFunc
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw Error(`cannot init client for serviceType=${settings.serviceType}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
import { DEFAULT_DEBUG_FOLDER, Entity } from "./baseTypes";
|
||||
import { FakeFs } from "./fsAll";
|
||||
|
||||
import { TFile, TFolder, type Vault } from "obsidian";
|
||||
import { listFilesInObsFolder } from "./obsFolderLister";
|
||||
import { Profiler } from "./profiler";
|
||||
import { getFolderLevels, mkdirpInVault, statFix, unixTimeToStr } from "./misc";
|
||||
|
||||
export class FakeFsLocal extends FakeFs {
|
||||
vault: Vault;
|
||||
syncConfigDir: boolean;
|
||||
configDir: string;
|
||||
pluginID: string;
|
||||
profiler: Profiler | undefined;
|
||||
deleteToWhere: "obsidian" | "system";
|
||||
kind: "local";
|
||||
constructor(
|
||||
vault: Vault,
|
||||
syncConfigDir: boolean,
|
||||
configDir: string,
|
||||
pluginID: string,
|
||||
profiler: Profiler | undefined,
|
||||
deleteToWhere: "obsidian" | "system"
|
||||
) {
|
||||
super();
|
||||
|
||||
this.vault = vault;
|
||||
this.syncConfigDir = syncConfigDir;
|
||||
this.configDir = configDir;
|
||||
this.pluginID = pluginID;
|
||||
this.profiler = profiler;
|
||||
this.deleteToWhere = deleteToWhere;
|
||||
this.kind = "local";
|
||||
}
|
||||
|
||||
async walk(): Promise<Entity[]> {
|
||||
this.profiler?.addIndent();
|
||||
this.profiler?.insert("enter walk for local");
|
||||
const local: Entity[] = [];
|
||||
|
||||
const localTAbstractFiles = this.vault.getAllLoadedFiles();
|
||||
this.profiler?.insert("finish getting walk for local");
|
||||
for (const entry of localTAbstractFiles) {
|
||||
let r: Entity | undefined = undefined;
|
||||
let key = entry.path;
|
||||
|
||||
if (entry.path === "/") {
|
||||
// ignore
|
||||
continue;
|
||||
} else if (entry instanceof TFile) {
|
||||
let mtimeLocal: number | undefined = entry.stat.mtime;
|
||||
if (mtimeLocal <= 0) {
|
||||
mtimeLocal = entry.stat.ctime;
|
||||
}
|
||||
if (mtimeLocal === 0) {
|
||||
mtimeLocal = undefined;
|
||||
}
|
||||
if (mtimeLocal === undefined) {
|
||||
throw Error(
|
||||
`Your file has last modified time 0: ${key}, don't know how to deal with it`
|
||||
);
|
||||
}
|
||||
r = {
|
||||
key: entry.path, // local always unencrypted
|
||||
keyRaw: entry.path,
|
||||
mtimeCli: mtimeLocal,
|
||||
mtimeSvr: mtimeLocal,
|
||||
size: entry.stat.size, // local always unencrypted
|
||||
sizeRaw: entry.stat.size,
|
||||
};
|
||||
} else if (entry instanceof TFolder) {
|
||||
key = `${entry.path}/`;
|
||||
r = {
|
||||
key: key,
|
||||
keyRaw: key,
|
||||
size: 0,
|
||||
sizeRaw: 0,
|
||||
};
|
||||
} else {
|
||||
throw Error(`unexpected ${entry}`);
|
||||
}
|
||||
|
||||
if (r.keyRaw.startsWith(DEFAULT_DEBUG_FOLDER)) {
|
||||
// skip listing the debug folder,
|
||||
// which should always not involved in sync
|
||||
continue;
|
||||
} else {
|
||||
local.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
this.profiler?.insert("finish transforming walk for local");
|
||||
|
||||
if (this.syncConfigDir) {
|
||||
this.profiler?.insert("into syncConfigDir");
|
||||
const syncFiles = await listFilesInObsFolder(
|
||||
this.configDir,
|
||||
this.vault,
|
||||
this.pluginID
|
||||
);
|
||||
for (const f of syncFiles) {
|
||||
local.push(f);
|
||||
}
|
||||
this.profiler?.insert("finish syncConfigDir");
|
||||
}
|
||||
|
||||
this.profiler?.insert("finish walk for local");
|
||||
this.profiler?.removeIndent();
|
||||
return local;
|
||||
}
|
||||
|
||||
async stat(key: string): Promise<Entity> {
|
||||
const statRes = await statFix(this.vault, key);
|
||||
if (statRes === undefined || statRes === null) {
|
||||
throw Error(`${key} does not exist! cannot stat for local`);
|
||||
}
|
||||
const isFolder = statRes.type === "folder";
|
||||
return {
|
||||
key: isFolder ? `${key}/` : key, // local always unencrypted
|
||||
keyRaw: isFolder ? `${key}/` : key,
|
||||
mtimeCli: statRes.mtime,
|
||||
mtimeSvr: statRes.mtime,
|
||||
mtimeCliFmt: unixTimeToStr(statRes.mtime),
|
||||
mtimeSvrFmt: unixTimeToStr(statRes.mtime),
|
||||
size: statRes.size, // local always unencrypted
|
||||
sizeRaw: statRes.size,
|
||||
};
|
||||
}
|
||||
|
||||
async mkdir(key: string, mtime?: number, ctime?: number): Promise<Entity> {
|
||||
// console.debug(`mkdir: ${key}`);
|
||||
await mkdirpInVault(key, this.vault);
|
||||
return await this.stat(key);
|
||||
}
|
||||
|
||||
async writeFile(
|
||||
key: string,
|
||||
content: ArrayBuffer,
|
||||
mtime: number,
|
||||
ctime: number
|
||||
): Promise<Entity> {
|
||||
await this.vault.adapter.writeBinary(key, content, {
|
||||
mtime: mtime,
|
||||
});
|
||||
return await this.stat(key);
|
||||
}
|
||||
|
||||
async readFile(key: string): Promise<ArrayBuffer> {
|
||||
return await this.vault.adapter.readBinary(key);
|
||||
}
|
||||
|
||||
async rm(key: string): Promise<void> {
|
||||
if (this.deleteToWhere === "obsidian") {
|
||||
await this.vault.adapter.trashLocal(key);
|
||||
} else {
|
||||
// "system"
|
||||
if (!(await this.vault.adapter.trashSystem(key))) {
|
||||
await this.vault.adapter.trashLocal(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
async checkConnect(callbackFunc?: any): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async getUserDisplayName(): Promise<string> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async revokeAuth(): Promise<any> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Entity } from "./baseTypes";
|
||||
import { FakeFs } from "./fsAll";
|
||||
|
||||
export class FakeFsMock extends FakeFs {
|
||||
kind: "mock";
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.kind = "mock";
|
||||
}
|
||||
|
||||
async walk(): Promise<Entity[]> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async stat(key: string): Promise<Entity> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async mkdir(key: string, mtime: number, ctime: number): Promise<Entity> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async writeFile(
|
||||
key: string,
|
||||
content: ArrayBuffer,
|
||||
mtime: number,
|
||||
ctime: number
|
||||
): Promise<Entity> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async readFile(key: string): Promise<ArrayBuffer> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async rm(key: string): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async checkConnect(callbackFunc?: any): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async getUserDisplayName(): Promise<string> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async revokeAuth(): Promise<any> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,23 @@
|
||||
import { CryptoProvider, PublicClientApplication } from "@azure/msal-node";
|
||||
import { AuthenticationProvider } from "@microsoft/microsoft-graph-client";
|
||||
import type {
|
||||
DriveItem,
|
||||
FileSystemInfo,
|
||||
UploadSession,
|
||||
User,
|
||||
} from "@microsoft/microsoft-graph-types";
|
||||
import { CryptoProvider, PublicClientApplication } from "@azure/msal-node";
|
||||
import { AuthenticationProvider } from "@microsoft/microsoft-graph-client";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import { request, requestUrl, requireApiVersion, Vault } from "obsidian";
|
||||
import { request, requestUrl } from "obsidian";
|
||||
import {
|
||||
VALID_REQURL,
|
||||
COMMAND_CALLBACK_ONEDRIVE,
|
||||
DEFAULT_CONTENT_TYPE,
|
||||
Entity,
|
||||
OAUTH2_FORCE_EXPIRE_MILLISECONDS,
|
||||
OnedriveConfig,
|
||||
Entity,
|
||||
UploadedType,
|
||||
} from "./baseTypes";
|
||||
import {
|
||||
bufferToArrayBuffer,
|
||||
getRandomArrayBuffer,
|
||||
getRandomIntInclusive,
|
||||
mkdirpInVault,
|
||||
} from "./misc";
|
||||
import { Cipher } from "./encryptUnified";
|
||||
import { VALID_REQURL } from "./baseTypesObs";
|
||||
import { FakeFs } from "./fsAll";
|
||||
import { bufferToArrayBuffer } from "./misc";
|
||||
|
||||
const SCOPES = ["User.Read", "Files.ReadWrite.AppFolder", "offline_access"];
|
||||
const REDIRECT_URI = `obsidian://${COMMAND_CALLBACK_ONEDRIVE}`;
|
||||
@@ -237,23 +231,6 @@ const getOnedrivePath = (fileOrFolderPath: string, remoteBaseDir: string) => {
|
||||
return key;
|
||||
};
|
||||
|
||||
const getNormPath = (fileOrFolderPath: string, remoteBaseDir: string) => {
|
||||
const prefix = `/drive/special/approot:/${remoteBaseDir}`;
|
||||
|
||||
if (
|
||||
!(fileOrFolderPath === prefix || fileOrFolderPath.startsWith(`${prefix}/`))
|
||||
) {
|
||||
throw Error(
|
||||
`"${fileOrFolderPath}" doesn't starts with "${prefix}/" or equals to "${prefix}"`
|
||||
);
|
||||
}
|
||||
|
||||
if (fileOrFolderPath === prefix) {
|
||||
return "/";
|
||||
}
|
||||
return fileOrFolderPath.slice(`${prefix}/`.length);
|
||||
};
|
||||
|
||||
const constructFromDriveItemToEntityError = (x: DriveItem) => {
|
||||
return `parentPath="${
|
||||
x.parentReference?.path ?? "(no parentReference or path)"
|
||||
@@ -267,6 +244,10 @@ const fromDriveItemToEntity = (x: DriveItem, remoteBaseDir: string): Entity => {
|
||||
// pure english: /drive/root:/Apps/remotely-save/${remoteBaseDir}
|
||||
// or localized, e.g.: /drive/root:/应用/remotely-save/${remoteBaseDir}
|
||||
const FIRST_COMMON_PREFIX_REGEX = /^\/drive\/root:\/[^\/]+\/remotely-save\//g;
|
||||
|
||||
// why?? /drive/root:/Apps/Graph
|
||||
const FIFTH_COMMON_PREFIX_REGEX = /^\/drive\/root:\/[^\/]+\/Graph\//g;
|
||||
|
||||
// or the root is absolute path /Livefolders,
|
||||
// e.g.: /Livefolders/应用/remotely-save/${remoteBaseDir}
|
||||
const SECOND_COMMON_PREFIX_REGEX = /^\/Livefolders\/[^\/]+\/remotely-save\//g;
|
||||
@@ -289,6 +270,7 @@ const fromDriveItemToEntity = (x: DriveItem, remoteBaseDir: string): Entity => {
|
||||
}
|
||||
const fullPathOriginal = `${x.parentReference.path}/${x.name}`;
|
||||
const matchFirstPrefixRes = fullPathOriginal.match(FIRST_COMMON_PREFIX_REGEX);
|
||||
const matchFifthPrefixRes = fullPathOriginal.match(FIFTH_COMMON_PREFIX_REGEX);
|
||||
const matchSecondPrefixRes = fullPathOriginal.match(
|
||||
SECOND_COMMON_PREFIX_REGEX
|
||||
);
|
||||
@@ -299,6 +281,12 @@ const fromDriveItemToEntity = (x: DriveItem, remoteBaseDir: string): Entity => {
|
||||
) {
|
||||
const foundPrefix = `${matchFirstPrefixRes[0]}${remoteBaseDir}`;
|
||||
key = fullPathOriginal.substring(foundPrefix.length + 1);
|
||||
} else if (
|
||||
matchFifthPrefixRes !== null &&
|
||||
fullPathOriginal.startsWith(`${matchFifthPrefixRes[0]}${remoteBaseDir}`)
|
||||
) {
|
||||
const foundPrefix = `${matchFifthPrefixRes[0]}${remoteBaseDir}`;
|
||||
key = fullPathOriginal.substring(foundPrefix.length + 1);
|
||||
} else if (
|
||||
matchSecondPrefixRes !== null &&
|
||||
fullPathOriginal.startsWith(`${matchSecondPrefixRes[0]}${remoteBaseDir}`)
|
||||
@@ -350,15 +338,20 @@ const fromDriveItemToEntity = (x: DriveItem, remoteBaseDir: string): Entity => {
|
||||
const mtimeSvr = Date.parse(x?.fileSystemInfo!.lastModifiedDateTime!);
|
||||
const mtimeCli = Date.parse(x?.fileSystemInfo!.lastModifiedDateTime!);
|
||||
return {
|
||||
key: key,
|
||||
keyRaw: key,
|
||||
mtimeSvr: mtimeSvr,
|
||||
mtimeCli: mtimeCli,
|
||||
size: isFolder ? 0 : x.size!,
|
||||
sizeRaw: isFolder ? 0 : x.size!,
|
||||
// hash: ?? // TODO
|
||||
etag: x.cTag || "", // do NOT use x.eTag because it changes if meta changes
|
||||
};
|
||||
};
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// The client.
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// to adapt to the required interface
|
||||
class MyAuthProvider implements AuthenticationProvider {
|
||||
onedriveConfig: OnedriveConfig;
|
||||
@@ -370,7 +363,8 @@ class MyAuthProvider implements AuthenticationProvider {
|
||||
this.onedriveConfig = onedriveConfig;
|
||||
this.saveUpdatedConfigFunc = saveUpdatedConfigFunc;
|
||||
}
|
||||
getAccessToken = async () => {
|
||||
|
||||
async getAccessToken() {
|
||||
if (
|
||||
this.onedriveConfig.accessToken === "" ||
|
||||
this.onedriveConfig.refreshToken === ""
|
||||
@@ -404,28 +398,47 @@ class MyAuthProvider implements AuthenticationProvider {
|
||||
console.info("Onedrive accessToken updated");
|
||||
return this.onedriveConfig.accessToken;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class WrappedOnedriveClient {
|
||||
/**
|
||||
* to export the settings in qrcode,
|
||||
* we want to "trim" or "shrink" the settings
|
||||
* @param onedriveConfig
|
||||
*/
|
||||
export const getShrinkedSettings = (onedriveConfig: OnedriveConfig) => {
|
||||
const config = cloneDeep(onedriveConfig);
|
||||
config.accessToken = "x";
|
||||
config.accessTokenExpiresInSeconds = 1;
|
||||
config.accessTokenExpiresAtTime = 1;
|
||||
return config;
|
||||
};
|
||||
|
||||
export class FakeFsOnedrive extends FakeFs {
|
||||
kind: "onedrive";
|
||||
onedriveConfig: OnedriveConfig;
|
||||
remoteBaseDir: string;
|
||||
vaultFolderExists: boolean;
|
||||
authGetter: MyAuthProvider;
|
||||
saveUpdatedConfigFunc: () => Promise<any>;
|
||||
foldersCreatedBefore: Set<string>;
|
||||
|
||||
constructor(
|
||||
onedriveConfig: OnedriveConfig,
|
||||
remoteBaseDir: string,
|
||||
vaultName: string,
|
||||
saveUpdatedConfigFunc: () => Promise<any>
|
||||
) {
|
||||
super();
|
||||
this.kind = "onedrive";
|
||||
this.onedriveConfig = onedriveConfig;
|
||||
this.remoteBaseDir = remoteBaseDir;
|
||||
this.remoteBaseDir = this.onedriveConfig.remoteBaseDir || vaultName || "";
|
||||
this.vaultFolderExists = false;
|
||||
this.saveUpdatedConfigFunc = saveUpdatedConfigFunc;
|
||||
this.authGetter = new MyAuthProvider(onedriveConfig, saveUpdatedConfigFunc);
|
||||
this.foldersCreatedBefore = new Set();
|
||||
}
|
||||
|
||||
init = async () => {
|
||||
async _init() {
|
||||
// check token
|
||||
if (
|
||||
this.onedriveConfig.accessToken === "" ||
|
||||
@@ -439,14 +452,14 @@ export class WrappedOnedriveClient {
|
||||
if (this.vaultFolderExists) {
|
||||
// console.info(`already checked, /${this.remoteBaseDir} exist before`)
|
||||
} else {
|
||||
const k = await this.getJson("/drive/special/approot/children");
|
||||
const k = await this._getJson("/drive/special/approot/children");
|
||||
// console.debug(k);
|
||||
this.vaultFolderExists =
|
||||
(k.value as DriveItem[]).filter((x) => x.name === this.remoteBaseDir)
|
||||
.length > 0;
|
||||
if (!this.vaultFolderExists) {
|
||||
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}`,
|
||||
folder: {},
|
||||
"@microsoft.graph.conflictBehavior": "replace",
|
||||
@@ -457,9 +470,9 @@ export class WrappedOnedriveClient {
|
||||
// console.info(`remote folder /${this.remoteBaseDir} exists`);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
buildUrl = (pathFragOrig: string) => {
|
||||
_buildUrl(pathFragOrig: string) {
|
||||
const API_PREFIX = "https://graph.microsoft.com/v1.0";
|
||||
let theUrl = "";
|
||||
if (
|
||||
@@ -471,11 +484,16 @@ export class WrappedOnedriveClient {
|
||||
const pathFrag = encodeURI(pathFragOrig);
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
getJson = async (pathFragOrig: string) => {
|
||||
const theUrl = this.buildUrl(pathFragOrig);
|
||||
async _getJson(pathFragOrig: string) {
|
||||
const theUrl = this._buildUrl(pathFragOrig);
|
||||
console.debug(`getJson, theUrl=${theUrl}`);
|
||||
return JSON.parse(
|
||||
await request({
|
||||
@@ -488,10 +506,10 @@ export class WrappedOnedriveClient {
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
postJson = async (pathFragOrig: string, payload: any) => {
|
||||
const theUrl = this.buildUrl(pathFragOrig);
|
||||
async _postJson(pathFragOrig: string, payload: any) {
|
||||
const theUrl = this._buildUrl(pathFragOrig);
|
||||
console.debug(`postJson, theUrl=${theUrl}`);
|
||||
return JSON.parse(
|
||||
await request({
|
||||
@@ -504,10 +522,10 @@ export class WrappedOnedriveClient {
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
patchJson = async (pathFragOrig: string, payload: any) => {
|
||||
const theUrl = this.buildUrl(pathFragOrig);
|
||||
async _patchJson(pathFragOrig: string, payload: any) {
|
||||
const theUrl = this._buildUrl(pathFragOrig);
|
||||
console.debug(`patchJson, theUrl=${theUrl}`);
|
||||
return JSON.parse(
|
||||
await request({
|
||||
@@ -520,10 +538,10 @@ export class WrappedOnedriveClient {
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
deleteJson = async (pathFragOrig: string) => {
|
||||
const theUrl = this.buildUrl(pathFragOrig);
|
||||
async _deleteJson(pathFragOrig: string) {
|
||||
const theUrl = this._buildUrl(pathFragOrig);
|
||||
console.debug(`deleteJson, theUrl=${theUrl}`);
|
||||
if (VALID_REQURL) {
|
||||
await requestUrl({
|
||||
@@ -541,10 +559,10 @@ export class WrappedOnedriveClient {
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
putArrayBuffer = async (pathFragOrig: string, payload: ArrayBuffer) => {
|
||||
const theUrl = this.buildUrl(pathFragOrig);
|
||||
async _putArrayBuffer(pathFragOrig: string, payload: ArrayBuffer) {
|
||||
const theUrl = this._buildUrl(pathFragOrig);
|
||||
console.debug(`putArrayBuffer, theUrl=${theUrl}`);
|
||||
// TODO:
|
||||
// 20220401: On Android, requestUrl has issue that text becomes base64.
|
||||
@@ -572,7 +590,7 @@ export class WrappedOnedriveClient {
|
||||
});
|
||||
return (await res.json()) as DriveItem | UploadSession;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A specialized function to upload large files by parts
|
||||
@@ -582,14 +600,14 @@ export class WrappedOnedriveClient {
|
||||
* @param rangeEnd the end, exclusive
|
||||
* @param size
|
||||
*/
|
||||
putUint8ArrayByRange = async (
|
||||
async _putUint8ArrayByRange(
|
||||
pathFragOrig: string,
|
||||
payload: Uint8Array,
|
||||
rangeStart: number,
|
||||
rangeEnd: number,
|
||||
size: number
|
||||
) => {
|
||||
const theUrl = this.buildUrl(pathFragOrig);
|
||||
) {
|
||||
const theUrl = this._buildUrl(pathFragOrig);
|
||||
console.debug(
|
||||
`putUint8ArrayByRange, theUrl=${theUrl}, range=${rangeStart}-${
|
||||
rangeEnd - 1
|
||||
@@ -625,201 +643,140 @@ export class WrappedOnedriveClient {
|
||||
});
|
||||
return (await res.json()) as DriveItem | UploadSession;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const getOnedriveClient = (
|
||||
onedriveConfig: OnedriveConfig,
|
||||
remoteBaseDir: string,
|
||||
saveUpdatedConfigFunc: () => Promise<any>
|
||||
) => {
|
||||
return new WrappedOnedriveClient(
|
||||
onedriveConfig,
|
||||
remoteBaseDir,
|
||||
saveUpdatedConfigFunc
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Use delta api to list all files and folders
|
||||
* https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_delta?view=odsp-graph-online
|
||||
* @param client
|
||||
*/
|
||||
export const listAllFromRemote = async (client: WrappedOnedriveClient) => {
|
||||
await client.init();
|
||||
|
||||
const NEXT_LINK_KEY = "@odata.nextLink";
|
||||
const DELTA_LINK_KEY = "@odata.deltaLink";
|
||||
|
||||
let res = await client.getJson(
|
||||
`/drive/special/approot:/${client.remoteBaseDir}:/delta`
|
||||
);
|
||||
let driveItems = res.value as DriveItem[];
|
||||
// console.debug(driveItems);
|
||||
|
||||
while (NEXT_LINK_KEY in res) {
|
||||
res = await client.getJson(res[NEXT_LINK_KEY]);
|
||||
driveItems.push(...cloneDeep(res.value as DriveItem[]));
|
||||
}
|
||||
|
||||
// lastly we should have delta link?
|
||||
if (DELTA_LINK_KEY in res) {
|
||||
client.onedriveConfig.deltaLink = res[DELTA_LINK_KEY];
|
||||
await client.saveUpdatedConfigFunc();
|
||||
/**
|
||||
* Use delta api to list all files and folders
|
||||
* https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_delta?view=odsp-graph-online
|
||||
*/
|
||||
async walk(): Promise<Entity[]> {
|
||||
await this._init();
|
||||
|
||||
const NEXT_LINK_KEY = "@odata.nextLink";
|
||||
const DELTA_LINK_KEY = "@odata.deltaLink";
|
||||
|
||||
let res = await this._getJson(
|
||||
`/drive/special/approot:/${this.remoteBaseDir}:/delta`
|
||||
);
|
||||
let driveItems = res.value as DriveItem[];
|
||||
// console.debug(driveItems);
|
||||
|
||||
while (NEXT_LINK_KEY in res) {
|
||||
res = await this._getJson(res[NEXT_LINK_KEY]);
|
||||
driveItems.push(...cloneDeep(res.value as DriveItem[]));
|
||||
}
|
||||
|
||||
// lastly we should have delta link?
|
||||
if (DELTA_LINK_KEY in res) {
|
||||
this.onedriveConfig.deltaLink = res[DELTA_LINK_KEY];
|
||||
await this.saveUpdatedConfigFunc();
|
||||
}
|
||||
|
||||
// unify everything to Entity
|
||||
const unifiedContents = driveItems
|
||||
.map((x) => fromDriveItemToEntity(x, this.remoteBaseDir))
|
||||
.filter((x) => x.key !== "/");
|
||||
|
||||
return unifiedContents;
|
||||
}
|
||||
|
||||
// unify everything to Entity
|
||||
const unifiedContents = driveItems
|
||||
.map((x) => fromDriveItemToEntity(x, client.remoteBaseDir))
|
||||
.filter((x) => x.keyRaw !== "/");
|
||||
async stat(key: string): Promise<Entity> {
|
||||
await this._init();
|
||||
return await this._statFromRoot(getOnedrivePath(key, this.remoteBaseDir));
|
||||
}
|
||||
|
||||
return unifiedContents;
|
||||
};
|
||||
async _statFromRoot(key: string): Promise<Entity> {
|
||||
// console.info(`remotePath=${remotePath}`);
|
||||
const rsp = await this._getJson(
|
||||
`${key}?$select=cTag,eTag,fileSystemInfo,folder,file,name,parentReference,size`
|
||||
);
|
||||
// console.info(rsp);
|
||||
const driveItem = rsp as DriveItem;
|
||||
const res = fromDriveItemToEntity(driveItem, this.remoteBaseDir);
|
||||
// console.info(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export const getRemoteMeta = async (
|
||||
client: WrappedOnedriveClient,
|
||||
remotePath: string
|
||||
) => {
|
||||
await client.init();
|
||||
// console.info(`remotePath=${remotePath}`);
|
||||
const rsp = await client.getJson(
|
||||
`${remotePath}?$select=cTag,eTag,fileSystemInfo,folder,file,name,parentReference,size`
|
||||
);
|
||||
// console.info(rsp);
|
||||
const driveItem = rsp as DriveItem;
|
||||
const res = fromDriveItemToEntity(driveItem, client.remoteBaseDir);
|
||||
// console.info(res);
|
||||
return res;
|
||||
};
|
||||
async mkdir(key: string, mtime?: number, ctime?: number): Promise<Entity> {
|
||||
if (!key.endsWith("/")) {
|
||||
throw Error(`you should not call mkdir on ${key}`);
|
||||
}
|
||||
await this._init();
|
||||
const uploadFolder = getOnedrivePath(key, this.remoteBaseDir);
|
||||
console.debug(`mkdir uploadFolder=${uploadFolder}`);
|
||||
return await this._mkdirFromRoot(uploadFolder, mtime, ctime);
|
||||
}
|
||||
|
||||
export const uploadToRemote = async (
|
||||
client: WrappedOnedriveClient,
|
||||
fileOrFolderPath: string,
|
||||
vault: Vault | undefined,
|
||||
isRecursively: boolean,
|
||||
cipher: Cipher,
|
||||
remoteEncryptedKey: string = "",
|
||||
foldersCreatedBefore: Set<string> | undefined = undefined,
|
||||
uploadRaw: boolean = false,
|
||||
rawContent: string | ArrayBuffer = ""
|
||||
): Promise<UploadedType> => {
|
||||
await client.init();
|
||||
async _mkdirFromRoot(
|
||||
key: string,
|
||||
mtime?: number,
|
||||
ctime?: number
|
||||
): Promise<Entity> {
|
||||
// console.debug(`foldersCreatedBefore=${Array.from(this.foldersCreatedBefore)}`);
|
||||
if (this.foldersCreatedBefore.has(key)) {
|
||||
// created, pass
|
||||
// console.debug(`folder ${key} created.`)
|
||||
} else {
|
||||
// https://stackoverflow.com/questions/56479865/creating-nested-folders-in-one-go-onedrive-api
|
||||
// use PATCH to create folder recursively!!!
|
||||
let playload: any = {
|
||||
folder: {},
|
||||
"@microsoft.graph.conflictBehavior": "replace",
|
||||
};
|
||||
const fileSystemInfo: Record<string, string> = {};
|
||||
if (mtime !== undefined && mtime !== 0) {
|
||||
const mtimeStr = new Date(mtime).toISOString();
|
||||
fileSystemInfo["lastModifiedDateTime"] = mtimeStr;
|
||||
}
|
||||
if (ctime !== undefined && ctime !== 0) {
|
||||
const ctimeStr = new Date(ctime).toISOString();
|
||||
fileSystemInfo["createdDateTime"] = ctimeStr;
|
||||
}
|
||||
if (Object.keys(fileSystemInfo).length > 0) {
|
||||
playload["fileSystemInfo"] = fileSystemInfo;
|
||||
}
|
||||
await this._patchJson(key, playload);
|
||||
}
|
||||
const res = await this._statFromRoot(key);
|
||||
return res;
|
||||
}
|
||||
|
||||
let uploadFile = fileOrFolderPath;
|
||||
if (!cipher.isPasswordEmpty()) {
|
||||
if (remoteEncryptedKey === undefined || remoteEncryptedKey === "") {
|
||||
async writeFile(
|
||||
key: string,
|
||||
content: ArrayBuffer,
|
||||
mtime: number,
|
||||
ctime: number
|
||||
): Promise<Entity> {
|
||||
if (key.endsWith("/")) {
|
||||
throw Error(`you should not call writeFile on ${key}`);
|
||||
}
|
||||
await this._init();
|
||||
const uploadFile = getOnedrivePath(key, this.remoteBaseDir);
|
||||
console.debug(`uploadFile=${uploadFile}`);
|
||||
return await this._writeFileFromRoot(
|
||||
uploadFile,
|
||||
content,
|
||||
mtime,
|
||||
ctime,
|
||||
key
|
||||
);
|
||||
}
|
||||
|
||||
async _writeFileFromRoot(
|
||||
key: string,
|
||||
content: ArrayBuffer,
|
||||
mtime: number,
|
||||
ctime: number,
|
||||
origKey: string
|
||||
): Promise<Entity> {
|
||||
if (content.byteLength === 0) {
|
||||
throw Error(
|
||||
`uploadToRemote(onedrive) you have password but remoteEncryptedKey is empty!`
|
||||
`${origKey}: Empty file is not allowed in OneDrive, and please write something in it.`
|
||||
);
|
||||
}
|
||||
uploadFile = remoteEncryptedKey;
|
||||
}
|
||||
uploadFile = getOnedrivePath(uploadFile, client.remoteBaseDir);
|
||||
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("/");
|
||||
|
||||
if (isFolder && isRecursively) {
|
||||
throw Error("upload function doesn't implement recursive function yet!");
|
||||
} else if (isFolder && !isRecursively) {
|
||||
if (uploadRaw) {
|
||||
throw Error(`you specify uploadRaw, but you also provide a folder key!`);
|
||||
}
|
||||
// folder
|
||||
if (cipher.isPasswordEmpty() || cipher.isFolderAware()) {
|
||||
// if not encrypted, || encrypted isFolderAware, mkdir a remote folder
|
||||
if (foldersCreatedBefore?.has(uploadFile)) {
|
||||
// created, pass
|
||||
} else {
|
||||
// https://stackoverflow.com/questions/56479865/creating-nested-folders-in-one-go-onedrive-api
|
||||
// use PATCH to create folder recursively!!!
|
||||
let k: any = {
|
||||
folder: {},
|
||||
"@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);
|
||||
return {
|
||||
entity: res,
|
||||
mtimeCli: mtime,
|
||||
};
|
||||
} else {
|
||||
// if encrypted && !isFolderAware(),
|
||||
// upload a fake, random-size file
|
||||
// with the encrypted file name
|
||||
const byteLengthRandom = getRandomIntInclusive(
|
||||
1,
|
||||
65536 /* max allowed */
|
||||
);
|
||||
const arrBufRandom = await cipher.encryptContent(
|
||||
getRandomArrayBuffer(byteLengthRandom)
|
||||
);
|
||||
|
||||
// an encrypted folder is always small, we just use put here
|
||||
await client.putArrayBuffer(
|
||||
`${uploadFile}:/content?${new URLSearchParams({
|
||||
"@microsoft.graph.conflictBehavior": "replace",
|
||||
})}`,
|
||||
arrBufRandom
|
||||
);
|
||||
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);
|
||||
return {
|
||||
entity: res,
|
||||
mtimeCli: mtime,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// file
|
||||
// we ignore isRecursively parameter here
|
||||
let localContent = undefined;
|
||||
if (uploadRaw) {
|
||||
if (typeof rawContent === "string") {
|
||||
localContent = new TextEncoder().encode(rawContent).buffer;
|
||||
} else {
|
||||
localContent = rawContent;
|
||||
}
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
let remoteContent = localContent;
|
||||
if (!cipher.isPasswordEmpty()) {
|
||||
remoteContent = await cipher.encryptContent(localContent);
|
||||
}
|
||||
const ctimeStr = new Date(ctime).toISOString();
|
||||
const mtimeStr = new Date(mtime).toISOString();
|
||||
|
||||
// no need to create parent folders firstly, cool!
|
||||
|
||||
@@ -828,16 +785,16 @@ export const uploadToRemote = async (
|
||||
const RANGE_SIZE = MIN_UNIT * 20; // about 6.5536 MB
|
||||
const DIRECT_UPLOAD_MAX_SIZE = 1000 * 1000 * 4; // 4 Megabyte
|
||||
|
||||
if (remoteContent.byteLength < DIRECT_UPLOAD_MAX_SIZE) {
|
||||
if (content.byteLength < DIRECT_UPLOAD_MAX_SIZE) {
|
||||
// directly using put!
|
||||
await client.putArrayBuffer(
|
||||
`${uploadFile}:/content?${new URLSearchParams({
|
||||
await this._putArrayBuffer(
|
||||
`${key}:/content?${new URLSearchParams({
|
||||
"@microsoft.graph.conflictBehavior": "replace",
|
||||
})}`,
|
||||
remoteContent
|
||||
content
|
||||
);
|
||||
if (mtime !== 0 && ctime !== 0) {
|
||||
await client.patchJson(`${uploadFile}`, {
|
||||
await this._patchJson(key, {
|
||||
fileSystemInfo: {
|
||||
lastModifiedDateTime: mtimeStr,
|
||||
createdDateTime: ctimeStr,
|
||||
@@ -850,13 +807,13 @@ export const uploadToRemote = async (
|
||||
|
||||
// 1. create uploadSession
|
||||
// uploadFile already starts with /drive/special/approot:/${remoteBaseDir}
|
||||
let k: any = {
|
||||
let playload: any = {
|
||||
item: {
|
||||
"@microsoft.graph.conflictBehavior": "replace",
|
||||
},
|
||||
};
|
||||
if (mtime !== 0 && ctime !== 0) {
|
||||
k = {
|
||||
playload = {
|
||||
item: {
|
||||
"@microsoft.graph.conflictBehavior": "replace",
|
||||
|
||||
@@ -868,9 +825,9 @@ export const uploadToRemote = async (
|
||||
},
|
||||
};
|
||||
}
|
||||
const s: UploadSession = await client.postJson(
|
||||
`${uploadFile}:/createUploadSession`,
|
||||
k
|
||||
const s: UploadSession = await this._postJson(
|
||||
`${key}:/createUploadSession`,
|
||||
playload
|
||||
);
|
||||
const uploadUrl = s.uploadUrl!;
|
||||
console.debug("uploadSession = ");
|
||||
@@ -878,12 +835,12 @@ export const uploadToRemote = async (
|
||||
|
||||
// 2. upload by ranges
|
||||
// convert to uint8
|
||||
const uint8 = new Uint8Array(remoteContent);
|
||||
const uint8 = new Uint8Array(content);
|
||||
|
||||
// upload the ranges one by one
|
||||
let rangeStart = 0;
|
||||
while (rangeStart < uint8.byteLength) {
|
||||
await client.putUint8ArrayByRange(
|
||||
await this._putUint8ArrayByRange(
|
||||
uploadUrl,
|
||||
uint8,
|
||||
rangeStart,
|
||||
@@ -894,132 +851,79 @@ export const uploadToRemote = async (
|
||||
}
|
||||
}
|
||||
|
||||
const res = await getRemoteMeta(client, uploadFile);
|
||||
return {
|
||||
entity: res,
|
||||
mtimeCli: mtime,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const downloadFromRemoteRaw = async (
|
||||
client: WrappedOnedriveClient,
|
||||
remotePath: string
|
||||
): Promise<ArrayBuffer> => {
|
||||
await client.init();
|
||||
const rsp = await client.getJson(
|
||||
`${remotePath}?$select=@microsoft.graph.downloadUrl`
|
||||
);
|
||||
const downloadUrl: string = rsp["@microsoft.graph.downloadUrl"];
|
||||
if (VALID_REQURL) {
|
||||
const content = (
|
||||
await requestUrl({
|
||||
url: downloadUrl,
|
||||
headers: { "Cache-Control": "no-cache" },
|
||||
})
|
||||
).arrayBuffer;
|
||||
return content;
|
||||
} else {
|
||||
const content = await // cannot set no-cache here, will have cors error
|
||||
(await fetch(downloadUrl)).arrayBuffer();
|
||||
return content;
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadFromRemote = async (
|
||||
client: WrappedOnedriveClient,
|
||||
fileOrFolderPath: string,
|
||||
vault: Vault,
|
||||
mtime: number,
|
||||
cipher: Cipher,
|
||||
remoteEncryptedKey: string = "",
|
||||
skipSaving: boolean = false
|
||||
) => {
|
||||
await client.init();
|
||||
|
||||
const isFolder = fileOrFolderPath.endsWith("/");
|
||||
|
||||
if (!skipSaving) {
|
||||
await mkdirpInVault(fileOrFolderPath, vault);
|
||||
const res = await this._statFromRoot(key);
|
||||
return res;
|
||||
}
|
||||
|
||||
if (isFolder) {
|
||||
// mkdirp locally is enough
|
||||
// do nothing here
|
||||
return new ArrayBuffer(0);
|
||||
} else {
|
||||
let downloadFile = fileOrFolderPath;
|
||||
if (!cipher.isPasswordEmpty()) {
|
||||
downloadFile = remoteEncryptedKey;
|
||||
async readFile(key: string): Promise<ArrayBuffer> {
|
||||
await this._init();
|
||||
if (key.endsWith("/")) {
|
||||
throw new Error(`you should not call readFile on folder ${key}`);
|
||||
}
|
||||
downloadFile = getOnedrivePath(downloadFile, client.remoteBaseDir);
|
||||
const remoteContent = await downloadFromRemoteRaw(client, downloadFile);
|
||||
let localContent = remoteContent;
|
||||
if (!cipher.isPasswordEmpty()) {
|
||||
localContent = await cipher.decryptContent(remoteContent);
|
||||
const downloadFile = getOnedrivePath(key, this.remoteBaseDir);
|
||||
return await this._readFileFromRoot(downloadFile);
|
||||
}
|
||||
|
||||
async _readFileFromRoot(key: string): Promise<ArrayBuffer> {
|
||||
const rsp = await this._getJson(
|
||||
`${key}?$select=@microsoft.graph.downloadUrl`
|
||||
);
|
||||
const downloadUrl: string = rsp["@microsoft.graph.downloadUrl"];
|
||||
if (VALID_REQURL) {
|
||||
const content = (
|
||||
await requestUrl({
|
||||
url: downloadUrl,
|
||||
headers: { "Cache-Control": "no-cache" },
|
||||
})
|
||||
).arrayBuffer;
|
||||
return content;
|
||||
} else {
|
||||
// cannot set no-cache here, will have cors error
|
||||
const content = await (await fetch(downloadUrl)).arrayBuffer();
|
||||
return content;
|
||||
}
|
||||
if (!skipSaving) {
|
||||
await vault.adapter.writeBinary(fileOrFolderPath, localContent, {
|
||||
mtime: mtime,
|
||||
});
|
||||
}
|
||||
|
||||
async rm(key: string): Promise<void> {
|
||||
if (key === "" || key === "/") {
|
||||
return;
|
||||
}
|
||||
return localContent;
|
||||
}
|
||||
};
|
||||
const remoteFileName = getOnedrivePath(key, this.remoteBaseDir);
|
||||
|
||||
export const deleteFromRemote = async (
|
||||
client: WrappedOnedriveClient,
|
||||
fileOrFolderPath: string,
|
||||
cipher: Cipher,
|
||||
remoteEncryptedKey: string = ""
|
||||
) => {
|
||||
if (fileOrFolderPath === "/") {
|
||||
return;
|
||||
await this._init();
|
||||
await this._deleteJson(remoteFileName);
|
||||
}
|
||||
let remoteFileName = fileOrFolderPath;
|
||||
if (!cipher.isPasswordEmpty()) {
|
||||
remoteFileName = remoteEncryptedKey;
|
||||
}
|
||||
remoteFileName = getOnedrivePath(remoteFileName, client.remoteBaseDir);
|
||||
|
||||
await client.init();
|
||||
await client.deleteJson(remoteFileName);
|
||||
};
|
||||
|
||||
export const checkConnectivity = async (
|
||||
client: WrappedOnedriveClient,
|
||||
callbackFunc?: any
|
||||
) => {
|
||||
try {
|
||||
const k = await getUserDisplayName(client);
|
||||
return k !== "<unknown display name>";
|
||||
} catch (err) {
|
||||
console.debug(err);
|
||||
if (callbackFunc !== undefined) {
|
||||
callbackFunc(err);
|
||||
async checkConnect(callbackFunc?: any): Promise<boolean> {
|
||||
try {
|
||||
const k = await this.getUserDisplayName();
|
||||
return k !== "<unknown display name>";
|
||||
} catch (err) {
|
||||
console.debug(err);
|
||||
callbackFunc?.(err);
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getUserDisplayName = async (client: WrappedOnedriveClient) => {
|
||||
await client.init();
|
||||
const res: User = await client.getJson("/me?$select=displayName");
|
||||
return res.displayName || "<unknown display name>";
|
||||
};
|
||||
async getUserDisplayName() {
|
||||
await this._init();
|
||||
const res: User = await this._getJson("/me?$select=displayName");
|
||||
return res.displayName || "<unknown display name>";
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-protocols-oidc#send-a-sign-out-request
|
||||
* https://docs.microsoft.com/en-us/graph/api/user-revokesigninsessions
|
||||
* https://docs.microsoft.com/en-us/graph/api/user-invalidateallrefreshtokens
|
||||
* @param client
|
||||
*/
|
||||
// export const revokeAuth = async (client: WrappedOnedriveClient) => {
|
||||
// await client.init();
|
||||
// await client.postJson('/me/revokeSignInSessions', {});
|
||||
// };
|
||||
/**
|
||||
*
|
||||
* https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-protocols-oidc#send-a-sign-out-request
|
||||
* https://docs.microsoft.com/en-us/graph/api/user-revokesigninsessions
|
||||
* https://docs.microsoft.com/en-us/graph/api/user-invalidateallrefreshtokens
|
||||
*/
|
||||
async revokeAuth() {
|
||||
// await this._init();
|
||||
// await this._postJson("/me/revokeSignInSessions", {});
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
export const getRevokeAddr = async () => {
|
||||
return "https://account.live.com/consent/Manage";
|
||||
};
|
||||
async getRevokeAddr() {
|
||||
return "https://account.live.com/consent/Manage";
|
||||
}
|
||||
}
|
||||
+819
@@ -0,0 +1,819 @@
|
||||
import type { _Object, PutObjectCommandInput } from "@aws-sdk/client-s3";
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
HeadObjectCommandOutput,
|
||||
ListObjectsV2Command,
|
||||
ListObjectsV2CommandInput,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { Upload } from "@aws-sdk/lib-storage";
|
||||
import { HttpRequest, HttpResponse } from "@smithy/protocol-http";
|
||||
import {
|
||||
FetchHttpHandler,
|
||||
FetchHttpHandlerOptions,
|
||||
} from "@smithy/fetch-http-handler";
|
||||
// @ts-ignore
|
||||
import { requestTimeout } from "@smithy/fetch-http-handler/dist-es/request-timeout";
|
||||
import { buildQueryString } from "@smithy/querystring-builder";
|
||||
import { HttpHandlerOptions } from "@aws-sdk/types";
|
||||
import { Buffer } from "buffer";
|
||||
import * as mime from "mime-types";
|
||||
import { Platform, requestUrl, RequestUrlParam } from "obsidian";
|
||||
import { Readable } from "stream";
|
||||
import * as path from "path";
|
||||
import AggregateError from "aggregate-error";
|
||||
import { DEFAULT_CONTENT_TYPE, S3Config } from "./baseTypes";
|
||||
import { VALID_REQURL } from "./baseTypesObs";
|
||||
import { bufferToArrayBuffer, getFolderLevels } from "./misc";
|
||||
import PQueue from "p-queue";
|
||||
|
||||
import { Entity } from "./baseTypes";
|
||||
import { FakeFs } from "./fsAll";
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// special handler using Obsidian requestUrl
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/**
|
||||
* This is close to origin implementation of FetchHttpHandler
|
||||
* https://github.com/aws/aws-sdk-js-v3/blob/main/packages/fetch-http-handler/src/fetch-http-handler.ts
|
||||
* that is released under Apache 2 License.
|
||||
* But this uses Obsidian requestUrl instead.
|
||||
*/
|
||||
class ObsHttpHandler extends FetchHttpHandler {
|
||||
requestTimeoutInMs: number | undefined;
|
||||
reverseProxyNoSignUrl: string | undefined;
|
||||
constructor(
|
||||
options?: FetchHttpHandlerOptions,
|
||||
reverseProxyNoSignUrl?: string
|
||||
) {
|
||||
super(options);
|
||||
this.requestTimeoutInMs =
|
||||
options === undefined ? undefined : options.requestTimeout;
|
||||
this.reverseProxyNoSignUrl = reverseProxyNoSignUrl;
|
||||
}
|
||||
async handle(
|
||||
request: HttpRequest,
|
||||
{ abortSignal }: HttpHandlerOptions = {}
|
||||
): Promise<{ response: HttpResponse }> {
|
||||
if (abortSignal?.aborted) {
|
||||
const abortError = new Error("Request aborted");
|
||||
abortError.name = "AbortError";
|
||||
return Promise.reject(abortError);
|
||||
}
|
||||
|
||||
let path = request.path;
|
||||
if (request.query) {
|
||||
const queryString = buildQueryString(request.query);
|
||||
if (queryString) {
|
||||
path += `?${queryString}`;
|
||||
}
|
||||
}
|
||||
|
||||
const { port, method } = request;
|
||||
let url = `${request.protocol}//${request.hostname}${
|
||||
port ? `:${port}` : ""
|
||||
}${path}`;
|
||||
if (
|
||||
this.reverseProxyNoSignUrl !== undefined &&
|
||||
this.reverseProxyNoSignUrl !== ""
|
||||
) {
|
||||
const urlObj = new URL(url);
|
||||
urlObj.host = this.reverseProxyNoSignUrl;
|
||||
url = urlObj.href;
|
||||
}
|
||||
const body =
|
||||
method === "GET" || method === "HEAD" ? undefined : request.body;
|
||||
|
||||
const transformedHeaders: Record<string, string> = {};
|
||||
for (const key of Object.keys(request.headers)) {
|
||||
const keyLower = key.toLowerCase();
|
||||
if (keyLower === "host" || keyLower === "content-length") {
|
||||
continue;
|
||||
}
|
||||
transformedHeaders[keyLower] = request.headers[key];
|
||||
}
|
||||
|
||||
let contentType: string | undefined = undefined;
|
||||
if (transformedHeaders["content-type"] !== undefined) {
|
||||
contentType = transformedHeaders["content-type"];
|
||||
}
|
||||
|
||||
let transformedBody: any = body;
|
||||
if (ArrayBuffer.isView(body)) {
|
||||
transformedBody = bufferToArrayBuffer(body);
|
||||
}
|
||||
|
||||
const param: RequestUrlParam = {
|
||||
body: transformedBody,
|
||||
headers: transformedHeaders,
|
||||
method: method,
|
||||
url: url,
|
||||
contentType: contentType,
|
||||
};
|
||||
|
||||
const raceOfPromises = [
|
||||
requestUrl(param).then((rsp) => {
|
||||
const headers = rsp.headers;
|
||||
const headersLower: Record<string, string> = {};
|
||||
for (const key of Object.keys(headers)) {
|
||||
headersLower[key.toLowerCase()] = headers[key];
|
||||
}
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(rsp.arrayBuffer));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new HttpResponse({
|
||||
headers: headersLower,
|
||||
statusCode: rsp.status,
|
||||
body: stream,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
requestTimeout(this.requestTimeoutInMs),
|
||||
];
|
||||
|
||||
if (abortSignal) {
|
||||
raceOfPromises.push(
|
||||
new Promise<never>((resolve, reject) => {
|
||||
abortSignal.onabort = () => {
|
||||
const abortError = new Error("Request aborted");
|
||||
abortError.name = "AbortError";
|
||||
reject(abortError);
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.race(raceOfPromises);
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// other stuffs
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
export const simpleTransRemotePrefix = (x: string) => {
|
||||
if (x === undefined) {
|
||||
return "";
|
||||
}
|
||||
let y = path.posix.normalize(x.trim());
|
||||
if (y === undefined || y === "" || y === "/" || y === ".") {
|
||||
return "";
|
||||
}
|
||||
if (y.startsWith("/")) {
|
||||
y = y.slice(1);
|
||||
}
|
||||
if (!y.endsWith("/")) {
|
||||
y = `${y}/`;
|
||||
}
|
||||
return y;
|
||||
};
|
||||
|
||||
export const DEFAULT_S3_CONFIG: S3Config = {
|
||||
s3Endpoint: "",
|
||||
s3Region: "",
|
||||
s3AccessKeyID: "",
|
||||
s3SecretAccessKey: "",
|
||||
s3BucketName: "",
|
||||
bypassCorsLocally: true,
|
||||
partsConcurrency: 20,
|
||||
forcePathStyle: false,
|
||||
remotePrefix: "",
|
||||
useAccurateMTime: false, // it causes money, disable by default
|
||||
reverseProxyNoSignUrl: "",
|
||||
generateFolderObject: false, // new version, by default not generate folders
|
||||
};
|
||||
|
||||
/**
|
||||
* The Body of resp of aws GetObject has mix types
|
||||
* and we want to get ArrayBuffer here.
|
||||
* See https://github.com/aws/aws-sdk-js-v3/issues/1877
|
||||
* @param b The Body of GetObject
|
||||
* @returns Promise<ArrayBuffer>
|
||||
*/
|
||||
const getObjectBodyToArrayBuffer = async (
|
||||
b: Readable | ReadableStream | Blob | undefined
|
||||
) => {
|
||||
if (b === undefined) {
|
||||
throw Error(`ObjectBody is undefined and don't know how to deal with it`);
|
||||
}
|
||||
if (b instanceof Readable) {
|
||||
return (await new Promise((resolve, reject) => {
|
||||
const chunks: Uint8Array[] = [];
|
||||
b.on("data", (chunk) => chunks.push(chunk));
|
||||
b.on("error", reject);
|
||||
b.on("end", () => resolve(bufferToArrayBuffer(Buffer.concat(chunks))));
|
||||
})) as ArrayBuffer;
|
||||
} else if (b instanceof ReadableStream) {
|
||||
return await new Response(b, {}).arrayBuffer();
|
||||
} else if (b instanceof Blob) {
|
||||
return await b.arrayBuffer();
|
||||
} else {
|
||||
throw TypeError(`The type of ${b} is not one of the supported types`);
|
||||
}
|
||||
};
|
||||
|
||||
const getS3Client = (s3Config: S3Config) => {
|
||||
let endpoint = s3Config.s3Endpoint;
|
||||
if (!(endpoint.startsWith("http://") || endpoint.startsWith("https://"))) {
|
||||
endpoint = `https://${endpoint}`;
|
||||
}
|
||||
|
||||
let s3Client: S3Client;
|
||||
if (VALID_REQURL && s3Config.bypassCorsLocally) {
|
||||
s3Client = new S3Client({
|
||||
region: s3Config.s3Region,
|
||||
endpoint: endpoint,
|
||||
forcePathStyle: s3Config.forcePathStyle,
|
||||
credentials: {
|
||||
accessKeyId: s3Config.s3AccessKeyID,
|
||||
secretAccessKey: s3Config.s3SecretAccessKey,
|
||||
},
|
||||
requestHandler: new ObsHttpHandler(
|
||||
undefined,
|
||||
s3Config.reverseProxyNoSignUrl
|
||||
),
|
||||
});
|
||||
} else {
|
||||
s3Client = new S3Client({
|
||||
region: s3Config.s3Region,
|
||||
endpoint: endpoint,
|
||||
forcePathStyle: s3Config.forcePathStyle,
|
||||
credentials: {
|
||||
accessKeyId: s3Config.s3AccessKeyID,
|
||||
secretAccessKey: s3Config.s3SecretAccessKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
s3Client.middlewareStack.add(
|
||||
(next, context) => (args) => {
|
||||
(args.request as any).headers["cache-control"] = "no-cache";
|
||||
return next(args);
|
||||
},
|
||||
{
|
||||
step: "build",
|
||||
}
|
||||
);
|
||||
|
||||
return s3Client;
|
||||
};
|
||||
|
||||
const getLocalNoPrefixPath = (
|
||||
fileOrFolderPathWithRemotePrefix: string,
|
||||
remotePrefix: string
|
||||
) => {
|
||||
if (
|
||||
!(
|
||||
fileOrFolderPathWithRemotePrefix === `${remotePrefix}` ||
|
||||
fileOrFolderPathWithRemotePrefix.startsWith(`${remotePrefix}`)
|
||||
)
|
||||
) {
|
||||
throw Error(
|
||||
`"${fileOrFolderPathWithRemotePrefix}" doesn't starts with "${remotePrefix}"`
|
||||
);
|
||||
}
|
||||
return fileOrFolderPathWithRemotePrefix.slice(`${remotePrefix}`.length);
|
||||
};
|
||||
|
||||
const getRemoteWithPrefixPath = (
|
||||
fileOrFolderPath: string,
|
||||
remotePrefix: string
|
||||
) => {
|
||||
if (remotePrefix === undefined || remotePrefix === "") {
|
||||
return fileOrFolderPath;
|
||||
}
|
||||
let key = fileOrFolderPath;
|
||||
if (fileOrFolderPath === "/" || fileOrFolderPath === "") {
|
||||
// special
|
||||
key = remotePrefix;
|
||||
}
|
||||
if (!fileOrFolderPath.startsWith("/")) {
|
||||
key = `${remotePrefix}${fileOrFolderPath}`;
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
const fromS3ObjectToEntity = (
|
||||
x: _Object,
|
||||
remotePrefix: string,
|
||||
mtimeRecords: Record<string, number>,
|
||||
ctimeRecords: Record<string, number>
|
||||
) => {
|
||||
// console.debug(`fromS3ObjectToEntity: ${x.Key!}, ${JSON.stringify(x,null,2)}`);
|
||||
// S3 officially only supports seconds precision!!!!!
|
||||
const mtimeSvr = Math.floor(x.LastModified!.valueOf() / 1000.0) * 1000;
|
||||
let mtimeCli = mtimeSvr;
|
||||
if (x.Key! in mtimeRecords) {
|
||||
const m2 = mtimeRecords[x.Key!];
|
||||
if (m2 !== 0) {
|
||||
// to be compatible with RClone, we read and store the time in seconds in new version!
|
||||
if (m2 >= 1000000000000) {
|
||||
// it's a millsecond, uploaded by old codes..
|
||||
mtimeCli = m2;
|
||||
} else {
|
||||
// it's a second, uploaded by new codes of the plugin from March 24, 2024
|
||||
mtimeCli = m2 * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
const key = getLocalNoPrefixPath(x.Key!, remotePrefix); // we remove prefix here
|
||||
const r: Entity = {
|
||||
key: key, // from s3's repsective, the keyRaw is the key, we will change it in decyption
|
||||
keyRaw: key,
|
||||
mtimeSvr: mtimeSvr,
|
||||
mtimeCli: mtimeCli,
|
||||
sizeRaw: x.Size!,
|
||||
size: x.Size!, // from s3's repsective, the sizeRaw is the size, we will change it in decyption
|
||||
etag: x.ETag,
|
||||
synthesizedFolder: false,
|
||||
};
|
||||
return r;
|
||||
};
|
||||
|
||||
const fromS3HeadObjectToEntity = (
|
||||
fileOrFolderPathWithRemotePrefix: string,
|
||||
x: HeadObjectCommandOutput,
|
||||
remotePrefix: string,
|
||||
useAccurateMTime: boolean
|
||||
) => {
|
||||
// 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 (useAccurateMTime && x.Metadata !== undefined) {
|
||||
const m2 = Math.floor(
|
||||
parseFloat(x.Metadata.mtime || x.Metadata.MTime || "0")
|
||||
);
|
||||
if (m2 !== 0) {
|
||||
// to be compatible with RClone, we read and store the time in seconds in new version!
|
||||
if (m2 >= 1000000000000) {
|
||||
// it's a millsecond, uploaded by old codes..
|
||||
mtimeCli = m2;
|
||||
} else {
|
||||
// it's a second, uploaded by new codes of the plugin from March 24, 2024
|
||||
mtimeCli = m2 * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
// console.debug(
|
||||
// `fromS3HeadObjectToEntity, fileOrFolderPathWithRemotePrefix=${fileOrFolderPathWithRemotePrefix}, remotePrefix=${remotePrefix}, x=${JSON.stringify(
|
||||
// x
|
||||
// )} `
|
||||
// );
|
||||
const key = getLocalNoPrefixPath(
|
||||
fileOrFolderPathWithRemotePrefix,
|
||||
remotePrefix
|
||||
);
|
||||
// console.debug(`fromS3HeadObjectToEntity, key=${key} after removing prefix`);
|
||||
return {
|
||||
key: key,
|
||||
keyRaw: key,
|
||||
mtimeSvr: mtimeSvr,
|
||||
mtimeCli: mtimeCli,
|
||||
sizeRaw: x.ContentLength,
|
||||
size: x.ContentLength,
|
||||
etag: x.ETag,
|
||||
synthesizedFolder: false,
|
||||
} as Entity;
|
||||
};
|
||||
|
||||
export class FakeFsS3 extends FakeFs {
|
||||
s3Config: S3Config;
|
||||
s3Client: S3Client;
|
||||
kind: "s3";
|
||||
synthFoldersCache: Record<string, Entity>;
|
||||
constructor(s3Config: S3Config) {
|
||||
super();
|
||||
this.s3Config = s3Config;
|
||||
this.s3Client = getS3Client(s3Config);
|
||||
this.kind = "s3";
|
||||
this.synthFoldersCache = {};
|
||||
}
|
||||
|
||||
async walk(): Promise<Entity[]> {
|
||||
const res = (await this._walkFromRoot(this.s3Config.remotePrefix)).filter(
|
||||
(x) => x.key !== "" && x.key !== "/"
|
||||
);
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* the input key contains basedir (prefix),
|
||||
* but the result doesn't contain it.
|
||||
*/
|
||||
async _walkFromRoot(prefixOfRawKeys: string | undefined) {
|
||||
const confCmd = {
|
||||
Bucket: this.s3Config.s3BucketName,
|
||||
} as ListObjectsV2CommandInput;
|
||||
if (prefixOfRawKeys !== undefined && prefixOfRawKeys !== "") {
|
||||
confCmd.Prefix = prefixOfRawKeys;
|
||||
}
|
||||
|
||||
const contents = [] as _Object[];
|
||||
const mtimeRecords: Record<string, number> = {};
|
||||
const ctimeRecords: Record<string, number> = {};
|
||||
const queueHead = new PQueue({
|
||||
concurrency: this.s3Config.partsConcurrency,
|
||||
autoStart: true,
|
||||
});
|
||||
queueHead.on("error", (error) => {
|
||||
queueHead.pause();
|
||||
queueHead.clear();
|
||||
throw error;
|
||||
});
|
||||
|
||||
let isTruncated = true;
|
||||
do {
|
||||
const rsp = await this.s3Client.send(new ListObjectsV2Command(confCmd));
|
||||
|
||||
if (rsp.$metadata.httpStatusCode !== 200) {
|
||||
throw Error("some thing bad while listing remote!");
|
||||
}
|
||||
if (rsp.Contents === undefined) {
|
||||
break;
|
||||
}
|
||||
contents.push(...rsp.Contents);
|
||||
|
||||
if (this.s3Config.useAccurateMTime) {
|
||||
// head requests of all objects, love it
|
||||
for (const content of rsp.Contents) {
|
||||
queueHead.add(async () => {
|
||||
const rspHead = await this.s3Client.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: this.s3Config.s3BucketName,
|
||||
Key: content.Key,
|
||||
})
|
||||
);
|
||||
if (rspHead.$metadata.httpStatusCode !== 200) {
|
||||
throw Error("some thing bad while heading single object!");
|
||||
}
|
||||
if (rspHead.Metadata === undefined) {
|
||||
// pass
|
||||
} else {
|
||||
mtimeRecords[content.Key!] = Math.floor(
|
||||
parseFloat(
|
||||
rspHead.Metadata.mtime || rspHead.Metadata.MTime || "0"
|
||||
)
|
||||
);
|
||||
ctimeRecords[content.Key!] = Math.floor(
|
||||
parseFloat(
|
||||
rspHead.Metadata.ctime || rspHead.Metadata.CTime || "0"
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
isTruncated = rsp.IsTruncated ?? false;
|
||||
confCmd.ContinuationToken = rsp.NextContinuationToken;
|
||||
if (
|
||||
isTruncated &&
|
||||
(confCmd.ContinuationToken === undefined ||
|
||||
confCmd.ContinuationToken === "")
|
||||
) {
|
||||
throw Error("isTruncated is true but no continuationToken provided");
|
||||
}
|
||||
} while (isTruncated);
|
||||
|
||||
// wait for any head requests
|
||||
await queueHead.onIdle();
|
||||
|
||||
// ensemble fake rsp
|
||||
// in the end, we need to transform the response list
|
||||
// back to the local contents-alike list
|
||||
const res: Entity[] = [];
|
||||
const realEnrities = new Set<string>();
|
||||
for (const remoteObj of contents) {
|
||||
const remoteEntity = fromS3ObjectToEntity(
|
||||
remoteObj,
|
||||
this.s3Config.remotePrefix ?? "",
|
||||
mtimeRecords,
|
||||
ctimeRecords
|
||||
);
|
||||
realEnrities.add(remoteEntity.key!);
|
||||
res.push(remoteEntity);
|
||||
|
||||
for (const f of getFolderLevels(remoteEntity.key!, true)) {
|
||||
if (realEnrities.has(f)) {
|
||||
delete this.synthFoldersCache[f];
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!this.synthFoldersCache.hasOwnProperty(f) ||
|
||||
remoteEntity.mtimeSvr! >= this.synthFoldersCache[f].mtimeSvr!
|
||||
) {
|
||||
this.synthFoldersCache[f] = {
|
||||
key: f,
|
||||
keyRaw: f,
|
||||
size: 0,
|
||||
sizeRaw: 0,
|
||||
sizeEnc: 0,
|
||||
mtimeSvr: remoteEntity.mtimeSvr,
|
||||
mtimeSvrFmt: remoteEntity.mtimeSvrFmt,
|
||||
mtimeCli: remoteEntity.mtimeCli,
|
||||
mtimeCliFmt: remoteEntity.mtimeCliFmt,
|
||||
synthesizedFolder: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(this.synthFoldersCache)) {
|
||||
res.push(this.synthFoldersCache[key]);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async stat(key: string): Promise<Entity> {
|
||||
if (this.synthFoldersCache.hasOwnProperty(key)) {
|
||||
return this.synthFoldersCache[key];
|
||||
}
|
||||
let keyFullPath = key;
|
||||
keyFullPath = getRemoteWithPrefixPath(
|
||||
keyFullPath,
|
||||
this.s3Config.remotePrefix ?? ""
|
||||
);
|
||||
return await this._statFromRoot(keyFullPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* the input key contains basedir (prefix),
|
||||
* but the result doesn't contain it.
|
||||
*/
|
||||
async _statFromRoot(key: string): Promise<Entity> {
|
||||
if (
|
||||
this.s3Config.remotePrefix !== undefined &&
|
||||
this.s3Config.remotePrefix !== "" &&
|
||||
!key.startsWith(this.s3Config.remotePrefix)
|
||||
) {
|
||||
throw Error(`_statFromRoot should only accept prefix-ed path`);
|
||||
}
|
||||
const res = await this.s3Client.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: this.s3Config.s3BucketName,
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
|
||||
return fromS3HeadObjectToEntity(
|
||||
key,
|
||||
res,
|
||||
this.s3Config.remotePrefix ?? "",
|
||||
this.s3Config.useAccurateMTime ?? false
|
||||
);
|
||||
}
|
||||
|
||||
async mkdir(key: string, mtime?: number, ctime?: number): Promise<Entity> {
|
||||
if (!key.endsWith("/")) {
|
||||
throw new Error(`You should not call mkdir on ${key}!`);
|
||||
}
|
||||
|
||||
const generateFolderObject = this.s3Config.generateFolderObject ?? false;
|
||||
if (!generateFolderObject) {
|
||||
const synth = {
|
||||
key: key,
|
||||
keyRaw: key,
|
||||
size: 0,
|
||||
sizeRaw: 0,
|
||||
sizeEnc: 0,
|
||||
mtimeSvr: mtime,
|
||||
mtimeCli: mtime,
|
||||
synthesizedFolder: true,
|
||||
};
|
||||
this.synthFoldersCache[key] = synth;
|
||||
return synth;
|
||||
}
|
||||
|
||||
const uploadFile = getRemoteWithPrefixPath(
|
||||
key,
|
||||
this.s3Config.remotePrefix ?? ""
|
||||
);
|
||||
return await this._mkdirFromRoot(uploadFile, mtime, ctime);
|
||||
}
|
||||
|
||||
async _mkdirFromRoot(key: string, mtime?: number, ctime?: number) {
|
||||
if (
|
||||
this.s3Config.remotePrefix !== undefined &&
|
||||
this.s3Config.remotePrefix !== "" &&
|
||||
!key.startsWith(this.s3Config.remotePrefix)
|
||||
) {
|
||||
throw Error(`_mkdirFromRoot should only accept prefix-ed path`);
|
||||
}
|
||||
|
||||
const contentType = DEFAULT_CONTENT_TYPE;
|
||||
const p: PutObjectCommandInput = {
|
||||
Bucket: this.s3Config.s3BucketName,
|
||||
Key: key,
|
||||
Body: "",
|
||||
ContentType: contentType,
|
||||
ContentLength: 0, // interesting we need to set this to avoid the warning
|
||||
};
|
||||
const metadata: Record<string, string> = {};
|
||||
if (mtime !== undefined && mtime !== 0) {
|
||||
metadata["MTime"] = `${mtime / 1000.0}`;
|
||||
}
|
||||
if (ctime !== undefined && ctime !== 0) {
|
||||
metadata["CTime"] = `${ctime / 1000.0}`;
|
||||
}
|
||||
if (Object.keys(metadata).length > 0) {
|
||||
p["Metadata"] = metadata;
|
||||
}
|
||||
await this.s3Client.send(new PutObjectCommand(p));
|
||||
return await this._statFromRoot(key);
|
||||
}
|
||||
|
||||
async writeFile(
|
||||
key: string,
|
||||
content: ArrayBuffer,
|
||||
mtime: number,
|
||||
ctime: number
|
||||
): Promise<Entity> {
|
||||
const uploadFile = getRemoteWithPrefixPath(
|
||||
key,
|
||||
this.s3Config.remotePrefix ?? ""
|
||||
);
|
||||
const res = await this._writeFileFromRoot(
|
||||
uploadFile,
|
||||
content,
|
||||
mtime,
|
||||
ctime
|
||||
);
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* the input key contains basedir (prefix),
|
||||
* but the result doesn't contain it.
|
||||
*/
|
||||
async _writeFileFromRoot(
|
||||
key: string,
|
||||
content: ArrayBuffer,
|
||||
mtime: number,
|
||||
ctime: number
|
||||
): Promise<Entity> {
|
||||
if (
|
||||
this.s3Config.remotePrefix !== undefined &&
|
||||
this.s3Config.remotePrefix !== "" &&
|
||||
!key.startsWith(this.s3Config.remotePrefix)
|
||||
) {
|
||||
throw Error(`_writeFileFromRoot should only accept prefix-ed path`);
|
||||
}
|
||||
|
||||
const bytesIn5MB = 5242880;
|
||||
const body = new Uint8Array(content);
|
||||
|
||||
let contentType = DEFAULT_CONTENT_TYPE;
|
||||
contentType =
|
||||
mime.contentType(mime.lookup(key) || DEFAULT_CONTENT_TYPE) ||
|
||||
DEFAULT_CONTENT_TYPE;
|
||||
|
||||
const upload = new Upload({
|
||||
client: this.s3Client,
|
||||
queueSize: this.s3Config.partsConcurrency, // concurrency
|
||||
partSize: bytesIn5MB, // minimal 5MB by default
|
||||
leavePartsOnError: false,
|
||||
params: {
|
||||
Bucket: this.s3Config.s3BucketName,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
Metadata: {
|
||||
MTime: `${mtime / 1000.0}`,
|
||||
CTime: `${ctime / 1000.0}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
upload.on("httpUploadProgress", (progress) => {
|
||||
// console.info(progress);
|
||||
});
|
||||
await upload.done();
|
||||
|
||||
return await this._statFromRoot(key);
|
||||
}
|
||||
|
||||
async readFile(key: string): Promise<ArrayBuffer> {
|
||||
if (key.endsWith("/")) {
|
||||
throw new Error(`you should not call readFile on folder ${key}`);
|
||||
}
|
||||
const downloadFile = getRemoteWithPrefixPath(
|
||||
key,
|
||||
this.s3Config.remotePrefix ?? ""
|
||||
);
|
||||
|
||||
return await this._readFileFromRoot(downloadFile);
|
||||
}
|
||||
|
||||
async _readFileFromRoot(key: string): Promise<ArrayBuffer> {
|
||||
if (
|
||||
this.s3Config.remotePrefix !== undefined &&
|
||||
this.s3Config.remotePrefix !== "" &&
|
||||
!key.startsWith(this.s3Config.remotePrefix)
|
||||
) {
|
||||
throw Error(`_readFileFromRoot should only accept prefix-ed path`);
|
||||
}
|
||||
const data = await this.s3Client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: this.s3Config.s3BucketName,
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
const bodyContents = await getObjectBodyToArrayBuffer(data.Body);
|
||||
return bodyContents;
|
||||
}
|
||||
|
||||
async rm(key: string): Promise<void> {
|
||||
if (key === "/") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.synthFoldersCache.hasOwnProperty(key)) {
|
||||
delete this.synthFoldersCache[key];
|
||||
return;
|
||||
}
|
||||
|
||||
const remoteFileName = getRemoteWithPrefixPath(
|
||||
key,
|
||||
this.s3Config.remotePrefix ?? ""
|
||||
);
|
||||
|
||||
await this.s3Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: this.s3Config.s3BucketName,
|
||||
Key: remoteFileName,
|
||||
})
|
||||
);
|
||||
|
||||
// TODO: do we need to delete folder recursively?
|
||||
// maybe we should not
|
||||
// because the outer sync algorithm should do that
|
||||
// (await this._walkFromRoot(remoteFileName)).map(...)
|
||||
}
|
||||
|
||||
async checkConnect(callbackFunc?: any): Promise<boolean> {
|
||||
try {
|
||||
// TODO: no universal way now, just check this in connectivity
|
||||
if (Platform.isIosApp && this.s3Config.s3Endpoint.startsWith("http://")) {
|
||||
throw Error(
|
||||
`Your s3 endpoint could only be https, not http, because of the iOS restriction.`
|
||||
);
|
||||
}
|
||||
|
||||
// const results = await this.s3Client.send(
|
||||
// new HeadBucketCommand({ Bucket: this.s3Config.s3BucketName })
|
||||
// );
|
||||
// very simplified version of listing objects
|
||||
const confCmd = {
|
||||
Bucket: this.s3Config.s3BucketName,
|
||||
} as ListObjectsV2CommandInput;
|
||||
const results = await this.s3Client.send(
|
||||
new ListObjectsV2Command(confCmd)
|
||||
);
|
||||
|
||||
if (
|
||||
results === undefined ||
|
||||
results.$metadata === undefined ||
|
||||
results.$metadata.httpStatusCode === undefined
|
||||
) {
|
||||
const err = "results or $metadata or httStatusCode is undefined";
|
||||
console.debug(err);
|
||||
if (callbackFunc !== undefined) {
|
||||
callbackFunc(err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return results.$metadata.httpStatusCode === 200;
|
||||
} catch (err: any) {
|
||||
console.debug(err);
|
||||
if (callbackFunc !== undefined) {
|
||||
if (this.s3Config.s3Endpoint.contains(this.s3Config.s3BucketName)) {
|
||||
const err2 = new AggregateError([
|
||||
err,
|
||||
new Error(
|
||||
"Maybe you've included the bucket name inside the endpoint setting. Please remove the bucket name and try again."
|
||||
),
|
||||
]);
|
||||
callbackFunc(err2);
|
||||
} else {
|
||||
callbackFunc(err);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getUserDisplayName(): Promise<string> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async revokeAuth() {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
}
|
||||
+495
@@ -0,0 +1,495 @@
|
||||
import { getReasonPhrase } from "http-status-codes/build/cjs/utils-functions";
|
||||
import { Buffer } from "buffer";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import { Queue } from "@fyears/tsqueue";
|
||||
import chunk from "lodash/chunk";
|
||||
import flatten from "lodash/flatten";
|
||||
import { Platform, requestUrl } from "obsidian";
|
||||
import { FakeFs } from "./fsAll";
|
||||
import { bufferToArrayBuffer } from "./misc";
|
||||
import { Entity, WebdavConfig } from "./baseTypes";
|
||||
import { VALID_REQURL } from "./baseTypesObs";
|
||||
import type {
|
||||
FileStat,
|
||||
WebDAVClient,
|
||||
RequestOptionsWithState,
|
||||
// Response,
|
||||
// ResponseDataDetailed,
|
||||
} from "webdav";
|
||||
|
||||
/**
|
||||
* https://stackoverflow.com/questions/32850898/how-to-check-if-a-string-has-any-non-iso-8859-1-characters-with-javascript
|
||||
* @param str
|
||||
* @returns true if all are iso 8859 1 chars
|
||||
*/
|
||||
function onlyAscii(str: string) {
|
||||
return !/[^\u0000-\u00ff]/g.test(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* https://stackoverflow.com/questions/12539574/
|
||||
* @param obj
|
||||
* @returns
|
||||
*/
|
||||
function objKeyToLower(obj: Record<string, string>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).map(([k, v]) => [k.toLowerCase(), v])
|
||||
);
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
import { getPatcher } from "webdav/dist/web/index.js";
|
||||
if (VALID_REQURL) {
|
||||
getPatcher().patch(
|
||||
"request",
|
||||
async (options: RequestOptionsWithState): Promise<Response> => {
|
||||
const transformedHeaders = objKeyToLower({ ...options.headers });
|
||||
delete transformedHeaders["host"];
|
||||
delete transformedHeaders["content-length"];
|
||||
|
||||
const reqContentType =
|
||||
transformedHeaders["accept"] ?? transformedHeaders["content-type"];
|
||||
|
||||
const retractedHeaders = { ...transformedHeaders };
|
||||
if (retractedHeaders.hasOwnProperty("authorization")) {
|
||||
retractedHeaders["authorization"] = "<retracted>";
|
||||
}
|
||||
|
||||
console.debug(`before request:`);
|
||||
console.debug(`url: ${options.url}`);
|
||||
console.debug(`method: ${options.method}`);
|
||||
console.debug(`headers: ${JSON.stringify(retractedHeaders, null, 2)}`);
|
||||
console.debug(`reqContentType: ${reqContentType}`);
|
||||
|
||||
let r = await requestUrl({
|
||||
url: options.url,
|
||||
method: options.method,
|
||||
body: options.data as string | ArrayBuffer,
|
||||
headers: transformedHeaders,
|
||||
contentType: reqContentType,
|
||||
throw: false,
|
||||
});
|
||||
|
||||
if (
|
||||
r.status === 401 &&
|
||||
Platform.isIosApp &&
|
||||
!options.url.endsWith("/") &&
|
||||
!options.url.endsWith(".md") &&
|
||||
options.method.toUpperCase() === "PROPFIND"
|
||||
) {
|
||||
// don't ask me why,
|
||||
// some webdav servers have some mysterious behaviours,
|
||||
// if a folder doesn't exist without slash, the servers return 401 instead of 404
|
||||
// here is a dirty hack that works
|
||||
console.debug(`so we have 401, try appending request url with slash`);
|
||||
r = await requestUrl({
|
||||
url: `${options.url}/`,
|
||||
method: options.method,
|
||||
body: options.data as string | ArrayBuffer,
|
||||
headers: transformedHeaders,
|
||||
contentType: reqContentType,
|
||||
throw: false,
|
||||
});
|
||||
}
|
||||
|
||||
console.debug(`after request:`);
|
||||
const rspHeaders = objKeyToLower({ ...r.headers });
|
||||
console.debug(`rspHeaders: ${JSON.stringify(rspHeaders, null, 2)}`);
|
||||
for (let key in rspHeaders) {
|
||||
if (rspHeaders.hasOwnProperty(key)) {
|
||||
// avoid the error:
|
||||
// Failed to read the 'headers' property from 'ResponseInit': String contains non ISO-8859-1 code point.
|
||||
// const possibleNonAscii = [
|
||||
// "Content-Disposition",
|
||||
// "X-Accel-Redirect",
|
||||
// "X-Outfilename",
|
||||
// "X-Sendfile"
|
||||
// ];
|
||||
// for (const p of possibleNonAscii) {
|
||||
// if (key === p || key === p.toLowerCase()) {
|
||||
// rspHeaders[key] = encodeURIComponent(rspHeaders[key]);
|
||||
// }
|
||||
// }
|
||||
if (!onlyAscii(rspHeaders[key])) {
|
||||
console.debug(`rspHeaders[key] needs encode: ${key}`);
|
||||
rspHeaders[key] = encodeURIComponent(rspHeaders[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let r2: Response | undefined = undefined;
|
||||
const statusText = getReasonPhrase(r.status);
|
||||
console.debug(`statusText: ${statusText}`);
|
||||
if ([101, 103, 204, 205, 304].includes(r.status)) {
|
||||
// A null body status is a status that is 101, 103, 204, 205, or 304.
|
||||
// https://fetch.spec.whatwg.org/#statuses
|
||||
// fix this: Failed to construct 'Response': Response with null body status cannot have body
|
||||
r2 = new Response(null, {
|
||||
status: r.status,
|
||||
statusText: statusText,
|
||||
headers: rspHeaders,
|
||||
});
|
||||
} else {
|
||||
r2 = new Response(r.arrayBuffer, {
|
||||
status: r.status,
|
||||
statusText: statusText,
|
||||
headers: rspHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
return r2;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
import { AuthType, BufferLike, createClient } from "webdav/dist/web/index.js";
|
||||
|
||||
export const DEFAULT_WEBDAV_CONFIG = {
|
||||
address: "",
|
||||
username: "",
|
||||
password: "",
|
||||
authType: "basic",
|
||||
manualRecursive: true,
|
||||
depth: "manual_1",
|
||||
remoteBaseDir: "",
|
||||
} as WebdavConfig;
|
||||
|
||||
const getWebdavPath = (fileOrFolderPath: string, remoteBaseDir: string) => {
|
||||
let key = fileOrFolderPath;
|
||||
if (fileOrFolderPath === "/" || fileOrFolderPath === "") {
|
||||
// special
|
||||
key = `/${remoteBaseDir}/`;
|
||||
} else if (fileOrFolderPath.startsWith("/")) {
|
||||
console.warn(
|
||||
`why the path ${fileOrFolderPath} starts with '/'? but we just go on.`
|
||||
);
|
||||
key = `/${remoteBaseDir}${fileOrFolderPath}`;
|
||||
} else {
|
||||
key = `/${remoteBaseDir}/${fileOrFolderPath}`;
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
const getNormPath = (fileOrFolderPath: string, remoteBaseDir: string) => {
|
||||
if (
|
||||
!(
|
||||
fileOrFolderPath === `/${remoteBaseDir}` ||
|
||||
fileOrFolderPath.startsWith(`/${remoteBaseDir}/`)
|
||||
)
|
||||
) {
|
||||
throw Error(
|
||||
`"${fileOrFolderPath}" doesn't starts with "/${remoteBaseDir}/"`
|
||||
);
|
||||
}
|
||||
|
||||
return fileOrFolderPath.slice(`/${remoteBaseDir}/`.length);
|
||||
};
|
||||
|
||||
const fromWebdavItemToEntity = (x: FileStat, remoteBaseDir: string): Entity => {
|
||||
let key = getNormPath(x.filename, remoteBaseDir);
|
||||
if (x.type === "directory" && !key.endsWith("/")) {
|
||||
key = `${key}/`;
|
||||
}
|
||||
const mtimeSvr = Date.parse(x.lastmod).valueOf();
|
||||
return {
|
||||
key: key,
|
||||
keyRaw: key,
|
||||
mtimeSvr: mtimeSvr,
|
||||
mtimeCli: mtimeSvr, // TODO: no universal way to set mtime in webdav
|
||||
size: x.size,
|
||||
sizeRaw: x.size,
|
||||
};
|
||||
};
|
||||
|
||||
export class FakeFsWebdav extends FakeFs {
|
||||
kind: "webdav";
|
||||
|
||||
webdavConfig: WebdavConfig;
|
||||
remoteBaseDir: string;
|
||||
client!: WebDAVClient;
|
||||
vaultFolderExists: boolean;
|
||||
saveUpdatedConfigFunc: () => Promise<any>;
|
||||
|
||||
constructor(
|
||||
webdavConfig: WebdavConfig,
|
||||
vaultName: string,
|
||||
saveUpdatedConfigFunc: () => Promise<any>
|
||||
) {
|
||||
super();
|
||||
this.kind = "webdav";
|
||||
this.webdavConfig = cloneDeep(webdavConfig);
|
||||
this.webdavConfig.address = encodeURI(this.webdavConfig.address);
|
||||
this.remoteBaseDir = this.webdavConfig.remoteBaseDir || vaultName || "";
|
||||
this.vaultFolderExists = false;
|
||||
this.saveUpdatedConfigFunc = saveUpdatedConfigFunc;
|
||||
}
|
||||
|
||||
async _init() {
|
||||
// init client if not inited
|
||||
if (this.client !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.isIosApp && !this.webdavConfig.address.startsWith("https")) {
|
||||
throw Error(
|
||||
`Your webdav address could only be https, not http, because of the iOS restriction.`
|
||||
);
|
||||
}
|
||||
|
||||
const headers = {
|
||||
"Cache-Control": "no-cache",
|
||||
};
|
||||
if (
|
||||
this.webdavConfig.username !== "" &&
|
||||
this.webdavConfig.password !== ""
|
||||
) {
|
||||
this.client = createClient(this.webdavConfig.address, {
|
||||
username: this.webdavConfig.username,
|
||||
password: this.webdavConfig.password,
|
||||
headers: headers,
|
||||
authType:
|
||||
this.webdavConfig.authType === "digest"
|
||||
? AuthType.Digest
|
||||
: AuthType.Password,
|
||||
});
|
||||
} else {
|
||||
console.info("no password");
|
||||
this.client = createClient(this.webdavConfig.address, {
|
||||
headers: headers,
|
||||
});
|
||||
}
|
||||
|
||||
// check vault folder
|
||||
if (this.vaultFolderExists) {
|
||||
// pass
|
||||
} else {
|
||||
const res = await this.client.exists(`/${this.remoteBaseDir}/`);
|
||||
if (res) {
|
||||
// console.info("remote vault folder exits!");
|
||||
this.vaultFolderExists = true;
|
||||
} else {
|
||||
console.info("remote vault folder not exists, creating");
|
||||
await this.client.createDirectory(`/${this.remoteBaseDir}/`);
|
||||
console.info("remote vault folder created!");
|
||||
this.vaultFolderExists = true;
|
||||
}
|
||||
}
|
||||
|
||||
// adjust depth parameter
|
||||
if (
|
||||
this.webdavConfig.depth === "auto" ||
|
||||
this.webdavConfig.depth === "auto_1" ||
|
||||
this.webdavConfig.depth === "auto_infinity" ||
|
||||
this.webdavConfig.depth === "auto_unknown"
|
||||
) {
|
||||
this.webdavConfig.depth = "manual_1";
|
||||
this.webdavConfig.manualRecursive = true;
|
||||
if (this.saveUpdatedConfigFunc !== undefined) {
|
||||
await this.saveUpdatedConfigFunc();
|
||||
console.info(
|
||||
`webdav depth="auto_???" is changed to ${this.webdavConfig.depth}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async walk(): Promise<Entity[]> {
|
||||
await this._init();
|
||||
|
||||
let contents = [] as FileStat[];
|
||||
if (
|
||||
this.webdavConfig.depth === "auto" ||
|
||||
this.webdavConfig.depth === "auto_unknown" ||
|
||||
this.webdavConfig.depth === "auto_1" ||
|
||||
this.webdavConfig.depth === "auto_infinity" /* don't trust auto now */ ||
|
||||
this.webdavConfig.depth === "manual_1"
|
||||
) {
|
||||
// the remote doesn't support infinity propfind,
|
||||
// we need to do a bfs here
|
||||
const q = new Queue([`/${this.remoteBaseDir}`]);
|
||||
const CHUNK_SIZE = 10;
|
||||
while (q.length > 0) {
|
||||
const itemsToFetch: string[] = [];
|
||||
while (q.length > 0) {
|
||||
itemsToFetch.push(q.pop()!);
|
||||
}
|
||||
const itemsToFetchChunks = chunk(itemsToFetch, CHUNK_SIZE);
|
||||
// console.debug(itemsToFetchChunks);
|
||||
const subContents = [] as FileStat[];
|
||||
for (const singleChunk of itemsToFetchChunks) {
|
||||
const r = singleChunk.map((x) => {
|
||||
return this.client.getDirectoryContents(x, {
|
||||
deep: false,
|
||||
details: false /* no need for verbose details here */,
|
||||
// TODO: to support .obsidian,
|
||||
// we need to load all files including dot,
|
||||
// anyway to reduce the resources?
|
||||
// glob: "/**" /* avoid dot files by using glob */,
|
||||
}) as Promise<FileStat[]>;
|
||||
});
|
||||
const r2 = flatten(await Promise.all(r));
|
||||
subContents.push(...r2);
|
||||
}
|
||||
for (let i = 0; i < subContents.length; ++i) {
|
||||
const f = subContents[i];
|
||||
contents.push(f);
|
||||
if (f.type === "directory") {
|
||||
q.push(f.filename);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// the remote supports infinity propfind
|
||||
contents = (await this.client.getDirectoryContents(
|
||||
`/${this.remoteBaseDir}`,
|
||||
{
|
||||
deep: true,
|
||||
details: false /* no need for verbose details here */,
|
||||
// TODO: to support .obsidian,
|
||||
// we need to load all files including dot,
|
||||
// anyway to reduce the resources?
|
||||
// glob: "/**" /* avoid dot files by using glob */,
|
||||
}
|
||||
)) as FileStat[];
|
||||
}
|
||||
return contents.map((x) => fromWebdavItemToEntity(x, this.remoteBaseDir));
|
||||
}
|
||||
|
||||
async stat(key: string): Promise<Entity> {
|
||||
await this._init();
|
||||
const fullPath = getWebdavPath(key, this.remoteBaseDir);
|
||||
return await this._statFromRoot(fullPath);
|
||||
}
|
||||
|
||||
async _statFromRoot(key: string): Promise<Entity> {
|
||||
const res = (await this.client.stat(key, {
|
||||
details: false,
|
||||
})) as FileStat;
|
||||
return fromWebdavItemToEntity(res, this.remoteBaseDir);
|
||||
}
|
||||
|
||||
async mkdir(key: string, mtime?: number, ctime?: number): Promise<Entity> {
|
||||
if (!key.endsWith("/")) {
|
||||
throw Error(`you should not call mkdir on ${key}`);
|
||||
}
|
||||
await this._init();
|
||||
const uploadFile = getWebdavPath(key, this.remoteBaseDir);
|
||||
return await this._mkdirFromRoot(uploadFile, mtime, ctime);
|
||||
}
|
||||
|
||||
async _mkdirFromRoot(
|
||||
key: string,
|
||||
mtime?: number,
|
||||
ctime?: number
|
||||
): Promise<Entity> {
|
||||
await this.client.createDirectory(key, {
|
||||
recursive: true,
|
||||
});
|
||||
return await this._statFromRoot(key);
|
||||
}
|
||||
|
||||
async writeFile(
|
||||
key: string,
|
||||
content: ArrayBuffer,
|
||||
mtime: number,
|
||||
ctime: number
|
||||
): Promise<Entity> {
|
||||
if (key.endsWith("/")) {
|
||||
throw Error(`you should not call writeFile on ${key}`);
|
||||
}
|
||||
await this._init();
|
||||
const uploadFile = getWebdavPath(key, this.remoteBaseDir);
|
||||
return await this._writeFileFromRoot(uploadFile, content, mtime, ctime);
|
||||
}
|
||||
|
||||
async _writeFileFromRoot(
|
||||
key: string,
|
||||
content: ArrayBuffer,
|
||||
mtime: number,
|
||||
ctime: number
|
||||
): Promise<Entity> {
|
||||
await this.client.putFileContents(key, content, {
|
||||
overwrite: true,
|
||||
onUploadProgress: (progress: any) => {
|
||||
console.info(`Uploaded ${progress.loaded} bytes of ${progress.total}`);
|
||||
},
|
||||
});
|
||||
return await this._statFromRoot(key);
|
||||
}
|
||||
|
||||
async readFile(key: string): Promise<ArrayBuffer> {
|
||||
if (key.endsWith("/")) {
|
||||
throw Error(`you should not call readFile on ${key}`);
|
||||
}
|
||||
await this._init();
|
||||
const downloadFile = getWebdavPath(key, this.remoteBaseDir);
|
||||
return await this._readFileFromRoot(downloadFile);
|
||||
}
|
||||
|
||||
async _readFileFromRoot(key: string): Promise<ArrayBuffer> {
|
||||
const buff = (await this.client.getFileContents(key)) as BufferLike;
|
||||
if (buff instanceof ArrayBuffer) {
|
||||
return buff;
|
||||
} else if (buff instanceof Buffer) {
|
||||
return bufferToArrayBuffer(buff);
|
||||
}
|
||||
throw Error(`unexpected file content result with type ${typeof buff}`);
|
||||
}
|
||||
|
||||
async rm(key: string): Promise<void> {
|
||||
if (key === "/") {
|
||||
return;
|
||||
}
|
||||
await this._init();
|
||||
try {
|
||||
const remoteFileName = getWebdavPath(key, this.remoteBaseDir);
|
||||
await this.client.deleteFile(remoteFileName);
|
||||
// console.info(`delete ${remoteFileName} succeeded`);
|
||||
} catch (err) {
|
||||
console.error("some error while deleting");
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async checkConnect(callbackFunc?: any): Promise<boolean> {
|
||||
if (
|
||||
!(
|
||||
this.webdavConfig.address.startsWith("http://") ||
|
||||
this.webdavConfig.address.startsWith("https://")
|
||||
)
|
||||
) {
|
||||
const err =
|
||||
"Error: the url should start with http(s):// but it does not!";
|
||||
console.error(err);
|
||||
if (callbackFunc !== undefined) {
|
||||
callbackFunc(err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this._init();
|
||||
const results = await this._statFromRoot(`/${this.remoteBaseDir}/`);
|
||||
if (results === undefined) {
|
||||
const err = "results is undefined";
|
||||
console.error(err);
|
||||
callbackFunc?.(err);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
callbackFunc?.(err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getUserDisplayName(): Promise<string> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async revokeAuth() {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
}
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
import { cloneDeep, isEqual } from "lodash";
|
||||
import { DEFAULT_CONTENT_TYPE, Entity, WebdisConfig } from "./baseTypes";
|
||||
import { FakeFs } from "./fsAll";
|
||||
|
||||
export const DEFAULT_WEBDIS_CONFIG: WebdisConfig = {
|
||||
address: "",
|
||||
username: "",
|
||||
password: "",
|
||||
remoteBaseDir: "",
|
||||
};
|
||||
|
||||
const getWebdisPath = (fileOrFolderPath: string, remoteBaseDir: string) => {
|
||||
let key = fileOrFolderPath;
|
||||
if (fileOrFolderPath === "/" || fileOrFolderPath === "") {
|
||||
// special
|
||||
key = `${remoteBaseDir}`;
|
||||
} else if (fileOrFolderPath.startsWith("/")) {
|
||||
console.warn(
|
||||
`why the path ${fileOrFolderPath} starts with '/'? but we just go on.`
|
||||
);
|
||||
key = `${remoteBaseDir}${fileOrFolderPath}`;
|
||||
} else {
|
||||
key = `${remoteBaseDir}/${fileOrFolderPath}`;
|
||||
}
|
||||
return `rs:fs:v1:${encodeURIComponent(key)}`; // we should encode them!!!!
|
||||
};
|
||||
|
||||
export const getOrigPath = (fullKey: string, remoteBaseDir: string) => {
|
||||
const fullKeyDecoded = decodeURIComponent(fullKey);
|
||||
const prefix = `rs:fs:v1:${remoteBaseDir}/`;
|
||||
// console.debug(`prefix=${prefix}`);
|
||||
const suffix1 = ":meta";
|
||||
const suffix2 = ":content";
|
||||
if (!fullKeyDecoded.startsWith(prefix)) {
|
||||
throw Error(`you should not call getOrigEntity on ${fullKey}`);
|
||||
}
|
||||
let realKey = fullKeyDecoded.slice(prefix.length);
|
||||
// console.debug(`realKey=${realKey}`);
|
||||
if (realKey.endsWith(suffix1)) {
|
||||
realKey = realKey.slice(0, -suffix1.length);
|
||||
// console.debug(`realKey=${realKey}`);
|
||||
} else if (realKey.endsWith(suffix2)) {
|
||||
realKey = realKey.slice(0, -suffix2.length);
|
||||
// console.debug(`realKey=${realKey}`);
|
||||
}
|
||||
// console.debug(`fullKey=${fullKey}, realKey=${realKey}`);
|
||||
return realKey;
|
||||
};
|
||||
|
||||
export class FakeFsWebdis extends FakeFs {
|
||||
kind: "webdis";
|
||||
webdisConfig: WebdisConfig;
|
||||
remoteBaseDir: string;
|
||||
saveUpdatedConfigFunc: () => Promise<any>;
|
||||
|
||||
constructor(
|
||||
webdisConfig: WebdisConfig,
|
||||
vaultName: string,
|
||||
saveUpdatedConfigFunc: () => Promise<any>
|
||||
) {
|
||||
super();
|
||||
this.kind = "webdis";
|
||||
this.webdisConfig = webdisConfig;
|
||||
this.remoteBaseDir = this.webdisConfig.remoteBaseDir || vaultName || "";
|
||||
this.saveUpdatedConfigFunc = saveUpdatedConfigFunc;
|
||||
}
|
||||
|
||||
async _fetchCommand(
|
||||
method: "GET" | "POST" | "PUT",
|
||||
urlPath: string,
|
||||
content?: ArrayBuffer
|
||||
) {
|
||||
const address = this.webdisConfig.address;
|
||||
if (!address.startsWith(`https://`) && !address.startsWith(`http://`)) {
|
||||
throw Error(
|
||||
`your webdis server address should start with https:// or http://`
|
||||
);
|
||||
}
|
||||
if (address.endsWith("/")) {
|
||||
throw Error(`your webdis server should not ends with /`);
|
||||
}
|
||||
|
||||
if (content !== undefined && method !== "PUT") {
|
||||
throw Error(`you can only "POST" ArrayBuffer, not using other methods`);
|
||||
}
|
||||
|
||||
const fullUrl = `${address}/${urlPath}`;
|
||||
// console.debug(`fullUrl=${fullUrl}`)
|
||||
|
||||
const username = this.webdisConfig.username ?? "";
|
||||
const password = this.webdisConfig.password ?? "";
|
||||
if (username !== "" && password !== "") {
|
||||
return await fetch(fullUrl, {
|
||||
method: method,
|
||||
headers: {
|
||||
Authorization: "Basic " + btoa(username + ":" + password),
|
||||
},
|
||||
body: content,
|
||||
});
|
||||
} else if (username === "" && password === "") {
|
||||
return await fetch(fullUrl, {
|
||||
method: method,
|
||||
body: content,
|
||||
});
|
||||
} else {
|
||||
throw Error(
|
||||
`your username and password should both be empty or not empty!`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async walk(): Promise<Entity[]> {
|
||||
let cursor = "0";
|
||||
const res: Entity[] = [];
|
||||
do {
|
||||
const command = `SCAN/${cursor}/MATCH/rs:fs:v1:*:meta/COUNT/1000`;
|
||||
const rsp = (await (await this._fetchCommand("GET", command)).json())[
|
||||
"SCAN"
|
||||
];
|
||||
// console.debug(rsp);
|
||||
cursor = rsp[0];
|
||||
for (const fullKeyWithMeta of rsp[1]) {
|
||||
const realKey = getOrigPath(fullKeyWithMeta, this.remoteBaseDir);
|
||||
res.push(await this.stat(realKey));
|
||||
}
|
||||
} while (cursor !== "0");
|
||||
// console.debug(`walk res:`);
|
||||
// console.debug(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
async stat(key: string): Promise<Entity> {
|
||||
const fullKey = getWebdisPath(key, this.remoteBaseDir);
|
||||
return await this._statFromRaw(fullKey);
|
||||
}
|
||||
|
||||
async _statFromRaw(key: string): Promise<Entity> {
|
||||
// console.debug(`_statFromRaw on ${key}`);
|
||||
const command = `HGETALL/${key}:meta`;
|
||||
const rsp = (await (await this._fetchCommand("GET", command)).json())[
|
||||
"HGETALL"
|
||||
];
|
||||
// console.debug(`rsp: ${JSON.stringify(rsp, null, 2)}`);
|
||||
if (isEqual(rsp, {})) {
|
||||
// empty!
|
||||
throw Error(`key ${key} doesn't exist!`);
|
||||
}
|
||||
const realKey = getOrigPath(key, this.remoteBaseDir);
|
||||
return {
|
||||
key: realKey,
|
||||
keyRaw: realKey,
|
||||
mtimeCli: parseInt(rsp["mtime"]),
|
||||
mtimeSvr: parseInt(rsp["mtime"]),
|
||||
size: parseInt(rsp["size"]),
|
||||
sizeRaw: parseInt(rsp["size"]),
|
||||
};
|
||||
}
|
||||
|
||||
async mkdir(key: string, mtime?: number, ctime?: number): Promise<Entity> {
|
||||
let command = `HSET/${getWebdisPath(key, this.remoteBaseDir)}:meta/size/0`;
|
||||
if (mtime !== undefined && mtime !== 0) {
|
||||
command = `${command}/mtime/${mtime}`;
|
||||
}
|
||||
if (ctime !== undefined && ctime !== 0) {
|
||||
command = `${command}/ctime/${ctime}`;
|
||||
}
|
||||
const rsp = (await (await this._fetchCommand("GET", command)).json())[
|
||||
"HSET"
|
||||
];
|
||||
return await this.stat(key);
|
||||
}
|
||||
|
||||
async writeFile(
|
||||
key: string,
|
||||
content: ArrayBuffer,
|
||||
mtime: number,
|
||||
ctime: number
|
||||
): Promise<Entity> {
|
||||
const fullKey = getWebdisPath(key, this.remoteBaseDir);
|
||||
|
||||
// meta
|
||||
let command1 = `HSET/${fullKey}:meta/size/${content.byteLength}`;
|
||||
if (mtime !== undefined && mtime !== 0) {
|
||||
command1 = `${command1}/mtime/${mtime}`;
|
||||
}
|
||||
if (ctime !== undefined && ctime !== 0) {
|
||||
command1 = `${command1}/ctime/${ctime}`;
|
||||
}
|
||||
const rsp1 = (await (await this._fetchCommand("GET", command1)).json())[
|
||||
"HSET"
|
||||
];
|
||||
|
||||
// content
|
||||
const command2 = `SET/${fullKey}:content`;
|
||||
const rsp2 = (
|
||||
await (await this._fetchCommand("PUT", command2, content)).json()
|
||||
)["SET"];
|
||||
|
||||
// fetch meta
|
||||
return await this.stat(key);
|
||||
}
|
||||
|
||||
async readFile(key: string): Promise<ArrayBuffer> {
|
||||
const fullKey = getWebdisPath(key, this.remoteBaseDir);
|
||||
const command = `GET/${fullKey}:content?type=${DEFAULT_CONTENT_TYPE}`;
|
||||
const rsp = await (await this._fetchCommand("GET", command)).arrayBuffer();
|
||||
return rsp;
|
||||
}
|
||||
|
||||
async rm(key: string): Promise<void> {
|
||||
const fullKey = getWebdisPath(key, this.remoteBaseDir);
|
||||
const command = `DEL/${fullKey}:meta/${fullKey}:content`;
|
||||
const rsp = (await (await this._fetchCommand("PUT", command)).json())[
|
||||
"DEL"
|
||||
];
|
||||
}
|
||||
|
||||
async checkConnect(callbackFunc?: any): Promise<boolean> {
|
||||
try {
|
||||
const k = await (
|
||||
await this._fetchCommand("GET", "PING/helloworld")
|
||||
).json();
|
||||
return isEqual(k, { PING: "helloworld" });
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
callbackFunc?.(err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getUserDisplayName(): Promise<string> {
|
||||
return this.webdisConfig.username || "<no usernme>";
|
||||
}
|
||||
|
||||
async revokeAuth(): Promise<any> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
}
|
||||
+30
-4
@@ -5,16 +5,28 @@ import {
|
||||
COMMAND_URI,
|
||||
UriParams,
|
||||
RemotelySavePluginSettings,
|
||||
QRExportType,
|
||||
} from "./baseTypes";
|
||||
import { getShrinkedSettings } from "./fsOnedrive";
|
||||
|
||||
export const exportQrCodeUri = async (
|
||||
settings: RemotelySavePluginSettings,
|
||||
currentVaultName: string,
|
||||
pluginVersion: string
|
||||
pluginVersion: string,
|
||||
exportFields: QRExportType
|
||||
) => {
|
||||
const settings2: Partial<RemotelySavePluginSettings> = cloneDeep(settings);
|
||||
delete settings2.dropbox;
|
||||
delete settings2.onedrive;
|
||||
let settings2: Partial<RemotelySavePluginSettings> = {};
|
||||
|
||||
if (exportFields === "all_but_oauth2") {
|
||||
settings2 = cloneDeep(settings);
|
||||
delete settings2.dropbox;
|
||||
delete settings2.onedrive;
|
||||
} else if (exportFields === "dropbox") {
|
||||
settings2 = { dropbox: cloneDeep(settings.dropbox) };
|
||||
} else if (exportFields === "onedrive") {
|
||||
settings2 = { onedrive: getShrinkedSettings(settings.onedrive) };
|
||||
}
|
||||
|
||||
delete settings2.vaultRandomID;
|
||||
const data = encodeURIComponent(JSON.stringify(settings2));
|
||||
const vault = encodeURIComponent(currentVaultName);
|
||||
@@ -34,6 +46,20 @@ export interface ProcessQrCodeResultType {
|
||||
result?: RemotelySavePluginSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* we also support directly parse the uri, instead of relying on web browser
|
||||
* @param input
|
||||
*/
|
||||
export const parseUriByHand = (input: string) => {
|
||||
if (!input.startsWith("obsidian://remotely-save?func=settings&")) {
|
||||
throw Error(`not valid string`);
|
||||
}
|
||||
|
||||
const k = new URL(input);
|
||||
const output = Object.fromEntries(k.searchParams);
|
||||
return output;
|
||||
};
|
||||
|
||||
export const importQrCodeUri = (
|
||||
inputParams: any,
|
||||
currentVaultName: string
|
||||
|
||||
+53
-23
@@ -5,13 +5,13 @@
|
||||
"goback": "Go Back",
|
||||
"submit": "Submit",
|
||||
"sometext": "Here are some texts.",
|
||||
"syncrun_alreadyrunning": "{{pluginName}} already running in stage {{syncStatus}}!",
|
||||
"syncrun_alreadyrunning": "New command {{newTriggerSource}} stops because {{pluginName}} is already running in stage {{syncStatus}}!",
|
||||
"syncrun_syncingribbon": "{{pluginName}}: syncing from {{triggerSource}}",
|
||||
"syncrun_step0": "0/8 Remotely Save is running in dry mode, thus not actual file changes would happen.",
|
||||
"syncrun_step1": "1/8 Remotely Save is preparing ({{serviceType}})",
|
||||
"syncrun_step2": "2/8 Starting to fetch remote meta data.",
|
||||
"syncrun_step3": "3/8 Checking password correct or not.",
|
||||
"syncrun_step2": "2/8 Starting to fetch remote meta data and check password.",
|
||||
"syncrun_passworderr": "Something goes wrong while checking password.",
|
||||
"syncrun_step3": "3/8 Startting to process remote data.",
|
||||
"syncrun_step4": "4/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.",
|
||||
@@ -24,7 +24,7 @@
|
||||
"syncrun_shortstep2": "2/2 Remotely Save finished!",
|
||||
"syncrun_abort": "{{manifestID}}-{{theDate}}: abort sync, triggerSource={{triggerSource}}, error while {{syncStatus}}",
|
||||
"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_saveqr": "New not-oauth2 settings for {{manifestName}} is saved. Reopen the plugin settings to make it effective.",
|
||||
"protocol_saveqr": "New settings for {{manifestName}} is imported and saved. Reopen the plugin settings to make it effective.",
|
||||
"protocol_callbacknotsupported": "Your uri calls a callback that's not supported yet: {{params}}",
|
||||
"protocol_dropbox_connecting": "Connecting to Dropbox...\nPlease DO NOT close this modal.",
|
||||
"protocol_dropbox_connect_succ": "Good! We've connected to Dropbox as user {{username}}!",
|
||||
@@ -38,7 +38,10 @@
|
||||
"protocol_onedrive_connect_unknown": "Do not know how to deal with the callback: {{params}}",
|
||||
"command_startsync": "start sync",
|
||||
"command_drynrun": "start sync (dry run only)",
|
||||
"command_exportsyncplans_json": "export sync plans in json format",
|
||||
"command_exportsyncplans_1": "export sync plans (latest 1)",
|
||||
"command_exportsyncplans_5": "export sync plans (latest 5)",
|
||||
"command_exportsyncplans_all": "export sync plans (all)",
|
||||
|
||||
"command_exportlogsindb": "export logs saved in db",
|
||||
|
||||
"statusbar_time_years": "Synced {{time}} years ago",
|
||||
@@ -47,14 +50,13 @@
|
||||
"statusbar_time_days": "Synced {{time}} days ago",
|
||||
"statusbar_time_hours": "Synced {{time}} hours ago",
|
||||
"statusbar_time_minutes": "Synced {{time}} minutes ago",
|
||||
"statusbar_time_lessminute": "Synced less than a minute ago",
|
||||
"statusbar_time_lessminute": "Synced last minute ago",
|
||||
"statusbar_lastsync": "Synced {{time}} ago",
|
||||
"statusbar_syncing": "Syncing...",
|
||||
"statusbar_now": "Synced just now",
|
||||
"statusbar_lastsync_label": "Last successful Sync on {{date}}",
|
||||
"statusbar_lastsync_never": "Never Synced",
|
||||
"statusbar_lastsync_never_label": "Never Synced before",
|
||||
|
||||
"modal_password_title": "Hold on and PLEASE READ ON...",
|
||||
"modal_password_shortdesc": "If the field is not empty, files would be encrypted locally before being uploaded.\nIf the field is empty, then files would be uploaded without encryption.",
|
||||
"modal_password_attn1": "Attention 1/5: The vault name is NOT encrypted. The plugin creates a folder with the vault name on some remote services.",
|
||||
@@ -90,6 +92,7 @@
|
||||
"modal_dropboxauth_maualinput_conn_succ_revoke": "You've connected as user {{username}}. If you want to disconnect, click this button.",
|
||||
"modal_dropboxauth_maualinput_conn_fail": "Something goes wrong while connecting to Dropbox.",
|
||||
"modal_onedriveauth_shortdesc": "Currently only OneDrive for personal is supported. OneDrive for Business is NOT supported (yet).\nVisit the address in a browser, and follow the steps.\nFinally you should be redirected to Obsidian.",
|
||||
"modal_onedriveauth_shortdesc_linux": "It seems that you are using Obsidian on Linux, and you might not be able to jump back here properly. Please consider <a href=\"https://github.com/remotely-save/remotely-save/issues/415\">using</a> the flatpack version of Obsidian, or creating an <a href=\"https://github.com/remotely-save/remotely-save/blob/master/docs/linux.md\"><code>obsidian.desktop</code> file</a>.",
|
||||
"modal_onedriveauth_copybutton": "Click to copy the auth url",
|
||||
"modal_onedriveauth_copynotice": "The auth url is copied to the clipboard!",
|
||||
"modal_onedriverevokeauth_step1": "Step 1: Go to the following address, click the \"Edit\" button for the plugin, then click \"Remove these permissions\" button on the page.",
|
||||
@@ -102,7 +105,7 @@
|
||||
"modal_syncconfig_attn": "Attention 1/2: This only syncs (copies) the whole Obsidian config dir, not other startting-with-dot folders or files. Except for ignoring folders .git and node_modules, it also doesn't understand the meaning of sub-files and sub-folders inside the config dir.\nAttention 2/2: After the config dir is synced, plugins settings might be corrupted, and Obsidian might need to be restarted to load the new settings.\nIf you are agreed to take your own risk, please click the following second confirm button.",
|
||||
"modal_syncconfig_secondconfirm": "The Second Confirm To Enable.",
|
||||
"modal_syncconfig_notice": "You've enabled syncing config folder!",
|
||||
"modal_qr_shortdesc": "This exports not-oauth2 settings. (It means that Dropbox, OneDrive info are NOT exported.)\nYou can use another device to scan this qrcode.\nOr, you can click the button to copy the special url.",
|
||||
"modal_qr_shortdesc": "This exports (partial) settings.\nYou can use another device to scan this qrcode.\nOr, you can click the button to copy the special uri and paste it into another device's web browser or Remotely Save Import Setting.",
|
||||
"modal_qr_button": "Click to copy the special URI",
|
||||
"modal_qr_button_notice": "The special uri is copied to the clipboard!",
|
||||
"modal_sizesconflict_title": "Remotely Save: Some conflict were found while skipping large files",
|
||||
@@ -130,17 +133,13 @@
|
||||
"settings_runoncestartup_1sec": "sync once after 1 second of start up",
|
||||
"settings_runoncestartup_10sec": "sync once after 10 seconds of start up",
|
||||
"settings_runoncestartup_30sec": "sync once after 30 seconds of start up",
|
||||
"settings_saverun": "Sync On Save (experimental)",
|
||||
"settings_saverun_desc": "A sync will be triggered if a file save action happened within a few seconds. Please pay attention that syncing is potentially a heavy action and battery may be impacted. (May need to reload the plugin or restart Obsidian after changing)",
|
||||
"settings_saverun_notset": "(not set)",
|
||||
"settings_saverun_1sec": "check every 1 second",
|
||||
"settings_saverun_5sec": "check every 5 seconds",
|
||||
"settings_saverun_10sec": "check every 10 seconds (recommended)",
|
||||
"settings_saverun_1min": "check every 1 minute",
|
||||
"settings_synconsave": "Sync On Save (experimental)",
|
||||
"settings_synconsave_desc": "If you change your file, the plugin tries to trigger a sync.",
|
||||
"settings_synconsave_disable": "Disable (default)",
|
||||
"settings_synconsave_enable": "Enable",
|
||||
"settings_skiplargefiles": "Skip Large Files",
|
||||
"settings_skiplargefiles_desc": "Skip files with sizes larger than the threshold. Here 1 MB = 10^6 bytes.",
|
||||
"settings_skiplargefiles_notset": "(not set)",
|
||||
|
||||
"settings_ignorepaths": "Regex Of Paths To Ignore",
|
||||
"settings_ignorepaths_desc": "Regex of paths of folders or files to ignore. One regex per line. The path is relative to the vault root without leading slash.",
|
||||
"settings_enablestatusbar_info": "Show Last Successful Sync In Status Bar",
|
||||
@@ -150,7 +149,6 @@
|
||||
"settings_resetstatusbar_time_desc": "Reset last successful sync time.",
|
||||
"settings_resetstatusbar_button": "Reset",
|
||||
"settings_resetstatusbar_notice": "Reset done!",
|
||||
|
||||
"settings_checkonnectivity": "Check Connectivity",
|
||||
"settings_checkonnectivity_desc": "Check connectivity.",
|
||||
"settings_checkonnectivity_button": "Check",
|
||||
@@ -183,6 +181,12 @@
|
||||
"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_desc": "Whether to force path-style URLs for S3 objects (e.g., https://s3.amazonaws.com/*/ instead of https://*.s3.amazonaws.com/).",
|
||||
"settings_s3_reverse_proxy_no_sign_url": "S3 Reverse Proxy (No Sign) Url (experimental)",
|
||||
"settings_s3_reverse_proxy_no_sign_url_desc": "S3 reverse proxy url without signature. This is useful if you use a revers proxy but do not change the original credential signature. No http(s):// prefix. Leave it blank if you don't know what it is.",
|
||||
"settings_s3_generatefolderobject": "Generate Folder Object Or Not",
|
||||
"settings_s3_generatefolderobject_desc": "S3 doesn't have \"real\" folder. If you set \"Generate\" here (or use old version), the plugin will upload a zero-byte object endding with \"/\" to represent the folder. In the new version, the plugin skips generating folder object by default.",
|
||||
"settings_s3_generatefolderobject_notgenerate": "Not generate (default)",
|
||||
"settings_s3_generatefolderobject_generate": "Generate",
|
||||
"settings_s3_connect_succ": "Great! The bucket can be accessed.",
|
||||
"settings_s3_connect_fail": "The S3 bucket cannot be reached.",
|
||||
"settings_dropbox": "Remote For Dropbox",
|
||||
@@ -236,12 +240,25 @@
|
||||
"settings_webdav_connect_succ": "Great! The webdav server can be accessed.",
|
||||
"settings_webdav_connect_fail": "The webdav server cannot be reached (possible to be any of address/username/password/authtype errors).",
|
||||
"settings_webdav_connect_fail_withcors": "The webdav server cannot be reached (possible to be any of address/username/password/authtype/CORS errors).",
|
||||
"settings_webdis": "Remote For Webdis",
|
||||
"settings_webdis_disclaimer1": "Disclaimer: This app is NOT an official Redis® Ltd / Redis® OSS / Webdis product. Redis is a registered trademark of Redis Ltd.",
|
||||
"settings_webdis_disclaimer2": "Disclaimer: The information is stored locally. Other malicious/harmful/faulty plugins may read the info. If you see any unintentional access to your Webdis server, please immediately change the username and password.",
|
||||
"settings_webdis_folder": "We will store the value with keys prefixed by :{{remoteBaseDir}} on your server.",
|
||||
"settings_webdis_addr": "Server Address",
|
||||
"settings_webdis_addr_desc": "Server address.",
|
||||
"settings_webdis_user": "Username",
|
||||
"settings_webdis_user_desc": "Username. Attention: the username and other info are saved locally.",
|
||||
"settings_webdis_password": "Password",
|
||||
"settings_webdis_password_desc": "Password. Attention: the password and other info are saved locally.",
|
||||
"settings_webdis_connect_succ": "Great! The Webdis server can be accessed.",
|
||||
"settings_webdis_connect_fail": "The Webdis server cannot be reached (possible to be any of address/username/password errors).",
|
||||
"settings_chooseservice": "Choose A Remote Service",
|
||||
"settings_chooseservice_desc": "Start here. What service are you connecting to? S3, Dropbox, Webdav, or OneDrive for personal?",
|
||||
"settings_chooseservice_desc": "Start here. What service are you connecting to? S3, Dropbox, Webdav, OneDrive for personal, or Webdis?",
|
||||
"settings_chooseservice_s3": "S3 or compatible",
|
||||
"settings_chooseservice_dropbox": "Dropbox",
|
||||
"settings_chooseservice_webdav": "Webdav",
|
||||
"settings_chooseservice_onedrive": "OneDrive for personal",
|
||||
"settings_chooseservice_webdis": "Webdis (HTTP for Redis®)",
|
||||
"settings_adv": "Advanced Settings",
|
||||
"settings_concurrency": "Concurrency",
|
||||
"settings_concurrency_desc": "How many files do you want to download or upload in parallel at most? By default it's set to 5. If you meet any problems such as rate limit, you can reduce the concurrency to a lower value.",
|
||||
@@ -275,10 +292,14 @@
|
||||
"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_export": "Export",
|
||||
"settings_export_desc": "Export not-oauth2 settings by generating a qrcode.",
|
||||
"settings_export_desc_button": "Get QR Code",
|
||||
"settings_export_desc": "Export settings by generating a QR code or URI.",
|
||||
"settings_export_all_but_oauth2_button": "Export Non-Oauth2 Part",
|
||||
"settings_export_dropbox_button": "Export Dropbox Part",
|
||||
"settings_export_onedrive_button": "Export OneDrive Part",
|
||||
"settings_import": "Import",
|
||||
"settings_import_desc": "You should open a camera or scan-qrcode app, to manually scan the QR code.",
|
||||
"settings_import_desc": "Paste the exported URI into here and click \"Import\". Or, you can open a camera or scan-qrcode app to scan the QR code.",
|
||||
"settings_import_button": "Import",
|
||||
"settings_import_error_notice": "Your URI string is empty or not correct!",
|
||||
"settings_debug": "Debug",
|
||||
"settings_debuglevel": "Alter Notice Level",
|
||||
"settings_debuglevel_desc": "By default the notice level is \"info\". You can change to \"debug\" to get verbose information while syncing.",
|
||||
@@ -292,7 +313,9 @@
|
||||
"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_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",
|
||||
"settings_syncplans_button_1": "Export latest 1",
|
||||
"settings_syncplans_button_5": "Export latest 5",
|
||||
"settings_syncplans_button_all": "Export All",
|
||||
"settings_syncplans_notice": "Sync plans history exported.",
|
||||
"settings_delsyncplans": "Delete Sync Plans History In DB",
|
||||
"settings_delsyncplans_desc": "Delete sync plans history in DB.",
|
||||
@@ -302,6 +325,10 @@
|
||||
"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_delprevsync_button": "Delete Prev Sync Details",
|
||||
"settings_delprevsync_notice": "Previous sync history (in local DB) deleted",
|
||||
"settings_profiler_results": "Export Profiler Results",
|
||||
"settings_profiler_results_desc": "The plugin records the time cost of each steps. Here you can export them to know which step is slow.",
|
||||
"settings_profiler_results_notice": "Profiler results exported.",
|
||||
"settings_profiler_results_button_all": "Export All",
|
||||
"settings_outputbasepathvaultid": "Output Vault Base Path And Randomly Assigned ID",
|
||||
"settings_outputbasepathvaultid_desc": "For debugging purposes.",
|
||||
"settings_outputbasepathvaultid_button": "Output",
|
||||
@@ -309,10 +336,13 @@
|
||||
"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_notice": "Local internal cache/databases deleted. Please manually reload the plugin.",
|
||||
|
||||
"syncalgov3_title": "Remotely Save has HUGE updates on the sync algorithm",
|
||||
"syncalgov3_texts": "Welcome to use Remotely Save!\nFrom this version, a new algorithm has been developed:\n<ul><li>More robust deletion sync,</li><li>minimal conflict handling,</li><li>no meta data uploaded any more,</li><li>deletion / modification protection,</li><li>backup mode</li><li>new encryption method</li><li>...</li></ul>\nStay tune for more! A full introduction is in the <a href='https://github.com/remotely-save/remotely-save/tree/master/docs/sync_algorithm/v3/intro.md'>doc website</a>.\nIf you agree to use this, please read and check two checkboxes then click the \"Agree\" button, and enjoy the plugin!\nIf you do not agree, please click the \"Do Not Agree\" button, the plugin will unload itself.\nAlso, please consider <a href='https://github.com/remotely-save/remotely-save'>visit the GitHub repo and star ⭐ it</a>! Or even <a href='https://github.com/remotely-save/donation'>buy me a coffee</a>. Your support is very important to me! Thanks!",
|
||||
"syncalgov3_checkbox_manual_backup": "I will backup my vault manually firstly.",
|
||||
"syncalgov3_checkbox_requiremultidevupdate": "I understand I need to update the plugin ACROSS ALL DEVICES to make them work properly.",
|
||||
"syncalgov3_button_agree": "Agree",
|
||||
"syncalgov3_button_disagree": "Do Not Agree"
|
||||
"syncalgov3_button_disagree": "Do Not Agree",
|
||||
|
||||
"menu_check_file_stat": "Check file stats"
|
||||
}
|
||||
|
||||
+52
-22
@@ -5,13 +5,13 @@
|
||||
"goback": "返回",
|
||||
"submit": "提交",
|
||||
"sometext": "这里有一段文字。",
|
||||
"syncrun_alreadyrunning": "{{pluginName}} 正处于此阶段:{{syncStatus}}!",
|
||||
"syncrun_alreadyrunning": "{{pluginName}} 正处于此阶段:{{syncStatus}}!中断触发 {{newTriggerSource}}。",
|
||||
"syncrun_syncingribbon": "{{pluginName}}:正在由 {{triggerSource}} 触发运行",
|
||||
"syncrun_step0": "0/8 Remotely Save 在空跑(dry run)模式,不会发生实际的文件交换。",
|
||||
"syncrun_step1": "1/8 Remotely Save 准备同步({{serviceType}})",
|
||||
"syncrun_step2": "2/8 正在获取远端的元数据。",
|
||||
"syncrun_step3": "3/8 正在检查密码正确与否。",
|
||||
"syncrun_step2": "2/8 正在获取远端的元数据和检查密码。",
|
||||
"syncrun_passworderr": "检查密码时候出错。",
|
||||
"syncrun_step3": "3/8 正在处理远端的元数据。",
|
||||
"syncrun_step4": "4/8 正在获取本地的元数据。",
|
||||
"syncrun_step5": "5/8 正在获取本地上一次同步的元数据。",
|
||||
"syncrun_step6": "6/8 正在生成同步计划。",
|
||||
@@ -24,7 +24,7 @@
|
||||
"syncrun_shortstep2": "2/2 Remotely Save 已完成同步!",
|
||||
"syncrun_abort": "{{manifestID}}-{{theDate}}:中断同步,同步来源={{triggerSource}},出错阶段={{syncStatus}}",
|
||||
"syncrun_abort_protectmodifypercentage": "中断同步!您设置了不允许 >= {{protectModifyPercentage}}% 的变更,但是现在 {{realModifyDeleteCount}}/{{allFilesCount}}={{percent}}% 的文件会被修改或删除!如果您确认这次同步是您想要的,那么请在设置里修改允许比例。",
|
||||
"protocol_saveqr": " {{manifestName}} 新的非 oauth2 设置保存完成。请重启插件设置页使之生效。",
|
||||
"protocol_saveqr": " {{manifestName}} 的新设置导入完成。请重启插件设置页使之生效。",
|
||||
"protocol_callbacknotsupported": "您的 uri callback 暂不支持: {{params}}",
|
||||
"protocol_dropbox_connecting": "正在连接 Dropbox……\n请不要关闭此弹窗。",
|
||||
"protocol_dropbox_connect_succ": "好!我们作为用户 {{username}} 连接上了 Dropbox!",
|
||||
@@ -39,6 +39,9 @@
|
||||
"command_startsync": "开始同步(start sync)",
|
||||
"command_drynrun": "开始同步(空跑模式)(start sync (dry run only))",
|
||||
"command_exportsyncplans_json": "导出同步计划为 json 格式(export sync plans in json format)",
|
||||
"command_exportsyncplans_1": "导出同步计划(最近 1 次)(export sync plans (latest 1))",
|
||||
"command_exportsyncplans_5": "导出同步计划(最近 5 次)(export sync plans (latest 5))",
|
||||
"command_exportsyncplans_all": "导出同步计划(所有)(export sync plans (all))",
|
||||
"command_exportlogsindb": "从数据库导出终端日志(export logs saved in db)",
|
||||
|
||||
"statusbar_time_years": "{{time}} 年前同步",
|
||||
@@ -54,7 +57,6 @@
|
||||
"statusbar_lastsync_label": "上一次同步于:{{date}}",
|
||||
"statusbar_lastsync_never": "没触发过同步",
|
||||
"statusbar_lastsync_never_label": "没触发过同步",
|
||||
|
||||
"modal_password_title": "稍等一下,请阅读下文:",
|
||||
"modal_password_shortdesc": "如果密码不是空的,那么文件会在上传之前,在本地先用此密码加密。\n如果密码是空的,那么文件会被非加密地上传。",
|
||||
"modal_password_attn1": "注意 1/5:库(Vault)名字是不会加密的!本插件会在一些远程存储里创建一个和库名字有着同名的文件夹。",
|
||||
@@ -90,6 +92,7 @@
|
||||
"modal_dropboxauth_maualinput_conn_succ_revoke": "您已作为用户 {{username}} 连接到 Dropbox。如果您想断开连接,点击此按钮。",
|
||||
"modal_dropboxauth_maualinput_conn_fail": "连接 Dropbox 途中出错了。",
|
||||
"modal_onedriveauth_shortdesc": "现在只支持个人版 OneDrive,(暂)不支持企业版。\n在浏览器中访问以下地址,然后按照网页提示操作。\n到了最后,您应该会被自动重定向回来 Obsidian。",
|
||||
"modal_onedriveauth_shortdesc_linux": "您正在用 Linux,有可能无法跳转回来。请考虑<a href=\"https://github.com/remotely-save/remotely-save/issues/415\">使用</a> flatpack 版本的 Obsidian,或创建 <a href=\"https://github.com/remotely-save/remotely-save/blob/master/docs/linux.md\"><code>obsidian.desktop</code> 文件</a>。",
|
||||
"modal_onedriveauth_copybutton": "点击此按钮从而复制鉴权 url",
|
||||
"modal_onedriveauth_copynotice": "鉴权 url 已复制到剪贴板!",
|
||||
"modal_onedriverevokeauth_step1": "第 1 步:用浏览器打开以下地址,点击本插件对应的“Edit”按钮,点击“Remove these permissions”按钮。",
|
||||
@@ -102,7 +105,7 @@
|
||||
"modal_syncconfig_attn": "注意 1/2:此设置只同步(复制)整个 Obsidian 的配置文件夹,但是不会同步其它 . 开头的文件夹或文件。除了会忽略 .git 和 node_modules 文件夹之外,它也并不理解配置文件夹的里各个子文件或子文件夹的含义。\n注意 2/2:配置文件夹被同步之后,各插件的设置或许会出错,且 Obsidian 或许需要重启来重载各插件的新配置。\n如果您同意自行承受以上风险,您可以点击以下再次确认按钮。",
|
||||
"modal_syncconfig_secondconfirm": "再次确认开启",
|
||||
"modal_syncconfig_notice": "您已开启配置文件夹的同步!",
|
||||
"modal_qr_shortdesc": "这里可导出非 oauth2 设置。(意味着:Dropbox 和 OneDrive 信息不会被导出。)\n您可以使用另一个设备来扫描此 QR 码。\n又或者,您可以点击以下按钮复制此特殊 URI。",
|
||||
"modal_qr_shortdesc": "这里可导出(部分)设置。\n您可以使用另一个设备来扫描此 QR 码。\n又或者,您可以点击以下按钮复制此特殊 URI,然后粘贴到另一台设备的网络浏览器或 Remotely Save 设置里的导入部分。",
|
||||
"modal_qr_button": "点击此按钮复制特殊 URI",
|
||||
"modal_qr_button_notice": "特殊 URI 已被复制到剪贴板!",
|
||||
"modal_sizesconflict_title": "Remotely Save:跳过大文件的时候出现了一些冲突",
|
||||
@@ -129,17 +132,13 @@
|
||||
"settings_runoncestartup_1sec": "启动后第 1 秒运行一次",
|
||||
"settings_runoncestartup_10sec": "启动后第 10 秒运行一次",
|
||||
"settings_runoncestartup_30sec": "启动后第 30 秒运行一次",
|
||||
"settings_saverun": "保存时同步(实验性质)",
|
||||
"settings_saverun_desc": "插件如果检查到当前文件在最近一段时间有修改保存过,则尝试同步。请注意,同步是一个很重的操作,因此会影响到耗电量。(修改设置后可能需要重载插件或重启。)",
|
||||
"settings_saverun_notset": "(不设置)",
|
||||
"settings_saverun_1sec": "隔 1 秒检查一次",
|
||||
"settings_saverun_5sec": "隔 5 秒检查一次",
|
||||
"settings_saverun_10sec": "隔 10 秒检查一次(推荐)",
|
||||
"settings_saverun_1min": "隔 1 分钟检查一次",
|
||||
"settings_synconsave": "保存时同步(实验性质)",
|
||||
"settings_synconsave_desc": "插件如果检查到当前文件在最近一段时间有修改保存过,则尝试同步。请注意,同步是一个很重的操作,因此会影响到耗电量。(修改设置后可能需要重载插件或重启。)",
|
||||
"settings_synconsave_disable": "关闭(默认)",
|
||||
"settings_synconsave_enable": "开启",
|
||||
"settings_skiplargefiles": "跳过大文件",
|
||||
"settings_skiplargefiles_desc": "跳过大于某一个阈值的文件。这里 1 MB = 10^6 bytes。",
|
||||
"settings_skiplargefiles_notset": "(不设置)",
|
||||
|
||||
"settings_ignorepaths": "忽略的文件或文件夹的正则表达式",
|
||||
"settings_ignorepaths_desc": "忽略的文件或文件夹的正则表达式。每行一条。路径是相对于库(Vault)根目录的,没有前置 / 符号。",
|
||||
"settings_enablestatusbar_info": "在状态栏显示上一次成功的同步",
|
||||
@@ -149,7 +148,6 @@
|
||||
"settings_resetstatusbar_time_desc": "重设上一次成功同步的时间记录。",
|
||||
"settings_resetstatusbar_button": "重设",
|
||||
"settings_resetstatusbar_notice": "重设完毕!",
|
||||
|
||||
"settings_checkonnectivity": "检查可否连接",
|
||||
"settings_checkonnectivity_desc": "检查可否连接。",
|
||||
"settings_checkonnectivity_button": "检查",
|
||||
@@ -182,6 +180,12 @@
|
||||
"settings_s3_accuratemtime_desc": "读取(已上传的)准确的文件修改时间,有助于同步算法更加准确和稳定。但是它也会导致额外的 api 请求、时间、金钱花费。",
|
||||
"settings_s3_urlstyle": "S3 URL style",
|
||||
"settings_s3_urlstyle_desc": "是否对 S3 对象强制使用 path style URL(例如使用 https://s3.amazonaws.com/*/ 而不是 https://*.s3.amazonaws.com/)。",
|
||||
"settings_s3_reverse_proxy_no_sign_url": "S3 反向代理(不签名)地址(实验性质)",
|
||||
"settings_s3_reverse_proxy_no_sign_url_desc": "不会参与到签名的 S3 反向代理地址。如果您有一个反向代理,但是不想修改原始鉴权签名,这里就可以填写。没有 http(s):// 前缀。如果您不知道这是什么,留空即可。",
|
||||
"settings_s3_generatefolderobject": "是否生成文件夹 Object",
|
||||
"settings_s3_generatefolderobject_desc": "S3 不存在“真正”的文件夹。如果您设置了“生成”(或用了旧版本),那么插件会上传 0 字节的以“/”结尾的 Object 来代表文件夹。新版本插件会默认跳过生成这种文件夹 Object。",
|
||||
"settings_s3_generatefolderobject_notgenerate": "不生成(默认)",
|
||||
"settings_s3_generatefolderobject_generate": "生成",
|
||||
"settings_s3_connect_succ": "很好!可以访问到对应存储桶。",
|
||||
"settings_s3_connect_fail": "无法访问到对应存储桶。",
|
||||
"settings_dropbox": "Dropbox 设置",
|
||||
@@ -235,12 +239,25 @@
|
||||
"settings_webdav_connect_succ": "很好!可以连接上 Webdav 服务器。",
|
||||
"settings_webdav_connect_fail": "无法连接上 Webdav 服务器。(可能是地址/账号/密码/鉴权类型等错误。)",
|
||||
"settings_webdav_connect_fail_withcors": "无法连接上 Webdav 服务器。(可能是地址/账号/密码/鉴权类型/CORS 等错误。)",
|
||||
"settings_webdis": "Webdis 设置",
|
||||
"settings_webdis_disclaimer1": "声明:此插件不是 Redis® Ltd 或 Redis® 软件或 Wedis 的官方产品。Redis 是 Redis Ltd 的注册商标。",
|
||||
"settings_webdis_disclaimer2": "声明:您所输入的信息存储于本地。其它有害的或者出错的插件,是有可能读取到这些信息的。如果您发现了 Webdis 服务器有不符合预期的访问,请立刻修改用户名和密码。",
|
||||
"settings_webdis_folder": "我们会在您的服务器上创建带有此前缀的 key 并在里面同步::{{remoteBaseDir}}。",
|
||||
"settings_webdis_addr": "服务器地址",
|
||||
"settings_webdis_addr_desc": "服务器地址",
|
||||
"settings_webdis_user": "用户名",
|
||||
"settings_webdis_user_desc": "用户名。注意:用户名和其它信息都会保存在本地。",
|
||||
"settings_webdis_password": "密码",
|
||||
"settings_webdis_password_desc": "密码。注意:密码和其它信息都会保存在本地。",
|
||||
"settings_webdis_connect_succ": "很好!可以连接上 Webdis 服务器。",
|
||||
"settings_webdis_connect_fail": "无法连接上 Webdis 服务器。(可能是地址/账号/密码/鉴权类型等错误。)",
|
||||
"settings_chooseservice": "选择远程服务",
|
||||
"settings_chooseservice_desc": "从这里开始设置。您想连接到哪一个服务?S3、Dropbox、Webdav、OneDrive(个人版)?",
|
||||
"settings_chooseservice_desc": "从这里开始设置。您想连接到哪一个服务?S3、Dropbox、Webdav、OneDrive(个人版)、Webdis?",
|
||||
"settings_chooseservice_s3": "S3 或兼容 S3 的服务",
|
||||
"settings_chooseservice_dropbox": "Dropbox",
|
||||
"settings_chooseservice_webdav": "Webdav",
|
||||
"settings_chooseservice_onedrive": "OneDrive(个人版)",
|
||||
"settings_chooseservice_webdis": "Webdis (an HTTP interface for Redis)",
|
||||
"settings_adv": "进阶设置",
|
||||
"settings_concurrency": "并行度",
|
||||
"settings_concurrency_desc": "您希望同时最多有多少个文件被上传和下载?默认值是 5。如果您遇到了一些问题(如访问频率限制),您可以减少并行度。",
|
||||
@@ -257,7 +274,7 @@
|
||||
"settings_conflictaction_keep_newer": "保留最后修改的版本(默认)",
|
||||
"settings_conflictaction_keep_larger": "保留文件体积较大的版本",
|
||||
"settings_cleanemptyfolder": "处理空文件夹",
|
||||
"settings_cleanemptyfolder_desc": "同步算法主要是针对文件处理的,您要要手动指定空文件夹如何处理。",
|
||||
"settings_cleanemptyfolder_desc": "同步算法主要是针对文件处理的,您需要手动指定空文件夹如何处理。",
|
||||
"settings_cleanemptyfolder_skip": "跳过处理空文件夹(默认)",
|
||||
"settings_cleanemptyfolder_clean_both": "删除本地和服务器的空文件夹",
|
||||
"settings_protectmodifypercentage": "如果修改超过百分比则中止同步",
|
||||
@@ -274,10 +291,14 @@
|
||||
"settings_enablemobilestatusbar_desc": "Obsidian 手机版默认隐藏了状态栏。有些用户希望展示它。这里提供了设置选项。",
|
||||
"settings_importexport": "导入导出部分设置",
|
||||
"settings_export": "导出",
|
||||
"settings_export_desc": "用 QR 码导出非 oauth2 的设置信息。",
|
||||
"settings_export_desc_button": "生成 QR 码",
|
||||
"settings_export_desc": "用 QR 码或 URI 导出设置信息。",
|
||||
"settings_export_all_but_oauth2_button": "导出非 Oauth2 部分",
|
||||
"settings_export_dropbox_button": "导出 Dropbox 部分",
|
||||
"settings_export_onedrive_button": "导出 OneDrive 部分",
|
||||
"settings_import": "导入",
|
||||
"settings_import_desc": "您需要使用系统拍摄 app 或者扫描 QR 码的app,来扫描对应的 QR 码。",
|
||||
"settings_import_desc": "粘贴之前导出的 URI 到这里然后点击“导入”。或,使用拍摄 app 或者扫描 QR 码的 app,来扫描对应的 QR 码。",
|
||||
"settings_import_button": "导入",
|
||||
"settings_import_error_notice": "您输入的 URI 是空的或者不准确的!",
|
||||
"settings_debug": "调试",
|
||||
"settings_debuglevel": "修改同步提示信息",
|
||||
"settings_debuglevel_desc": "默认值为 \"info\"。您可以改为 \"debug\" 从而在同步时候里获取更多信息。",
|
||||
@@ -291,7 +312,9 @@
|
||||
"settings_viewconsolelog_desc": "电脑上,输入“ctrl+shift+i”或“cmd+shift+i”来查看终端输出。手机上,安装第三方插件 <a href='https://obsidian.md/plugins?search=Logstravaganza'>Logstravaganza</a> 来导出终端输出到一篇笔记上。",
|
||||
"settings_syncplans": "导出同步计划",
|
||||
"settings_syncplans_desc": "每次您启动同步,并在实际上传下载前,插件会生成同步计划。它可以使您知道每次同步发生了什么。点击按钮可以导出同步计划。",
|
||||
"settings_syncplans_button_json": "导出",
|
||||
"settings_syncplans_button_1": "导出最近 1 次",
|
||||
"settings_syncplans_button_5": "导出最近 5 次",
|
||||
"settings_syncplans_button_all": "导出所有",
|
||||
"settings_syncplans_notice": "同步计划已导出",
|
||||
"settings_delsyncplans": "删除数据库里的同步计划历史",
|
||||
"settings_delsyncplans_desc": "删除数据库里的同步计划历史。",
|
||||
@@ -301,6 +324,10 @@
|
||||
"settings_delprevsync_desc": "同步算法需要上次成功同步的信息来决定文件变更,这个信息保存在本地的数据库里。如果您想忽略这些信息从而所有文件都被视为新创建的话,可以在此删除之前的信息。",
|
||||
"settings_delprevsync_button": "删除上次同步明细",
|
||||
"settings_delprevsync_notice": "(本地数据库里的)上次同步明细已被删除。",
|
||||
"settings_profiler_results": "导出性能数据记录",
|
||||
"settings_profiler_results_desc": "插件记录了每次同步每一步的耗时。这里可以导出记录得知哪一步最慢。",
|
||||
"settings_profiler_results_notice": "性能数据已导出",
|
||||
"settings_profiler_results_button_all": "导出所有",
|
||||
"settings_outputbasepathvaultid": "输出资料库对应的位置和随机分配的 ID",
|
||||
"settings_outputbasepathvaultid_desc": "用于调试。",
|
||||
"settings_outputbasepathvaultid_button": "输出",
|
||||
@@ -308,10 +335,13 @@
|
||||
"settings_resetcache_desc": "(出于调试原因)重设本地缓存和数据库。您需要在重设之后重新载入此插件。本重设不会删除 s3,密码……等设定。",
|
||||
"settings_resetcache_button": "重设",
|
||||
"settings_resetcache_notice": "本地同步缓存和数据库已被删除。请手动重新载入此插件。",
|
||||
|
||||
"syncalgov3_title": "Remotely Save 的同步算法有重大更新",
|
||||
"syncalgov3_texts": "欢迎使用 Remotely Save!\n从这个版本开始,插件更新了同步算法:\n<ul><li>更稳健的删除同步</li><li>引入冲突处理</li><li>避免上传元数据</li><li>修改删除保护</li><li>备份模式</li><li>新的加密方式</li><li>……</li></ul>\n敬请期待更多更新!详细介绍请参阅<a href='https://github.com/remotely-save/remotely-save/tree/master/docs/sync_algorithm/v3/intro.md'>文档网站</a>。\n如果您同意使用新版本,请阅读和勾选两个勾选框,然后点击“同意”按钮,开始使用插件吧!\n如果您不同意,请点击“不同意”按钮,插件将自动停止运行(unload)。\n此外,请考虑<a href='https://github.com/remotely-save/remotely-save'>访问 GitHub 页面然后点赞 ⭐</a>!您的支持对我十分重要!谢谢!",
|
||||
"syncalgov3_checkbox_manual_backup": "我将会首先手动备份我的库(Vault)。",
|
||||
"syncalgov3_checkbox_requiremultidevupdate": "我理解,我需要在所有设备上都更新此插件使之正常运行。",
|
||||
"syncalgov3_button_agree": "同意",
|
||||
"syncalgov3_button_disagree": "不同意"
|
||||
"syncalgov3_button_disagree": "不同意",
|
||||
|
||||
"menu_check_file_stat": "查看文件属性"
|
||||
}
|
||||
|
||||
+51
-22
@@ -5,13 +5,13 @@
|
||||
"goback": "返回",
|
||||
"submit": "提交",
|
||||
"sometext": "這裡有一段文字。",
|
||||
"syncrun_alreadyrunning": "{{pluginName}} 正處於此階段:{{syncStatus}}!",
|
||||
"syncrun_alreadyrunning": "{{pluginName}} 正處於此階段:{{syncStatus}}! 中斷觸發 {{newTriggerSource}}。",
|
||||
"syncrun_syncingribbon": "{{pluginName}}:正在由 {{triggerSource}} 觸發執行",
|
||||
"syncrun_step0": "0/8 Remotely Save 在空跑(dry run)模式,不會發生實際的檔案交換。",
|
||||
"syncrun_step1": "1/8 Remotely Save 準備同步({{serviceType}})",
|
||||
"syncrun_step2": "2/8 正在獲取遠端的元資料。",
|
||||
"syncrun_step3": "3/8 正在檢查密碼正確與否。",
|
||||
"syncrun_step2": "2/8 正在獲取遠端的元數據和檢查密碼。",
|
||||
"syncrun_passworderr": "檢查密碼時候出錯。",
|
||||
"syncrun_step3": "3/8 正在處理遠端的元數據。",
|
||||
"syncrun_step4": "4/8 正在獲取本地的元資料。",
|
||||
"syncrun_step5": "5/8 正在獲取本地上一次同步的元資料。",
|
||||
"syncrun_step6": "6/8 正在生成同步計劃。",
|
||||
@@ -24,7 +24,7 @@
|
||||
"syncrun_shortstep2": "2/2 Remotely Save 已完成同步!",
|
||||
"syncrun_abort": "{{manifestID}}-{{theDate}}:中斷同步,同步來源={{triggerSource}},出錯階段={{syncStatus}}",
|
||||
"syncrun_abort_protectmodifypercentage": "中斷同步!您設定了不允許 >= {{protectModifyPercentage}}% 的變更,但是現在 {{realModifyDeleteCount}}/{{allFilesCount}}={{percent}}% 的檔案會被修改或刪除!如果您確認這次同步是您想要的,那麼請在設定裡修改允許比例。",
|
||||
"protocol_saveqr": " {{manifestName}} 新的非 oauth2 設定儲存完成。請重啟外掛設定頁使之生效。",
|
||||
"protocol_saveqr": " {{manifestName}} 的新設定匯入完成。請重啟外掛設定頁使之生效。",
|
||||
"protocol_callbacknotsupported": "您的 uri callback 暫不支援: {{params}}",
|
||||
"protocol_dropbox_connecting": "正在連線 Dropbox……\n請不要關閉此彈窗。",
|
||||
"protocol_dropbox_connect_succ": "好!我們作為使用者 {{username}} 連線上了 Dropbox!",
|
||||
@@ -38,7 +38,9 @@
|
||||
"protocol_onedrive_connect_unknown": "不知道如何處理此 callback:{{params}}",
|
||||
"command_startsync": "開始同步(start sync)",
|
||||
"command_drynrun": "開始同步(空跑模式)(start sync (dry run only))",
|
||||
"command_exportsyncplans_json": "匯出同步計劃為 json 格式(export sync plans in json format)",
|
||||
"command_exportsyncplans_1": "匯出同步計劃(最近 1 次)(export sync plans (latest 1))",
|
||||
"command_exportsyncplans_5": "匯出同步計劃(最近 5 次)(export sync plans (latest 5))",
|
||||
"command_exportsyncplans_all": "匯出同步計劃(所有)(export sync plans (all))",
|
||||
"command_exportlogsindb": "從資料庫匯出終端日誌(export logs saved in db)",
|
||||
|
||||
"statusbar_time_years": "{{time}} 年前同步",
|
||||
@@ -54,7 +56,6 @@
|
||||
"statusbar_lastsync_label": "上一次同步於:{{date}}",
|
||||
"statusbar_lastsync_never": "沒觸發過同步",
|
||||
"statusbar_lastsync_never_label": "沒觸發過同步",
|
||||
|
||||
"modal_password_title": "稍等一下,請閱讀下文:",
|
||||
"modal_password_shortdesc": "如果密碼不是空的,那麼檔案會在上傳之前,在本地先用此密碼加密。\n如果密碼是空的,那麼檔案會被非加密地上傳。",
|
||||
"modal_password_attn1": "注意 1/5:儲存庫(Vault)名字是不會加密的!本外掛會在一些遠端儲存裡建立一個和庫名字有著同名的資料夾。",
|
||||
@@ -90,6 +91,7 @@
|
||||
"modal_dropboxauth_maualinput_conn_succ_revoke": "您已作為使用者 {{username}} 連線到 Dropbox。如果您想斷開連線,點選此按鈕。",
|
||||
"modal_dropboxauth_maualinput_conn_fail": "連線 Dropbox 途中出錯了。",
|
||||
"modal_onedriveauth_shortdesc": "現在只支援個人版 OneDrive,(暫)不支援企業版。\n在瀏覽器中訪問以下地址,然後按照網頁提示操作。\n到了最後,您應該會被自動重定向回來 Obsidian。",
|
||||
"modal_onedriveauth_shortdesc_linux": "您正在用 Linux,有可能無法跳轉回來。請考慮<a href=\"https://github.com/remotely-save/remotely-save/issues/415\">使用</a> flatpack 版本的 Obsidian,或建立 <a href=\"https://github.com/remotely-save/remotely-save/blob/master/docs/linux.md\"><code>obsidian.desktop</code> 檔案</a>。",
|
||||
"modal_onedriveauth_copybutton": "點選此按鈕從而複製鑑權 url",
|
||||
"modal_onedriveauth_copynotice": "鑑權 url 已複製到剪貼簿!",
|
||||
"modal_onedriverevokeauth_step1": "第 1 步:用瀏覽器開啟以下地址,點選本外掛對應的“Edit”按鈕,點選“Remove these permissions”按鈕。",
|
||||
@@ -102,7 +104,7 @@
|
||||
"modal_syncconfig_attn": "注意 1/2:此設定只同步(複製)整個 Obsidian 的配置資料夾,但是不會同步其它 . 開頭的資料夾或檔案。除了會忽略 .git 和 node_modules 資料夾之外,它也並不理解配置資料夾的裡各個子檔案或子資料夾的含義。\n注意 2/2:配置資料夾被同步之後,各外掛的設定或許會出錯,且 Obsidian 或許需要重啟來過載各外掛的新配置。\n如果您同意自行承受以上風險,您可以點選以下再次確認按鈕。",
|
||||
"modal_syncconfig_secondconfirm": "再次確認開啟",
|
||||
"modal_syncconfig_notice": "您已開啟配置資料夾的同步!",
|
||||
"modal_qr_shortdesc": "這裡可匯出非 oauth2 設定。(意味著:Dropbox 和 OneDrive 資訊不會被匯出。)\n您可以使用另一個裝置來掃描此 QR 碼。\n又或者,您可以點選以下按鈕複製此特殊 URI。",
|
||||
"modal_qr_shortdesc": "這裡可匯出(部分)設定。\n您可以使用另一個裝置來掃描此 QR 碼。\n又或者,您可以點選以下按鈕複製此特殊 URI,然後貼上到另一臺裝置的網路瀏覽器或 Remotely Save 設定裡的匯入部分。",
|
||||
"modal_qr_button": "點選此按鈕複製特殊 URI",
|
||||
"modal_qr_button_notice": "特殊 URI 已被複制到剪貼簿!",
|
||||
"modal_sizesconflict_title": "Remotely Save:跳過大檔案的時候出現了一些衝突",
|
||||
@@ -129,17 +131,13 @@
|
||||
"settings_runoncestartup_1sec": "啟動後第 1 秒執行一次",
|
||||
"settings_runoncestartup_10sec": "啟動後第 10 秒執行一次",
|
||||
"settings_runoncestartup_30sec": "啟動後第 30 秒執行一次",
|
||||
"settings_saverun": "儲存時同步(實驗性質)",
|
||||
"settings_saverun_desc": "外掛如果檢查到當前檔案在最近一段時間有修改儲存過,則嘗試同步。請注意,同步是一個很重的操作,因此會影響到耗電量。(修改設定後可能需要過載外掛或重啟。)",
|
||||
"settings_saverun_notset": "(不設定)",
|
||||
"settings_saverun_1sec": "隔 1 秒檢查一次",
|
||||
"settings_saverun_5sec": "隔 5 秒檢查一次",
|
||||
"settings_saverun_10sec": "隔 10 秒檢查一次(推薦)",
|
||||
"settings_saverun_1min": "隔 1 分鐘檢查一次",
|
||||
"settings_synconsave": "儲存時同步(實驗性質)",
|
||||
"settings_synconsave_desc": "外掛如果檢查到當前檔案在最近一段時間有修改儲存過,則嘗試同步。請注意,同步是一個很重的操作,因此會影響到耗電量。(修改設定後可能需要過載外掛或重啟。)",
|
||||
"settings_synconsave_disable": "關閉(預設)",
|
||||
"settings_synconsave_enable": "開啟",
|
||||
"settings_skiplargefiles": "跳過大檔案",
|
||||
"settings_skiplargefiles_desc": "跳過大於某一個閾值的檔案。這裡 1 MB = 10^6 bytes。",
|
||||
"settings_skiplargefiles_notset": "(不設定)",
|
||||
|
||||
"settings_ignorepaths": "忽略的檔案或資料夾的正則表示式",
|
||||
"settings_ignorepaths_desc": "忽略的檔案或資料夾的正則表示式。每行一條。路徑是相對於庫(Vault)根目錄的,沒有前置 / 符號。",
|
||||
"settings_enablestatusbar_info": "在狀態列顯示上一次成功的同步",
|
||||
@@ -149,7 +147,6 @@
|
||||
"settings_resetstatusbar_time_desc": "重設上一次成功同步的時間記錄。",
|
||||
"settings_resetstatusbar_button": "重設",
|
||||
"settings_resetstatusbar_notice": "重設完畢!",
|
||||
|
||||
"settings_checkonnectivity": "檢查可否連線",
|
||||
"settings_checkonnectivity_desc": "檢查可否連線。",
|
||||
"settings_checkonnectivity_button": "檢查",
|
||||
@@ -182,6 +179,12 @@
|
||||
"settings_s3_accuratemtime_desc": "讀取(已上傳的)準確的檔案修改時間,有助於同步演算法更加準確和穩定。但是它也會導致額外的 api 請求、時間、金錢花費。",
|
||||
"settings_s3_urlstyle": "S3 URL style",
|
||||
"settings_s3_urlstyle_desc": "是否對 S3 物件強制使用 path style URL(例如使用 https://s3.amazonaws.com/*/ 而不是 https://*.s3.amazonaws.com/)。",
|
||||
"settings_s3_reverse_proxy_no_sign_url": "S3 反向代理(不簽名)地址(實驗性質)",
|
||||
"settings_s3_reverse_proxy_no_sign_url_desc": "不會參與到簽名的 S3 反向代理地址。如果您有一個反向代理,但是不想修改原始鑑權簽名,這裡就可以填寫。沒有 http(s):// 字首。如果您不知道這是什麼,留空即可。",
|
||||
"settings_s3_generatefolderobject": "是否生成文件夾 Object",
|
||||
"settings_s3_generatefolderobject_desc": "S3 不存在“真正”的文件夾。如果您設置了“生成”(或用了舊版本),那麼插件會上傳 0 字節的以“/”結尾的 Object 來代表文件夾。新版本插件會默認跳過生成這種文件夾 Object。",
|
||||
"settings_s3_generatefolderobject_notgenerate": "不生成(默認)",
|
||||
"settings_s3_generatefolderobject_generate": "生成",
|
||||
"settings_s3_connect_succ": "很好!可以訪問到對應儲存桶。",
|
||||
"settings_s3_connect_fail": "無法訪問到對應儲存桶。",
|
||||
"settings_dropbox": "Dropbox 設定",
|
||||
@@ -235,12 +238,25 @@
|
||||
"settings_webdav_connect_succ": "很好!可以連線上 Webdav 伺服器。",
|
||||
"settings_webdav_connect_fail": "無法連線上 Webdav 伺服器。(可能是地址/賬號/密碼/鑑權型別等錯誤。)",
|
||||
"settings_webdav_connect_fail_withcors": "無法連線上 Webdav 伺服器。(可能是地址/賬號/密碼/鑑權型別/CORS 等錯誤。)",
|
||||
"settings_webdis": "Webdis 設置",
|
||||
"settings_webdis_disclaimer1": "聲明:此插件不是 Redis® Ltd 或 Redis® 軟件或 Wedis 的官方產品。Redis 是 Redis Ltd 的註冊商標。",
|
||||
"settings_webdis_disclaimer2": "聲明:您所輸入的信息存儲於本地。其它有害的或者出錯的插件,是有可能讀取到這些信息的。如果您發現了 Webdis 服務器有不符合預期的訪問,請立刻修改用戶名和密碼。",
|
||||
"settings_webdis_folder": "我們會在您的服務器上創建帶有此前綴的 key 並在裡面同步::{{remoteBaseDir}}。",
|
||||
"settings_webdis_addr": "服務器地址",
|
||||
"settings_webdis_addr_desc": "服務器地址",
|
||||
"settings_webdis_user": "用戶名",
|
||||
"settings_webdis_user_desc": "用戶名。注意:用戶名和其它信息都會保存在本地。",
|
||||
"settings_webdis_password": "密碼",
|
||||
"settings_webdis_password_desc": "密碼。注意:密碼和其它信息都會保存在本地。",
|
||||
"settings_webdis_connect_succ": "很好!可以連接上 Webdis 服務器。",
|
||||
"settings_webdis_connect_fail": "無法連接上 Webdis 服務器。(可能是地址/賬號/密碼/鑑權類型等錯誤。)",
|
||||
"settings_chooseservice": "選擇遠端服務",
|
||||
"settings_chooseservice_desc": "從這裡開始設定。您想連線到哪一個服務?S3、Dropbox、Webdav、OneDrive(個人版)?",
|
||||
"settings_chooseservice_desc": "從這裡開始設定。您想連線到哪一個服務?S3、Dropbox、Webdav、OneDrive(個人版)、Webdis?",
|
||||
"settings_chooseservice_s3": "S3 或相容 S3 的服務",
|
||||
"settings_chooseservice_dropbox": "Dropbox",
|
||||
"settings_chooseservice_webdav": "Webdav",
|
||||
"settings_chooseservice_onedrive": "OneDrive(個人版)",
|
||||
"settings_chooseservice_webdis": "Webdis (an HTTP interface for Redis®)",
|
||||
"settings_adv": "進階設定",
|
||||
"settings_concurrency": "並行度",
|
||||
"settings_concurrency_desc": "您希望同時最多有多少個檔案被上傳和下載?預設值是 5。如果您遇到了一些問題(如訪問頻率限制),您可以減少並行度。",
|
||||
@@ -274,10 +290,14 @@
|
||||
"settings_enablemobilestatusbar_desc": "Obsidian 手機版預設隱藏了狀態列。有些使用者希望展示它。這裡提供了設定選項。",
|
||||
"settings_importexport": "匯入匯出部分設定",
|
||||
"settings_export": "匯出",
|
||||
"settings_export_desc": "用 QR 碼匯出非 oauth2 的設定資訊。",
|
||||
"settings_export_desc_button": "生成 QR 碼",
|
||||
"settings_export_desc": "用 QR 碼或 URI 匯出設定資訊。",
|
||||
"settings_export_all_but_oauth2_button": "匯出非 Oauth2 部分",
|
||||
"settings_export_dropbox_button": "匯出 Dropbox 部分",
|
||||
"settings_export_onedrive_button": "匯出 OneDrive 部分",
|
||||
"settings_import": "匯入",
|
||||
"settings_import_desc": "您需要使用系統拍攝 app 或者掃描 QR 碼的app,來掃描對應的 QR 碼。",
|
||||
"settings_import_desc": "貼上之前匯出的 URI 到這裡然後點選“匯入”。或,使用拍攝 app 或者掃描 QR 碼的 app,來掃描對應的 QR 碼。",
|
||||
"settings_import_button": "匯入",
|
||||
"settings_import_error_notice": "您輸入的 URI 是空的或者不準確的!",
|
||||
"settings_debug": "除錯",
|
||||
"settings_debuglevel": "修改同步提示資訊",
|
||||
"settings_debuglevel_desc": "預設值為 \"info\"。您可以改為 \"debug\" 從而在同步時候裡獲取更多資訊。",
|
||||
@@ -291,7 +311,9 @@
|
||||
"settings_viewconsolelog_desc": "電腦上,輸入“ctrl+shift+i”或“cmd+shift+i”來檢視終端輸出。手機上,安裝第三方外掛 <a href='https://obsidian.md/plugins?search=Logstravaganza'>Logstravaganza</a> 來匯出終端輸出到一篇筆記上。",
|
||||
"settings_syncplans": "匯出同步計劃",
|
||||
"settings_syncplans_desc": "每次您啟動同步,並在實際上傳下載前,外掛會生成同步計劃。它可以使您知道每次同步發生了什麼。點選按鈕可以匯出同步計劃。",
|
||||
"settings_syncplans_button_json": "匯出",
|
||||
"settings_syncplans_button_1": "匯出最近 1 次",
|
||||
"settings_syncplans_button_5": "匯出最近 5 次",
|
||||
"settings_syncplans_button_all": "匯出所有",
|
||||
"settings_syncplans_notice": "同步計劃已匯出",
|
||||
"settings_delsyncplans": "刪除資料庫裡的同步計劃歷史",
|
||||
"settings_delsyncplans_desc": "刪除資料庫裡的同步計劃歷史。",
|
||||
@@ -301,6 +323,10 @@
|
||||
"settings_delprevsync_desc": "同步演算法需要上次成功同步的資訊來決定檔案變更,這個資訊儲存在本地的資料庫裡。如果您想忽略這些資訊從而所有檔案都被視為新建立的話,可以在此刪除之前的資訊。",
|
||||
"settings_delprevsync_button": "刪除上次同步明細",
|
||||
"settings_delprevsync_notice": "(本地資料庫裡的)上次同步明細已被刪除。",
|
||||
"settings_profiler_results": "匯出效能資料記錄",
|
||||
"settings_profiler_results_desc": "外掛記錄了每次同步每一步的耗時。這裡可以匯出記錄得知哪一步最慢。",
|
||||
"settings_profiler_results_notice": "效能資料已匯出",
|
||||
"settings_profiler_results_button_all": "匯出所有",
|
||||
"settings_outputbasepathvaultid": "輸出資料庫對應的位置和隨機分配的 ID",
|
||||
"settings_outputbasepathvaultid_desc": "用於除錯。",
|
||||
"settings_outputbasepathvaultid_button": "輸出",
|
||||
@@ -308,10 +334,13 @@
|
||||
"settings_resetcache_desc": "(出於除錯原因)重設本地快取和資料庫。您需要在重設之後重新載入此外掛。本重設不會刪除 s3,密碼……等設定。",
|
||||
"settings_resetcache_button": "重設",
|
||||
"settings_resetcache_notice": "本地同步快取和資料庫已被刪除。請手動重新載入此外掛。",
|
||||
|
||||
"syncalgov3_title": "Remotely Save 的同步演算法有重大更新",
|
||||
"syncalgov3_texts": "歡迎使用 Remotely Save!\n從這個版本開始,外掛更新了同步演算法:\n<ul><li>更穩健的刪除同步</li><li>引入衝突處理</li><li>避免上傳元資料</li><li>修改刪除保護</li><li>備份模式</li><li>新的加密方式</li><li>……</li></ul>\n敬請期待更多更新!詳細介紹請參閱<a href='https://github.com/remotely-save/remotely-save/tree/master/docs/sync_algorithm/v3/intro.md'>文件網站</a>。\n如果您同意使用新版本,請閱讀和勾選兩個勾選框,然後點選“同意”按鈕,開始使用外掛吧!\n如果您不同意,請點選“不同意”按鈕,外掛將自動停止執行(unload)。\n此外,請考慮<a href='https://github.com/remotely-save/remotely-save'>訪問 GitHub 頁面然後點贊 ⭐</a>!您的支援對我十分重要!謝謝!",
|
||||
"syncalgov3_checkbox_manual_backup": "我將會首先手動備份我的庫(Vault)。",
|
||||
"syncalgov3_checkbox_requiremultidevupdate": "我理解,我需要在所有裝置上都更新此外掛使之正常執行。",
|
||||
"syncalgov3_button_agree": "同意",
|
||||
"syncalgov3_button_disagree": "不同意"
|
||||
"syncalgov3_button_disagree": "不同意",
|
||||
|
||||
"menu_check_file_stat": "檢視檔案屬性"
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
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;
|
||||
};
|
||||
+58
-11
@@ -1,12 +1,12 @@
|
||||
import localforage from "localforage";
|
||||
import { extendPrototype } from "localforage-getitems";
|
||||
extendPrototype(localforage);
|
||||
export type LocalForage = typeof localforage;
|
||||
import { nanoid } from "nanoid";
|
||||
import { requireApiVersion, TAbstractFile, TFile, TFolder } from "obsidian";
|
||||
|
||||
import { API_VER_STAT_FOLDER } from "./baseTypes";
|
||||
import type { Entity, MixedEntity, SUPPORTED_SERVICES_TYPE } from "./baseTypes";
|
||||
import type { SyncPlanType } from "./sync";
|
||||
import { statFix, toText, unixTimeToStr } from "./misc";
|
||||
import { unixTimeToStr } from "./misc";
|
||||
|
||||
const DB_VERSION_NUMBER_IN_HISTORY = [20211114, 20220108, 20220326, 20240220];
|
||||
export const DEFAULT_DB_VERSION_NUMBER: number = 20240220;
|
||||
@@ -17,6 +17,7 @@ export const DEFAULT_TBL_VAULT_RANDOM_ID_MAPPING = "vaultrandomidmapping";
|
||||
export const DEFAULT_TBL_LOGGER_OUTPUT = "loggeroutput";
|
||||
export const DEFAULT_TBL_SIMPLE_KV_FOR_MISC = "simplekvformisc";
|
||||
export const DEFAULT_TBL_PREV_SYNC_RECORDS = "prevsyncrecords";
|
||||
export const DEFAULT_TBL_PROFILER_RESULTS = "profilerresults";
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
@@ -58,6 +59,7 @@ export interface InternalDBs {
|
||||
loggerOutputTbl: LocalForage;
|
||||
simpleKVForMiscTbl: LocalForage;
|
||||
prevSyncRecordsTbl: LocalForage;
|
||||
profilerResultsTbl: LocalForage;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
@@ -204,6 +206,10 @@ export const prepareDBs = async (
|
||||
name: DEFAULT_DB_NAME,
|
||||
storeName: DEFAULT_TBL_PREV_SYNC_RECORDS,
|
||||
}),
|
||||
profilerResultsTbl: localforage.createInstance({
|
||||
name: DEFAULT_DB_NAME,
|
||||
storeName: DEFAULT_TBL_PROFILER_RESULTS,
|
||||
}),
|
||||
|
||||
fileHistoryTbl: localforage.createInstance({
|
||||
name: DEFAULT_DB_NAME,
|
||||
@@ -382,13 +388,13 @@ export const readAllSyncPlanRecordTextsByVault = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* We remove records that are older than 3 days or 100 records.
|
||||
* We remove records that are older than 1 days or 20 records.
|
||||
* It's a heavy operation, so we shall not place it in the start up.
|
||||
* @param db
|
||||
*/
|
||||
export const clearExpiredSyncPlanRecords = async (db: InternalDBs) => {
|
||||
const MILLISECONDS_OLD = 1000 * 60 * 60 * 24 * 3; // 3 days
|
||||
const COUNT_TO_MANY = 100;
|
||||
const MILLISECONDS_OLD = 1000 * 60 * 60 * 24 * 1; // 1 days
|
||||
const COUNT_TO_MANY = 20;
|
||||
|
||||
const currTs = Date.now();
|
||||
const expiredTs = currTs - MILLISECONDS_OLD;
|
||||
@@ -428,13 +434,12 @@ export const getAllPrevSyncRecordsByVaultAndProfile = async (
|
||||
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) {
|
||||
const kv: Record<string, Entity | null> =
|
||||
await db.prevSyncRecordsTbl.getItems();
|
||||
for (const key of Object.getOwnPropertyNames(kv)) {
|
||||
if (key.startsWith(`${vaultRandomID}\t${profileID}\t`)) {
|
||||
const val: Entity | null = await db.prevSyncRecordsTbl.getItem(key);
|
||||
const val = kv[key];
|
||||
if (val !== null) {
|
||||
res.push(val);
|
||||
}
|
||||
@@ -524,3 +529,45 @@ export const upsertPluginVersionByVault = async (
|
||||
newVersion: newVersion,
|
||||
};
|
||||
};
|
||||
|
||||
export const insertProfilerResultByVault = async (
|
||||
db: InternalDBs,
|
||||
profilerStr: string,
|
||||
vaultRandomID: string,
|
||||
remoteType: SUPPORTED_SERVICES_TYPE
|
||||
) => {
|
||||
const now = Date.now();
|
||||
await db.profilerResultsTbl.setItem(`${vaultRandomID}\t${now}`, profilerStr);
|
||||
|
||||
// clear older one while writing
|
||||
const records = (await db.profilerResultsTbl.keys())
|
||||
.filter((x) => x.startsWith(`${vaultRandomID}\t`))
|
||||
.map((x) => parseInt(x.split("\t")[1]));
|
||||
records.sort((a, b) => -(a - b)); // descending
|
||||
while (records.length > 5) {
|
||||
const ts = records.pop()!;
|
||||
await db.profilerResultsTbl.removeItem(`${vaultRandomID}\t${ts}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const readAllProfilerResultsByVault = async (
|
||||
db: InternalDBs,
|
||||
vaultRandomID: string
|
||||
) => {
|
||||
const records = [] as { val: string; ts: number }[];
|
||||
await db.profilerResultsTbl.iterate((value, key, iterationNumber) => {
|
||||
if (key.startsWith(`${vaultRandomID}\t`)) {
|
||||
records.push({
|
||||
val: value as string,
|
||||
ts: parseInt(key.split("\t")[1]),
|
||||
});
|
||||
}
|
||||
});
|
||||
records.sort((a, b) => -(a.ts - b.ts)); // descending
|
||||
|
||||
if (records === undefined) {
|
||||
return [] as string[];
|
||||
} else {
|
||||
return records.map((x) => x.val);
|
||||
}
|
||||
};
|
||||
|
||||
+440
-377
File diff suppressed because it is too large
Load Diff
+129
-12
@@ -513,24 +513,141 @@ export const stringToFragment = (string: string) => {
|
||||
return wrapper.content;
|
||||
};
|
||||
|
||||
/**
|
||||
* https://stackoverflow.com/questions/39538473/using-settimeout-on-promise-chain
|
||||
* @param ms
|
||||
* @returns
|
||||
*/
|
||||
export const delay = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* https://forum.obsidian.md/t/css-to-show-status-bar-on-mobile-devices/77185
|
||||
* @param op
|
||||
*/
|
||||
export const changeMobileStatusBar = (op: "enable" | "disable") => {
|
||||
const bar = document.querySelector(
|
||||
export const changeMobileStatusBar = (
|
||||
op: "enable" | "disable",
|
||||
oldAppContainerObserver?: MutationObserver
|
||||
) => {
|
||||
const appContainer = document.getElementsByClassName("app-container")[0] as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
|
||||
const statusbar = document.querySelector(
|
||||
".is-mobile .app-container .status-bar"
|
||||
) as HTMLElement;
|
||||
) as HTMLElement | undefined;
|
||||
|
||||
if (appContainer === undefined || statusbar === undefined) {
|
||||
// give up, exit
|
||||
console.warn(`give up watching appContainer for statusbar`);
|
||||
console.warn(`appContainer=${appContainer}, statusbar=${statusbar}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (op === "enable") {
|
||||
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);
|
||||
const callback = async (
|
||||
mutationList: MutationRecord[],
|
||||
observer: MutationObserver
|
||||
) => {
|
||||
for (const mutation of mutationList) {
|
||||
// console.debug(mutation);
|
||||
if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
|
||||
const k = mutation.addedNodes[0] as Element;
|
||||
if (
|
||||
k.className.contains("mobile-navbar") ||
|
||||
k.className.contains("mobile-toolbar")
|
||||
) {
|
||||
// have to wait, otherwise the height is not correct??
|
||||
await delay(300);
|
||||
const height = window
|
||||
.getComputedStyle(k as Element)
|
||||
.getPropertyValue("height");
|
||||
|
||||
statusbar.style.setProperty("display", "flex");
|
||||
statusbar.style.setProperty("margin-bottom", height);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const observer = new MutationObserver(callback);
|
||||
observer.observe(appContainer, {
|
||||
attributes: false,
|
||||
childList: true,
|
||||
characterData: false,
|
||||
subtree: false,
|
||||
});
|
||||
|
||||
try {
|
||||
// init, manual call
|
||||
const navBar = document.getElementsByClassName(
|
||||
"mobile-navbar"
|
||||
)[0] as HTMLElement;
|
||||
// thanks to community's solution
|
||||
const height = window.getComputedStyle(navBar).getPropertyValue("height");
|
||||
statusbar.style.setProperty("display", "flex");
|
||||
statusbar.style.setProperty("margin-bottom", height);
|
||||
} catch (e) {
|
||||
// skip
|
||||
}
|
||||
|
||||
return observer;
|
||||
} else {
|
||||
bar.style.removeProperty("display");
|
||||
bar.style.removeProperty("margin-bottom");
|
||||
if (oldAppContainerObserver !== undefined) {
|
||||
console.debug(`disconnect oldAppContainerObserver`);
|
||||
oldAppContainerObserver.disconnect();
|
||||
oldAppContainerObserver = undefined;
|
||||
}
|
||||
statusbar.style.removeProperty("display");
|
||||
statusbar.style.removeProperty("margin-bottom");
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* https://github.com/remotely-save/remotely-save/issues/567
|
||||
* https://www.dropboxforum.com/t5/Dropbox-API-Support-Feedback/Case-Sensitivity-in-API-2/td-p/191279
|
||||
* @param entities
|
||||
*/
|
||||
export const fixEntityListCasesInplace = (entities: { keyRaw: string }[]) => {
|
||||
entities.sort((a, b) => a.keyRaw.length - b.keyRaw.length);
|
||||
// console.log(JSON.stringify(entities,null,2));
|
||||
|
||||
const caseMapping: Record<string, string> = { "": "" };
|
||||
for (const e of entities) {
|
||||
// console.log(`looking for: ${JSON.stringify(e, null, 2)}`);
|
||||
|
||||
let parentFolder = getParentFolder(e.keyRaw);
|
||||
if (parentFolder === "/") {
|
||||
parentFolder = "";
|
||||
}
|
||||
const parentFolderLower = parentFolder.toLocaleLowerCase();
|
||||
const segs = e.keyRaw.split("/");
|
||||
if (e.keyRaw.endsWith("/")) {
|
||||
// folder
|
||||
if (caseMapping.hasOwnProperty(parentFolderLower)) {
|
||||
const newKeyRaw = `${caseMapping[parentFolderLower]}${segs
|
||||
.slice(-2)
|
||||
.join("/")}`;
|
||||
caseMapping[newKeyRaw.toLocaleLowerCase()] = newKeyRaw;
|
||||
e.keyRaw = newKeyRaw;
|
||||
// console.log(JSON.stringify(caseMapping,null,2));
|
||||
continue;
|
||||
} else {
|
||||
throw Error(`${parentFolder} doesn't have cases record??`);
|
||||
}
|
||||
} else {
|
||||
// file
|
||||
if (caseMapping.hasOwnProperty(parentFolderLower)) {
|
||||
const newKeyRaw = `${caseMapping[parentFolderLower]}${segs
|
||||
.slice(-1)
|
||||
.join("/")}`;
|
||||
e.keyRaw = newKeyRaw;
|
||||
continue;
|
||||
} else {
|
||||
throw Error(`${parentFolder} doesn't have cases record??`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entities;
|
||||
};
|
||||
|
||||
@@ -31,13 +31,6 @@ const isLikelyPluginSubFiles = (x: string) => {
|
||||
return false;
|
||||
};
|
||||
|
||||
export const isInsideObsFolder = (x: string, configDir: string) => {
|
||||
if (!configDir.startsWith(".")) {
|
||||
throw Error(`configDir should starts with . but we get ${configDir}`);
|
||||
}
|
||||
return x === configDir || x.startsWith(`${configDir}/`);
|
||||
};
|
||||
|
||||
export const listFilesInObsFolder = async (
|
||||
configDir: string,
|
||||
vault: Vault,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { SUPPORTED_SERVICES_TYPE } from "./baseTypes";
|
||||
import { InternalDBs, insertProfilerResultByVault } from "./localdb";
|
||||
import { unixTimeToStr } from "./misc";
|
||||
|
||||
interface BreakPoint {
|
||||
label: string;
|
||||
fakeTimeMilli: number; // it's NOT a unix timestamp
|
||||
indent: number;
|
||||
}
|
||||
|
||||
export class Profiler {
|
||||
startTime: number;
|
||||
breakPoints: BreakPoint[];
|
||||
indent: number;
|
||||
constructor(label?: string) {
|
||||
this.breakPoints = [];
|
||||
this.indent = 0;
|
||||
this.startTime = 0;
|
||||
|
||||
if (label !== undefined) {
|
||||
this.startTime = Date.now();
|
||||
this.breakPoints.push({
|
||||
label: label,
|
||||
fakeTimeMilli: performance.now(),
|
||||
indent: this.indent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
insert(label: string) {
|
||||
if (this.breakPoints.length === 0) {
|
||||
this.startTime = Date.now();
|
||||
}
|
||||
this.breakPoints.push({
|
||||
label: label,
|
||||
fakeTimeMilli: performance.now(),
|
||||
indent: this.indent,
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
addIndent() {
|
||||
this.indent += 2;
|
||||
}
|
||||
removeIndent() {
|
||||
this.indent -= 2;
|
||||
if (this.indent < 0) {
|
||||
this.indent = 0;
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.breakPoints = [];
|
||||
this.indent = 0;
|
||||
this.startTime = 0;
|
||||
return this;
|
||||
}
|
||||
|
||||
toString() {
|
||||
if (this.breakPoints.length === 0) {
|
||||
return "nothing in profiler";
|
||||
}
|
||||
|
||||
let res = `[startTime]: ${unixTimeToStr(this.startTime)}`;
|
||||
for (let i = 0; i < this.breakPoints.length; ++i) {
|
||||
if (i === 0) {
|
||||
res += `\n[${this.breakPoints[i]["label"]}]: start`;
|
||||
} else {
|
||||
const label = this.breakPoints[i]["label"];
|
||||
const indent = this.breakPoints[i]["indent"];
|
||||
const millsec =
|
||||
Math.round(
|
||||
(this.breakPoints[i]["fakeTimeMilli"] -
|
||||
this.breakPoints[i - 1]["fakeTimeMilli"]) *
|
||||
10
|
||||
) / 10.0;
|
||||
res += `\n${" ".repeat(indent)}[${label}]: ${millsec}ms`;
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async save(
|
||||
db: InternalDBs,
|
||||
vaultRandomID: string,
|
||||
remoteType: SUPPORTED_SERVICES_TYPE
|
||||
) {
|
||||
await insertProfilerResultByVault(
|
||||
db,
|
||||
this.toString(),
|
||||
vaultRandomID,
|
||||
remoteType
|
||||
);
|
||||
}
|
||||
}
|
||||
-316
@@ -1,316 +0,0 @@
|
||||
import { Vault } from "obsidian";
|
||||
import type {
|
||||
Entity,
|
||||
DropboxConfig,
|
||||
OnedriveConfig,
|
||||
S3Config,
|
||||
SUPPORTED_SERVICES_TYPE,
|
||||
WebdavConfig,
|
||||
UploadedType,
|
||||
} from "./baseTypes";
|
||||
import * as dropbox from "./remoteForDropbox";
|
||||
import * as onedrive from "./remoteForOnedrive";
|
||||
import * as s3 from "./remoteForS3";
|
||||
import * as webdav from "./remoteForWebdav";
|
||||
import { Cipher } from "./encryptUnified";
|
||||
|
||||
export class RemoteClient {
|
||||
readonly serviceType: SUPPORTED_SERVICES_TYPE;
|
||||
readonly s3Config?: S3Config;
|
||||
readonly webdavClient?: webdav.WrappedWebdavClient;
|
||||
readonly webdavConfig?: WebdavConfig;
|
||||
readonly dropboxClient?: dropbox.WrappedDropboxClient;
|
||||
readonly dropboxConfig?: DropboxConfig;
|
||||
readonly onedriveClient?: onedrive.WrappedOnedriveClient;
|
||||
readonly onedriveConfig?: OnedriveConfig;
|
||||
|
||||
constructor(
|
||||
serviceType: SUPPORTED_SERVICES_TYPE,
|
||||
s3Config?: S3Config,
|
||||
webdavConfig?: WebdavConfig,
|
||||
dropboxConfig?: DropboxConfig,
|
||||
onedriveConfig?: OnedriveConfig,
|
||||
vaultName?: string,
|
||||
saveUpdatedConfigFunc?: () => Promise<any>
|
||||
) {
|
||||
this.serviceType = serviceType;
|
||||
// the client may modify the config inplace,
|
||||
// so we use a ref not copy of config here
|
||||
if (serviceType === "s3") {
|
||||
this.s3Config = s3Config;
|
||||
} else if (serviceType === "webdav") {
|
||||
if (vaultName === undefined || saveUpdatedConfigFunc === undefined) {
|
||||
throw Error(
|
||||
"remember to provide vault name and callback while init webdav client"
|
||||
);
|
||||
}
|
||||
const remoteBaseDir = webdavConfig!.remoteBaseDir || vaultName;
|
||||
this.webdavConfig = webdavConfig;
|
||||
this.webdavClient = webdav.getWebdavClient(
|
||||
this.webdavConfig!,
|
||||
remoteBaseDir,
|
||||
saveUpdatedConfigFunc
|
||||
);
|
||||
} else if (serviceType === "dropbox") {
|
||||
if (vaultName === undefined || saveUpdatedConfigFunc === undefined) {
|
||||
throw Error(
|
||||
"remember to provide vault name and callback while init dropbox client"
|
||||
);
|
||||
}
|
||||
const remoteBaseDir = dropboxConfig!.remoteBaseDir || vaultName;
|
||||
this.dropboxConfig = dropboxConfig;
|
||||
this.dropboxClient = dropbox.getDropboxClient(
|
||||
this.dropboxConfig!,
|
||||
remoteBaseDir,
|
||||
saveUpdatedConfigFunc
|
||||
);
|
||||
} else if (serviceType === "onedrive") {
|
||||
if (vaultName === undefined || saveUpdatedConfigFunc === undefined) {
|
||||
throw Error(
|
||||
"remember to provide vault name and callback while init onedrive client"
|
||||
);
|
||||
}
|
||||
const remoteBaseDir = onedriveConfig!.remoteBaseDir || vaultName;
|
||||
this.onedriveConfig = onedriveConfig;
|
||||
this.onedriveClient = onedrive.getOnedriveClient(
|
||||
this.onedriveConfig!,
|
||||
remoteBaseDir,
|
||||
saveUpdatedConfigFunc
|
||||
);
|
||||
} else {
|
||||
throw Error(`not supported service type ${this.serviceType}`);
|
||||
}
|
||||
}
|
||||
|
||||
getRemoteMeta = async (fileOrFolderPath: string) => {
|
||||
if (this.serviceType === "s3") {
|
||||
return await s3.getRemoteMeta(
|
||||
s3.getS3Client(this.s3Config!),
|
||||
this.s3Config!,
|
||||
fileOrFolderPath
|
||||
);
|
||||
} else if (this.serviceType === "webdav") {
|
||||
return await webdav.getRemoteMeta(this.webdavClient!, fileOrFolderPath);
|
||||
} else if (this.serviceType === "dropbox") {
|
||||
return await dropbox.getRemoteMeta(this.dropboxClient!, fileOrFolderPath);
|
||||
} else if (this.serviceType === "onedrive") {
|
||||
return await onedrive.getRemoteMeta(
|
||||
this.onedriveClient!,
|
||||
fileOrFolderPath
|
||||
);
|
||||
} else {
|
||||
throw Error(`not supported service type ${this.serviceType}`);
|
||||
}
|
||||
};
|
||||
|
||||
uploadToRemote = async (
|
||||
fileOrFolderPath: string,
|
||||
vault: Vault | undefined,
|
||||
isRecursively: boolean,
|
||||
cipher: Cipher,
|
||||
remoteEncryptedKey: string = "",
|
||||
foldersCreatedBefore: Set<string> | undefined = undefined,
|
||||
uploadRaw: boolean = false,
|
||||
rawContent: string | ArrayBuffer = ""
|
||||
): Promise<UploadedType> => {
|
||||
if (this.serviceType === "s3") {
|
||||
return await s3.uploadToRemote(
|
||||
s3.getS3Client(this.s3Config!),
|
||||
this.s3Config!,
|
||||
fileOrFolderPath,
|
||||
vault,
|
||||
isRecursively,
|
||||
cipher,
|
||||
remoteEncryptedKey,
|
||||
uploadRaw,
|
||||
rawContent
|
||||
);
|
||||
} else if (this.serviceType === "webdav") {
|
||||
return await webdav.uploadToRemote(
|
||||
this.webdavClient!,
|
||||
fileOrFolderPath,
|
||||
vault,
|
||||
isRecursively,
|
||||
cipher,
|
||||
remoteEncryptedKey,
|
||||
uploadRaw,
|
||||
rawContent
|
||||
);
|
||||
} else if (this.serviceType === "dropbox") {
|
||||
return await dropbox.uploadToRemote(
|
||||
this.dropboxClient!,
|
||||
fileOrFolderPath,
|
||||
vault,
|
||||
isRecursively,
|
||||
cipher,
|
||||
remoteEncryptedKey,
|
||||
foldersCreatedBefore,
|
||||
uploadRaw,
|
||||
rawContent
|
||||
);
|
||||
} else if (this.serviceType === "onedrive") {
|
||||
return await onedrive.uploadToRemote(
|
||||
this.onedriveClient!,
|
||||
fileOrFolderPath,
|
||||
vault,
|
||||
isRecursively,
|
||||
cipher,
|
||||
remoteEncryptedKey,
|
||||
foldersCreatedBefore,
|
||||
uploadRaw,
|
||||
rawContent
|
||||
);
|
||||
} else {
|
||||
throw Error(`not supported service type ${this.serviceType}`);
|
||||
}
|
||||
};
|
||||
|
||||
listAllFromRemote = async (): Promise<Entity[]> => {
|
||||
if (this.serviceType === "s3") {
|
||||
return await s3.listAllFromRemote(
|
||||
s3.getS3Client(this.s3Config!),
|
||||
this.s3Config!
|
||||
);
|
||||
} else if (this.serviceType === "webdav") {
|
||||
return await webdav.listAllFromRemote(this.webdavClient!);
|
||||
} else if (this.serviceType === "dropbox") {
|
||||
return await dropbox.listAllFromRemote(this.dropboxClient!);
|
||||
} else if (this.serviceType === "onedrive") {
|
||||
return await onedrive.listAllFromRemote(this.onedriveClient!);
|
||||
} else {
|
||||
throw Error(`not supported service type ${this.serviceType}`);
|
||||
}
|
||||
};
|
||||
|
||||
downloadFromRemote = async (
|
||||
fileOrFolderPath: string,
|
||||
vault: Vault,
|
||||
mtime: number,
|
||||
cipher: Cipher,
|
||||
remoteEncryptedKey: string = "",
|
||||
skipSaving: boolean = false
|
||||
) => {
|
||||
if (this.serviceType === "s3") {
|
||||
return await s3.downloadFromRemote(
|
||||
s3.getS3Client(this.s3Config!),
|
||||
this.s3Config!,
|
||||
fileOrFolderPath,
|
||||
vault,
|
||||
mtime,
|
||||
cipher,
|
||||
remoteEncryptedKey,
|
||||
skipSaving
|
||||
);
|
||||
} else if (this.serviceType === "webdav") {
|
||||
return await webdav.downloadFromRemote(
|
||||
this.webdavClient!,
|
||||
fileOrFolderPath,
|
||||
vault,
|
||||
mtime,
|
||||
cipher,
|
||||
remoteEncryptedKey,
|
||||
skipSaving
|
||||
);
|
||||
} else if (this.serviceType === "dropbox") {
|
||||
return await dropbox.downloadFromRemote(
|
||||
this.dropboxClient!,
|
||||
fileOrFolderPath,
|
||||
vault,
|
||||
mtime,
|
||||
cipher,
|
||||
remoteEncryptedKey,
|
||||
skipSaving
|
||||
);
|
||||
} else if (this.serviceType === "onedrive") {
|
||||
return await onedrive.downloadFromRemote(
|
||||
this.onedriveClient!,
|
||||
fileOrFolderPath,
|
||||
vault,
|
||||
mtime,
|
||||
cipher,
|
||||
remoteEncryptedKey,
|
||||
skipSaving
|
||||
);
|
||||
} else {
|
||||
throw Error(`not supported service type ${this.serviceType}`);
|
||||
}
|
||||
};
|
||||
|
||||
deleteFromRemote = async (
|
||||
fileOrFolderPath: string,
|
||||
cipher: Cipher,
|
||||
remoteEncryptedKey: string = ""
|
||||
) => {
|
||||
if (this.serviceType === "s3") {
|
||||
return await s3.deleteFromRemote(
|
||||
s3.getS3Client(this.s3Config!),
|
||||
this.s3Config!,
|
||||
fileOrFolderPath,
|
||||
cipher,
|
||||
remoteEncryptedKey
|
||||
);
|
||||
} else if (this.serviceType === "webdav") {
|
||||
return await webdav.deleteFromRemote(
|
||||
this.webdavClient!,
|
||||
fileOrFolderPath,
|
||||
cipher,
|
||||
remoteEncryptedKey
|
||||
);
|
||||
} else if (this.serviceType === "dropbox") {
|
||||
return await dropbox.deleteFromRemote(
|
||||
this.dropboxClient!,
|
||||
fileOrFolderPath,
|
||||
cipher,
|
||||
remoteEncryptedKey
|
||||
);
|
||||
} else if (this.serviceType === "onedrive") {
|
||||
return await onedrive.deleteFromRemote(
|
||||
this.onedriveClient!,
|
||||
fileOrFolderPath,
|
||||
cipher,
|
||||
remoteEncryptedKey
|
||||
);
|
||||
} else {
|
||||
throw Error(`not supported service type ${this.serviceType}`);
|
||||
}
|
||||
};
|
||||
|
||||
checkConnectivity = async (callbackFunc?: any) => {
|
||||
if (this.serviceType === "s3") {
|
||||
return await s3.checkConnectivity(
|
||||
s3.getS3Client(this.s3Config!),
|
||||
this.s3Config!,
|
||||
callbackFunc
|
||||
);
|
||||
} else if (this.serviceType === "webdav") {
|
||||
return await webdav.checkConnectivity(this.webdavClient!, callbackFunc);
|
||||
} else if (this.serviceType === "dropbox") {
|
||||
return await dropbox.checkConnectivity(this.dropboxClient!, callbackFunc);
|
||||
} else if (this.serviceType === "onedrive") {
|
||||
return await onedrive.checkConnectivity(
|
||||
this.onedriveClient!,
|
||||
callbackFunc
|
||||
);
|
||||
} else {
|
||||
throw Error(`not supported service type ${this.serviceType}`);
|
||||
}
|
||||
};
|
||||
|
||||
getUser = async () => {
|
||||
if (this.serviceType === "dropbox") {
|
||||
return await dropbox.getUserDisplayName(this.dropboxClient!);
|
||||
} else if (this.serviceType === "onedrive") {
|
||||
return await onedrive.getUserDisplayName(this.onedriveClient!);
|
||||
} else {
|
||||
throw Error(`not supported service type ${this.serviceType}`);
|
||||
}
|
||||
};
|
||||
|
||||
revokeAuth = async () => {
|
||||
if (this.serviceType === "dropbox") {
|
||||
return await dropbox.revokeAuth(this.dropboxClient!);
|
||||
} else {
|
||||
throw Error(`not supported service type ${this.serviceType}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,820 +0,0 @@
|
||||
import type { _Object } from "@aws-sdk/client-s3";
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommand,
|
||||
HeadObjectCommand,
|
||||
HeadObjectCommandOutput,
|
||||
ListObjectsV2Command,
|
||||
ListObjectsV2CommandInput,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { Upload } from "@aws-sdk/lib-storage";
|
||||
import { HttpHandler, HttpRequest, HttpResponse } from "@smithy/protocol-http";
|
||||
import {
|
||||
FetchHttpHandler,
|
||||
FetchHttpHandlerOptions,
|
||||
} from "@smithy/fetch-http-handler";
|
||||
// @ts-ignore
|
||||
import { requestTimeout } from "@smithy/fetch-http-handler/dist-es/request-timeout";
|
||||
import { buildQueryString } from "@smithy/querystring-builder";
|
||||
import { HeaderBag, HttpHandlerOptions, Provider } from "@aws-sdk/types";
|
||||
import { Buffer } from "buffer";
|
||||
import * as mime from "mime-types";
|
||||
import { Vault, requestUrl, RequestUrlParam, Platform } from "obsidian";
|
||||
import { Readable } from "stream";
|
||||
import * as path from "path";
|
||||
import AggregateError from "aggregate-error";
|
||||
import {
|
||||
DEFAULT_CONTENT_TYPE,
|
||||
Entity,
|
||||
S3Config,
|
||||
UploadedType,
|
||||
VALID_REQURL,
|
||||
} from "./baseTypes";
|
||||
import {
|
||||
arrayBufferToBuffer,
|
||||
bufferToArrayBuffer,
|
||||
mkdirpInVault,
|
||||
} from "./misc";
|
||||
|
||||
export { S3Client } from "@aws-sdk/client-s3";
|
||||
|
||||
import PQueue from "p-queue";
|
||||
import { Cipher } from "./encryptUnified";
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// special handler using Obsidian requestUrl
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/**
|
||||
* This is close to origin implementation of FetchHttpHandler
|
||||
* https://github.com/aws/aws-sdk-js-v3/blob/main/packages/fetch-http-handler/src/fetch-http-handler.ts
|
||||
* that is released under Apache 2 License.
|
||||
* But this uses Obsidian requestUrl instead.
|
||||
*/
|
||||
class ObsHttpHandler extends FetchHttpHandler {
|
||||
requestTimeoutInMs: number | undefined;
|
||||
constructor(options?: FetchHttpHandlerOptions) {
|
||||
super(options);
|
||||
this.requestTimeoutInMs =
|
||||
options === undefined ? undefined : options.requestTimeout;
|
||||
}
|
||||
async handle(
|
||||
request: HttpRequest,
|
||||
{ abortSignal }: HttpHandlerOptions = {}
|
||||
): Promise<{ response: HttpResponse }> {
|
||||
if (abortSignal?.aborted) {
|
||||
const abortError = new Error("Request aborted");
|
||||
abortError.name = "AbortError";
|
||||
return Promise.reject(abortError);
|
||||
}
|
||||
|
||||
let path = request.path;
|
||||
if (request.query) {
|
||||
const queryString = buildQueryString(request.query);
|
||||
if (queryString) {
|
||||
path += `?${queryString}`;
|
||||
}
|
||||
}
|
||||
|
||||
const { port, method } = request;
|
||||
const url = `${request.protocol}//${request.hostname}${
|
||||
port ? `:${port}` : ""
|
||||
}${path}`;
|
||||
const body =
|
||||
method === "GET" || method === "HEAD" ? undefined : request.body;
|
||||
|
||||
const transformedHeaders: Record<string, string> = {};
|
||||
for (const key of Object.keys(request.headers)) {
|
||||
const keyLower = key.toLowerCase();
|
||||
if (keyLower === "host" || keyLower === "content-length") {
|
||||
continue;
|
||||
}
|
||||
transformedHeaders[keyLower] = request.headers[key];
|
||||
}
|
||||
|
||||
let contentType: string | undefined = undefined;
|
||||
if (transformedHeaders["content-type"] !== undefined) {
|
||||
contentType = transformedHeaders["content-type"];
|
||||
}
|
||||
|
||||
let transformedBody: any = body;
|
||||
if (ArrayBuffer.isView(body)) {
|
||||
transformedBody = bufferToArrayBuffer(body);
|
||||
}
|
||||
|
||||
const param: RequestUrlParam = {
|
||||
body: transformedBody,
|
||||
headers: transformedHeaders,
|
||||
method: method,
|
||||
url: url,
|
||||
contentType: contentType,
|
||||
};
|
||||
|
||||
const raceOfPromises = [
|
||||
requestUrl(param).then((rsp) => {
|
||||
const headers = rsp.headers;
|
||||
const headersLower: Record<string, string> = {};
|
||||
for (const key of Object.keys(headers)) {
|
||||
headersLower[key.toLowerCase()] = headers[key];
|
||||
}
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(rsp.arrayBuffer));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new HttpResponse({
|
||||
headers: headersLower,
|
||||
statusCode: rsp.status,
|
||||
body: stream,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
requestTimeout(this.requestTimeoutInMs),
|
||||
];
|
||||
|
||||
if (abortSignal) {
|
||||
raceOfPromises.push(
|
||||
new Promise<never>((resolve, reject) => {
|
||||
abortSignal.onabort = () => {
|
||||
const abortError = new Error("Request aborted");
|
||||
abortError.name = "AbortError";
|
||||
reject(abortError);
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.race(raceOfPromises);
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// other stuffs
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
export const DEFAULT_S3_CONFIG: S3Config = {
|
||||
s3Endpoint: "",
|
||||
s3Region: "",
|
||||
s3AccessKeyID: "",
|
||||
s3SecretAccessKey: "",
|
||||
s3BucketName: "",
|
||||
bypassCorsLocally: true,
|
||||
partsConcurrency: 20,
|
||||
forcePathStyle: false,
|
||||
remotePrefix: "",
|
||||
useAccurateMTime: false, // it causes money, disable by default
|
||||
};
|
||||
|
||||
export type S3ObjectType = _Object;
|
||||
|
||||
export const simpleTransRemotePrefix = (x: string) => {
|
||||
if (x === undefined) {
|
||||
return "";
|
||||
}
|
||||
let y = path.posix.normalize(x.trim());
|
||||
if (y === undefined || y === "" || y === "/" || y === ".") {
|
||||
return "";
|
||||
}
|
||||
if (y.startsWith("/")) {
|
||||
y = y.slice(1);
|
||||
}
|
||||
if (!y.endsWith("/")) {
|
||||
y = `${y}/`;
|
||||
}
|
||||
return y;
|
||||
};
|
||||
|
||||
const getRemoteWithPrefixPath = (
|
||||
fileOrFolderPath: string,
|
||||
remotePrefix: string
|
||||
) => {
|
||||
let key = fileOrFolderPath;
|
||||
if (fileOrFolderPath === "/" || fileOrFolderPath === "") {
|
||||
// special
|
||||
key = remotePrefix;
|
||||
}
|
||||
if (!fileOrFolderPath.startsWith("/")) {
|
||||
key = `${remotePrefix}${fileOrFolderPath}`;
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
const getLocalNoPrefixPath = (
|
||||
fileOrFolderPathWithRemotePrefix: string,
|
||||
remotePrefix: string
|
||||
) => {
|
||||
if (
|
||||
!(
|
||||
fileOrFolderPathWithRemotePrefix === `${remotePrefix}` ||
|
||||
fileOrFolderPathWithRemotePrefix.startsWith(`${remotePrefix}`)
|
||||
)
|
||||
) {
|
||||
throw Error(
|
||||
`"${fileOrFolderPathWithRemotePrefix}" doesn't starts with "${remotePrefix}"`
|
||||
);
|
||||
}
|
||||
return fileOrFolderPathWithRemotePrefix.slice(`${remotePrefix}`.length);
|
||||
};
|
||||
|
||||
const fromS3ObjectToEntity = (
|
||||
x: S3ObjectType,
|
||||
remotePrefix: string,
|
||||
mtimeRecords: Record<string, number>,
|
||||
ctimeRecords: Record<string, number>
|
||||
) => {
|
||||
// console.debug(`fromS3ObjectToEntity: ${x.Key!}, ${JSON.stringify(x,null,2)}`);
|
||||
// S3 officially only supports seconds precision!!!!!
|
||||
const mtimeSvr = Math.floor(x.LastModified!.valueOf() / 1000.0) * 1000;
|
||||
let mtimeCli = mtimeSvr;
|
||||
if (x.Key! in mtimeRecords) {
|
||||
const m2 = mtimeRecords[x.Key!];
|
||||
if (m2 !== 0) {
|
||||
// to be compatible with RClone, we read and store the time in seconds in new version!
|
||||
if (m2 >= 1000000000000) {
|
||||
// it's a millsecond, uploaded by old codes..
|
||||
mtimeCli = m2;
|
||||
} else {
|
||||
// it's a second, uploaded by new codes of the plugin from March 24, 2024
|
||||
mtimeCli = m2 * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
const key = getLocalNoPrefixPath(x.Key!, remotePrefix);
|
||||
const r: Entity = {
|
||||
keyRaw: key,
|
||||
mtimeSvr: mtimeSvr,
|
||||
mtimeCli: mtimeCli,
|
||||
sizeRaw: x.Size!,
|
||||
etag: x.ETag,
|
||||
};
|
||||
return r;
|
||||
};
|
||||
|
||||
const fromS3HeadObjectToEntity = (
|
||||
fileOrFolderPathWithRemotePrefix: string,
|
||||
x: HeadObjectCommandOutput,
|
||||
remotePrefix: string
|
||||
) => {
|
||||
// console.debug(`fromS3HeadObjectToEntity: ${fileOrFolderPathWithRemotePrefix}: ${JSON.stringify(x,null,2)}`);
|
||||
// S3 officially only supports seconds precision!!!!!
|
||||
const mtimeSvr = Math.floor(x.LastModified!.valueOf() / 1000.0) * 1000;
|
||||
let mtimeCli = mtimeSvr;
|
||||
if (x.Metadata !== undefined) {
|
||||
const m2 = Math.floor(
|
||||
parseFloat(x.Metadata.mtime || x.Metadata.MTime || "0")
|
||||
);
|
||||
if (m2 !== 0) {
|
||||
// to be compatible with RClone, we read and store the time in seconds in new version!
|
||||
if (m2 >= 1000000000000) {
|
||||
// it's a millsecond, uploaded by old codes..
|
||||
mtimeCli = m2;
|
||||
} else {
|
||||
// it's a second, uploaded by new codes of the plugin from March 24, 2024
|
||||
mtimeCli = m2 * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
// console.debug(
|
||||
// `fromS3HeadObjectToEntity, fileOrFolderPathWithRemotePrefix=${fileOrFolderPathWithRemotePrefix}, remotePrefix=${remotePrefix}, x=${JSON.stringify(
|
||||
// x
|
||||
// )} `
|
||||
// );
|
||||
const key = getLocalNoPrefixPath(
|
||||
fileOrFolderPathWithRemotePrefix,
|
||||
remotePrefix
|
||||
);
|
||||
// console.debug(`fromS3HeadObjectToEntity, key=${key} after removing prefix`);
|
||||
return {
|
||||
keyRaw: key,
|
||||
mtimeSvr: mtimeSvr,
|
||||
mtimeCli: mtimeCli,
|
||||
sizeRaw: x.ContentLength,
|
||||
etag: x.ETag,
|
||||
} as Entity;
|
||||
};
|
||||
|
||||
export const getS3Client = (s3Config: S3Config) => {
|
||||
let endpoint = s3Config.s3Endpoint;
|
||||
if (!(endpoint.startsWith("http://") || endpoint.startsWith("https://"))) {
|
||||
endpoint = `https://${endpoint}`;
|
||||
}
|
||||
|
||||
let s3Client: S3Client;
|
||||
|
||||
if (VALID_REQURL && s3Config.bypassCorsLocally) {
|
||||
s3Client = new S3Client({
|
||||
region: s3Config.s3Region,
|
||||
endpoint: endpoint,
|
||||
forcePathStyle: s3Config.forcePathStyle,
|
||||
credentials: {
|
||||
accessKeyId: s3Config.s3AccessKeyID,
|
||||
secretAccessKey: s3Config.s3SecretAccessKey,
|
||||
},
|
||||
requestHandler: new ObsHttpHandler(),
|
||||
});
|
||||
} else {
|
||||
s3Client = new S3Client({
|
||||
region: s3Config.s3Region,
|
||||
endpoint: endpoint,
|
||||
forcePathStyle: s3Config.forcePathStyle,
|
||||
credentials: {
|
||||
accessKeyId: s3Config.s3AccessKeyID,
|
||||
secretAccessKey: s3Config.s3SecretAccessKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
s3Client.middlewareStack.add(
|
||||
(next, context) => (args) => {
|
||||
(args.request as any).headers["cache-control"] = "no-cache";
|
||||
return next(args);
|
||||
},
|
||||
{
|
||||
step: "build",
|
||||
}
|
||||
);
|
||||
|
||||
return s3Client;
|
||||
};
|
||||
|
||||
export const getRemoteMeta = async (
|
||||
s3Client: S3Client,
|
||||
s3Config: S3Config,
|
||||
fileOrFolderPathWithRemotePrefix: string
|
||||
) => {
|
||||
if (
|
||||
s3Config.remotePrefix !== undefined &&
|
||||
s3Config.remotePrefix !== "" &&
|
||||
!fileOrFolderPathWithRemotePrefix.startsWith(s3Config.remotePrefix)
|
||||
) {
|
||||
throw Error(`s3 getRemoteMeta should only accept prefix-ed path`);
|
||||
}
|
||||
const res = await s3Client.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: s3Config.s3BucketName,
|
||||
Key: fileOrFolderPathWithRemotePrefix,
|
||||
})
|
||||
);
|
||||
|
||||
return fromS3HeadObjectToEntity(
|
||||
fileOrFolderPathWithRemotePrefix,
|
||||
res,
|
||||
s3Config.remotePrefix ?? ""
|
||||
);
|
||||
};
|
||||
|
||||
export const uploadToRemote = async (
|
||||
s3Client: S3Client,
|
||||
s3Config: S3Config,
|
||||
fileOrFolderPath: string,
|
||||
vault: Vault | undefined,
|
||||
isRecursively: boolean,
|
||||
cipher: Cipher,
|
||||
remoteEncryptedKey: string = "",
|
||||
uploadRaw: boolean = false,
|
||||
rawContent: string | ArrayBuffer = "",
|
||||
rawContentMTime: number = 0,
|
||||
rawContentCTime: number = 0
|
||||
): Promise<UploadedType> => {
|
||||
console.debug(`uploading ${fileOrFolderPath}`);
|
||||
let uploadFile = fileOrFolderPath;
|
||||
if (!cipher.isPasswordEmpty()) {
|
||||
if (remoteEncryptedKey === undefined || remoteEncryptedKey === "") {
|
||||
throw Error(
|
||||
`uploadToRemote(s3) you have password but remoteEncryptedKey is empty!`
|
||||
);
|
||||
}
|
||||
uploadFile = remoteEncryptedKey;
|
||||
}
|
||||
uploadFile = getRemoteWithPrefixPath(uploadFile, s3Config.remotePrefix ?? "");
|
||||
// console.debug(`actual uploadFile=${uploadFile}`);
|
||||
const isFolder = fileOrFolderPath.endsWith("/");
|
||||
|
||||
if (isFolder && isRecursively) {
|
||||
throw Error("upload function doesn't implement recursive function yet!");
|
||||
} else if (isFolder && !isRecursively) {
|
||||
if (uploadRaw) {
|
||||
throw Error(`you specify uploadRaw, but you also provide a folder key!`);
|
||||
}
|
||||
// folder
|
||||
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;
|
||||
await s3Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: s3Config.s3BucketName,
|
||||
Key: uploadFile,
|
||||
Body: "",
|
||||
ContentType: contentType,
|
||||
Metadata: {
|
||||
MTime: `${mtime / 1000.0}`,
|
||||
CTime: `${ctime / 1000.0}`,
|
||||
},
|
||||
})
|
||||
);
|
||||
const res = await getRemoteMeta(s3Client, s3Config, uploadFile);
|
||||
return {
|
||||
entity: res,
|
||||
mtimeCli: mtime,
|
||||
};
|
||||
} else {
|
||||
// file
|
||||
// we ignore isRecursively parameter here
|
||||
let contentType = DEFAULT_CONTENT_TYPE;
|
||||
if (cipher.isPasswordEmpty()) {
|
||||
contentType =
|
||||
mime.contentType(
|
||||
mime.lookup(fileOrFolderPath) || DEFAULT_CONTENT_TYPE
|
||||
) || DEFAULT_CONTENT_TYPE;
|
||||
}
|
||||
let localContent = undefined;
|
||||
let mtime = 0;
|
||||
let ctime = 0;
|
||||
if (uploadRaw) {
|
||||
if (typeof rawContent === "string") {
|
||||
localContent = new TextEncoder().encode(rawContent).buffer;
|
||||
} else {
|
||||
localContent = rawContent;
|
||||
}
|
||||
mtime = rawContentMTime;
|
||||
ctime = rawContentCTime;
|
||||
} 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);
|
||||
const s = await vault.adapter.stat(fileOrFolderPath);
|
||||
if (s !== undefined && s !== null) {
|
||||
mtime = s.mtime;
|
||||
ctime = s.ctime;
|
||||
}
|
||||
}
|
||||
let remoteContent = localContent;
|
||||
if (!cipher.isPasswordEmpty()) {
|
||||
remoteContent = await cipher.encryptContent(localContent);
|
||||
}
|
||||
|
||||
const bytesIn5MB = 5242880;
|
||||
const body = new Uint8Array(remoteContent);
|
||||
|
||||
const upload = new Upload({
|
||||
client: s3Client,
|
||||
queueSize: s3Config.partsConcurrency, // concurrency
|
||||
partSize: bytesIn5MB, // minimal 5MB by default
|
||||
leavePartsOnError: false,
|
||||
params: {
|
||||
Bucket: s3Config.s3BucketName,
|
||||
Key: uploadFile,
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
Metadata: {
|
||||
MTime: `${mtime / 1000.0}`,
|
||||
CTime: `${ctime / 1000.0}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
upload.on("httpUploadProgress", (progress) => {
|
||||
// console.info(progress);
|
||||
});
|
||||
await upload.done();
|
||||
|
||||
const res = await getRemoteMeta(s3Client, s3Config, uploadFile);
|
||||
// console.debug(
|
||||
// `uploaded ${uploadFile} with res=${JSON.stringify(res, null, 2)}`
|
||||
// );
|
||||
return {
|
||||
entity: res,
|
||||
mtimeCli: mtime,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const listFromRemoteRaw = async (
|
||||
s3Client: S3Client,
|
||||
s3Config: S3Config,
|
||||
prefixOfRawKeys?: string
|
||||
) => {
|
||||
const confCmd = {
|
||||
Bucket: s3Config.s3BucketName,
|
||||
} as ListObjectsV2CommandInput;
|
||||
if (prefixOfRawKeys !== undefined && prefixOfRawKeys !== "") {
|
||||
confCmd.Prefix = prefixOfRawKeys;
|
||||
}
|
||||
|
||||
const contents = [] as _Object[];
|
||||
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;
|
||||
do {
|
||||
const rsp = await s3Client.send(new ListObjectsV2Command(confCmd));
|
||||
|
||||
if (rsp.$metadata.httpStatusCode !== 200) {
|
||||
throw Error("some thing bad while listing remote!");
|
||||
}
|
||||
if (rsp.Contents === undefined) {
|
||||
break;
|
||||
}
|
||||
contents.push(...rsp.Contents);
|
||||
|
||||
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;
|
||||
if (
|
||||
isTruncated &&
|
||||
(confCmd.ContinuationToken === undefined ||
|
||||
confCmd.ContinuationToken === "")
|
||||
) {
|
||||
throw Error("isTruncated is true but no continuationToken provided");
|
||||
}
|
||||
} while (isTruncated);
|
||||
|
||||
// wait for any head requests
|
||||
await queueHead.onIdle();
|
||||
|
||||
// ensemble fake rsp
|
||||
// in the end, we need to transform the response list
|
||||
// back to the local contents-alike list
|
||||
return contents.map((x) =>
|
||||
fromS3ObjectToEntity(
|
||||
x,
|
||||
s3Config.remotePrefix ?? "",
|
||||
mtimeRecords,
|
||||
ctimeRecords
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const listAllFromRemote = async (
|
||||
s3Client: S3Client,
|
||||
s3Config: S3Config
|
||||
) => {
|
||||
return await listFromRemoteRaw(s3Client, s3Config, s3Config.remotePrefix);
|
||||
};
|
||||
|
||||
/**
|
||||
* The Body of resp of aws GetObject has mix types
|
||||
* and we want to get ArrayBuffer here.
|
||||
* See https://github.com/aws/aws-sdk-js-v3/issues/1877
|
||||
* @param b The Body of GetObject
|
||||
* @returns Promise<ArrayBuffer>
|
||||
*/
|
||||
const getObjectBodyToArrayBuffer = async (
|
||||
b: Readable | ReadableStream | Blob | undefined
|
||||
) => {
|
||||
if (b === undefined) {
|
||||
throw Error(`ObjectBody is undefined and don't know how to deal with it`);
|
||||
}
|
||||
if (b instanceof Readable) {
|
||||
return (await new Promise((resolve, reject) => {
|
||||
const chunks: Uint8Array[] = [];
|
||||
b.on("data", (chunk) => chunks.push(chunk));
|
||||
b.on("error", reject);
|
||||
b.on("end", () => resolve(bufferToArrayBuffer(Buffer.concat(chunks))));
|
||||
})) as ArrayBuffer;
|
||||
} else if (b instanceof ReadableStream) {
|
||||
return await new Response(b, {}).arrayBuffer();
|
||||
} else if (b instanceof Blob) {
|
||||
return await b.arrayBuffer();
|
||||
} else {
|
||||
throw TypeError(`The type of ${b} is not one of the supported types`);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadFromRemoteRaw = async (
|
||||
s3Client: S3Client,
|
||||
s3Config: S3Config,
|
||||
fileOrFolderPathWithRemotePrefix: string
|
||||
) => {
|
||||
if (
|
||||
s3Config.remotePrefix !== undefined &&
|
||||
s3Config.remotePrefix !== "" &&
|
||||
!fileOrFolderPathWithRemotePrefix.startsWith(s3Config.remotePrefix)
|
||||
) {
|
||||
throw Error(`downloadFromRemoteRaw should only accept prefix-ed path`);
|
||||
}
|
||||
const data = await s3Client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: s3Config.s3BucketName,
|
||||
Key: fileOrFolderPathWithRemotePrefix,
|
||||
})
|
||||
);
|
||||
const bodyContents = await getObjectBodyToArrayBuffer(data.Body);
|
||||
return bodyContents;
|
||||
};
|
||||
|
||||
export const downloadFromRemote = async (
|
||||
s3Client: S3Client,
|
||||
s3Config: S3Config,
|
||||
fileOrFolderPath: string,
|
||||
vault: Vault,
|
||||
mtime: number,
|
||||
cipher: Cipher,
|
||||
remoteEncryptedKey: string,
|
||||
skipSaving: boolean = false
|
||||
) => {
|
||||
const isFolder = fileOrFolderPath.endsWith("/");
|
||||
|
||||
if (!skipSaving) {
|
||||
await mkdirpInVault(fileOrFolderPath, vault);
|
||||
}
|
||||
|
||||
// the file is always local file
|
||||
// we need to encrypt it
|
||||
|
||||
if (isFolder) {
|
||||
// mkdirp locally is enough
|
||||
// do nothing here
|
||||
return new ArrayBuffer(0);
|
||||
} else {
|
||||
let downloadFile = fileOrFolderPath;
|
||||
if (!cipher.isPasswordEmpty()) {
|
||||
downloadFile = remoteEncryptedKey;
|
||||
}
|
||||
downloadFile = getRemoteWithPrefixPath(
|
||||
downloadFile,
|
||||
s3Config.remotePrefix ?? ""
|
||||
);
|
||||
const remoteContent = await downloadFromRemoteRaw(
|
||||
s3Client,
|
||||
s3Config,
|
||||
downloadFile
|
||||
);
|
||||
let localContent = remoteContent;
|
||||
if (!cipher.isPasswordEmpty()) {
|
||||
localContent = await cipher.decryptContent(remoteContent);
|
||||
}
|
||||
if (!skipSaving) {
|
||||
await vault.adapter.writeBinary(fileOrFolderPath, localContent, {
|
||||
mtime: mtime,
|
||||
});
|
||||
}
|
||||
return localContent;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This function deals with file normally and "folder" recursively.
|
||||
* @param s3Client
|
||||
* @param s3Config
|
||||
* @param fileOrFolderPath
|
||||
* @returns
|
||||
*/
|
||||
export const deleteFromRemote = async (
|
||||
s3Client: S3Client,
|
||||
s3Config: S3Config,
|
||||
fileOrFolderPath: string,
|
||||
cipher: Cipher,
|
||||
remoteEncryptedKey: string = ""
|
||||
) => {
|
||||
if (fileOrFolderPath === "/") {
|
||||
return;
|
||||
}
|
||||
let remoteFileName = fileOrFolderPath;
|
||||
if (!cipher.isPasswordEmpty()) {
|
||||
remoteFileName = remoteEncryptedKey;
|
||||
}
|
||||
remoteFileName = getRemoteWithPrefixPath(
|
||||
remoteFileName,
|
||||
s3Config.remotePrefix ?? ""
|
||||
);
|
||||
await s3Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: s3Config.s3BucketName,
|
||||
Key: remoteFileName,
|
||||
})
|
||||
);
|
||||
|
||||
if (fileOrFolderPath.endsWith("/") && cipher.isPasswordEmpty()) {
|
||||
const x = await listFromRemoteRaw(s3Client, s3Config, remoteFileName);
|
||||
x.forEach(async (element) => {
|
||||
await s3Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: s3Config.s3BucketName,
|
||||
Key: element.key,
|
||||
})
|
||||
);
|
||||
});
|
||||
} else if (fileOrFolderPath.endsWith("/") && !cipher.isPasswordEmpty()) {
|
||||
// TODO
|
||||
} else {
|
||||
// pass
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check the config of S3 by heading bucket
|
||||
* https://stackoverflow.com/questions/50842835
|
||||
*
|
||||
* Updated on 20240102:
|
||||
* Users are not always have permission of heading bucket,
|
||||
* so we need to use listing objects instead...
|
||||
*
|
||||
* @param s3Client
|
||||
* @param s3Config
|
||||
* @returns
|
||||
*/
|
||||
export const checkConnectivity = async (
|
||||
s3Client: S3Client,
|
||||
s3Config: S3Config,
|
||||
callbackFunc?: any
|
||||
) => {
|
||||
try {
|
||||
// TODO: no universal way now, just check this in connectivity
|
||||
if (Platform.isIosApp && !s3Config.s3Endpoint.startsWith("https")) {
|
||||
throw Error(
|
||||
`Your s3 endpoint could only be https, not http, because of the iOS restriction.`
|
||||
);
|
||||
}
|
||||
|
||||
// const results = await s3Client.send(
|
||||
// new HeadBucketCommand({ Bucket: s3Config.s3BucketName })
|
||||
// );
|
||||
// very simplified version of listing objects
|
||||
const confCmd = {
|
||||
Bucket: s3Config.s3BucketName,
|
||||
} as ListObjectsV2CommandInput;
|
||||
const results = await s3Client.send(new ListObjectsV2Command(confCmd));
|
||||
|
||||
if (
|
||||
results === undefined ||
|
||||
results.$metadata === undefined ||
|
||||
results.$metadata.httpStatusCode === undefined
|
||||
) {
|
||||
const err = "results or $metadata or httStatusCode is undefined";
|
||||
console.debug(err);
|
||||
if (callbackFunc !== undefined) {
|
||||
callbackFunc(err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return results.$metadata.httpStatusCode === 200;
|
||||
} catch (err: any) {
|
||||
console.debug(err);
|
||||
if (callbackFunc !== undefined) {
|
||||
if (s3Config.s3Endpoint.contains(s3Config.s3BucketName)) {
|
||||
const err2 = new AggregateError([
|
||||
err,
|
||||
new Error(
|
||||
"Maybe you've included the bucket name inside the endpoint setting. Please remove the bucket name and try again."
|
||||
),
|
||||
]);
|
||||
callbackFunc(err2);
|
||||
} else {
|
||||
callbackFunc(err);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1,608 +0,0 @@
|
||||
import { Buffer } from "buffer";
|
||||
import { Platform, Vault, requestUrl } from "obsidian";
|
||||
|
||||
import { Queue } from "@fyears/tsqueue";
|
||||
import chunk from "lodash/chunk";
|
||||
import flatten from "lodash/flatten";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import { getReasonPhrase } from "http-status-codes";
|
||||
import { Entity, UploadedType, VALID_REQURL, WebdavConfig } from "./baseTypes";
|
||||
import { bufferToArrayBuffer, getPathFolder, mkdirpInVault } from "./misc";
|
||||
import { Cipher } from "./encryptUnified";
|
||||
|
||||
import type {
|
||||
FileStat,
|
||||
WebDAVClient,
|
||||
RequestOptionsWithState,
|
||||
// Response,
|
||||
// ResponseDataDetailed,
|
||||
} from "webdav";
|
||||
|
||||
/**
|
||||
* https://stackoverflow.com/questions/32850898/how-to-check-if-a-string-has-any-non-iso-8859-1-characters-with-javascript
|
||||
* @param str
|
||||
* @returns true if all are iso 8859 1 chars
|
||||
*/
|
||||
function onlyAscii(str: string) {
|
||||
return !/[^\u0000-\u00ff]/g.test(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* https://stackoverflow.com/questions/12539574/
|
||||
* @param obj
|
||||
* @returns
|
||||
*/
|
||||
function objKeyToLower(obj: Record<string, string>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).map(([k, v]) => [k.toLowerCase(), v])
|
||||
);
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
import { getPatcher } from "webdav/dist/web/index.js";
|
||||
if (VALID_REQURL) {
|
||||
getPatcher().patch(
|
||||
"request",
|
||||
async (options: RequestOptionsWithState): Promise<Response> => {
|
||||
const transformedHeaders = objKeyToLower({ ...options.headers });
|
||||
delete transformedHeaders["host"];
|
||||
delete transformedHeaders["content-length"];
|
||||
|
||||
const reqContentType =
|
||||
transformedHeaders["accept"] ?? transformedHeaders["content-type"];
|
||||
|
||||
const retractedHeaders = { ...transformedHeaders };
|
||||
if (retractedHeaders.hasOwnProperty("authorization")) {
|
||||
retractedHeaders["authorization"] = "<retracted>";
|
||||
}
|
||||
|
||||
console.debug(`before request:`);
|
||||
console.debug(`url: ${options.url}`);
|
||||
console.debug(`method: ${options.method}`);
|
||||
console.debug(`headers: ${JSON.stringify(retractedHeaders, null, 2)}`);
|
||||
console.debug(`reqContentType: ${reqContentType}`);
|
||||
|
||||
let r = await requestUrl({
|
||||
url: options.url,
|
||||
method: options.method,
|
||||
body: options.data as string | ArrayBuffer,
|
||||
headers: transformedHeaders,
|
||||
contentType: reqContentType,
|
||||
throw: false,
|
||||
});
|
||||
|
||||
if (
|
||||
r.status === 401 &&
|
||||
Platform.isIosApp &&
|
||||
!options.url.endsWith("/") &&
|
||||
!options.url.endsWith(".md") &&
|
||||
options.method.toUpperCase() === "PROPFIND"
|
||||
) {
|
||||
// don't ask me why,
|
||||
// some webdav servers have some mysterious behaviours,
|
||||
// if a folder doesn't exist without slash, the servers return 401 instead of 404
|
||||
// here is a dirty hack that works
|
||||
console.debug(`so we have 401, try appending request url with slash`);
|
||||
r = await requestUrl({
|
||||
url: `${options.url}/`,
|
||||
method: options.method,
|
||||
body: options.data as string | ArrayBuffer,
|
||||
headers: transformedHeaders,
|
||||
contentType: reqContentType,
|
||||
throw: false,
|
||||
});
|
||||
}
|
||||
|
||||
console.debug(`after request:`);
|
||||
const rspHeaders = objKeyToLower({ ...r.headers });
|
||||
console.debug(`rspHeaders: ${JSON.stringify(rspHeaders, null, 2)}`);
|
||||
for (let key in rspHeaders) {
|
||||
if (rspHeaders.hasOwnProperty(key)) {
|
||||
// avoid the error:
|
||||
// Failed to read the 'headers' property from 'ResponseInit': String contains non ISO-8859-1 code point.
|
||||
// const possibleNonAscii = [
|
||||
// "Content-Disposition",
|
||||
// "X-Accel-Redirect",
|
||||
// "X-Outfilename",
|
||||
// "X-Sendfile"
|
||||
// ];
|
||||
// for (const p of possibleNonAscii) {
|
||||
// if (key === p || key === p.toLowerCase()) {
|
||||
// rspHeaders[key] = encodeURIComponent(rspHeaders[key]);
|
||||
// }
|
||||
// }
|
||||
if (!onlyAscii(rspHeaders[key])) {
|
||||
console.debug(`rspHeaders[key] needs encode: ${key}`);
|
||||
rspHeaders[key] = encodeURIComponent(rspHeaders[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let r2: Response | undefined = undefined;
|
||||
const statusText = getReasonPhrase(r.status);
|
||||
console.debug(`statusText: ${statusText}`);
|
||||
if ([101, 103, 204, 205, 304].includes(r.status)) {
|
||||
// A null body status is a status that is 101, 103, 204, 205, or 304.
|
||||
// https://fetch.spec.whatwg.org/#statuses
|
||||
// fix this: Failed to construct 'Response': Response with null body status cannot have body
|
||||
r2 = new Response(null, {
|
||||
status: r.status,
|
||||
statusText: statusText,
|
||||
headers: rspHeaders,
|
||||
});
|
||||
} else {
|
||||
r2 = new Response(r.arrayBuffer, {
|
||||
status: r.status,
|
||||
statusText: statusText,
|
||||
headers: rspHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
return r2;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
import { AuthType, BufferLike, createClient } from "webdav/dist/web/index.js";
|
||||
export type { WebDAVClient } from "webdav";
|
||||
|
||||
export const DEFAULT_WEBDAV_CONFIG = {
|
||||
address: "",
|
||||
username: "",
|
||||
password: "",
|
||||
authType: "basic",
|
||||
manualRecursive: true,
|
||||
depth: "manual_1",
|
||||
remoteBaseDir: "",
|
||||
} as WebdavConfig;
|
||||
|
||||
const getWebdavPath = (fileOrFolderPath: string, remoteBaseDir: string) => {
|
||||
let key = fileOrFolderPath;
|
||||
if (fileOrFolderPath === "/" || fileOrFolderPath === "") {
|
||||
// special
|
||||
key = `/${remoteBaseDir}/`;
|
||||
} else if (fileOrFolderPath.startsWith("/")) {
|
||||
console.warn(
|
||||
`why the path ${fileOrFolderPath} starts with '/'? but we just go on.`
|
||||
);
|
||||
key = `/${remoteBaseDir}${fileOrFolderPath}`;
|
||||
} else {
|
||||
key = `/${remoteBaseDir}/${fileOrFolderPath}`;
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
const getNormPath = (fileOrFolderPath: string, remoteBaseDir: string) => {
|
||||
if (
|
||||
!(
|
||||
fileOrFolderPath === `/${remoteBaseDir}` ||
|
||||
fileOrFolderPath.startsWith(`/${remoteBaseDir}/`)
|
||||
)
|
||||
) {
|
||||
throw Error(
|
||||
`"${fileOrFolderPath}" doesn't starts with "/${remoteBaseDir}/"`
|
||||
);
|
||||
}
|
||||
// if (fileOrFolderPath.startsWith("/")) {
|
||||
// return fileOrFolderPath.slice(1);
|
||||
// }
|
||||
return fileOrFolderPath.slice(`/${remoteBaseDir}/`.length);
|
||||
};
|
||||
|
||||
const fromWebdavItemToEntity = (x: FileStat, remoteBaseDir: string) => {
|
||||
let key = getNormPath(x.filename, remoteBaseDir);
|
||||
if (x.type === "directory" && !key.endsWith("/")) {
|
||||
key = `${key}/`;
|
||||
}
|
||||
const mtimeSvr = Date.parse(x.lastmod).valueOf();
|
||||
return {
|
||||
keyRaw: key,
|
||||
mtimeSvr: mtimeSvr,
|
||||
mtimeCli: mtimeSvr, // no universal way to set mtime in webdav
|
||||
sizeRaw: x.size,
|
||||
etag: x.etag,
|
||||
} as Entity;
|
||||
};
|
||||
|
||||
export class WrappedWebdavClient {
|
||||
webdavConfig: WebdavConfig;
|
||||
remoteBaseDir: string;
|
||||
client!: WebDAVClient;
|
||||
vaultFolderExists: boolean;
|
||||
saveUpdatedConfigFunc: () => Promise<any>;
|
||||
constructor(
|
||||
webdavConfig: WebdavConfig,
|
||||
remoteBaseDir: string,
|
||||
saveUpdatedConfigFunc: () => Promise<any>
|
||||
) {
|
||||
this.webdavConfig = cloneDeep(webdavConfig);
|
||||
this.webdavConfig.address = encodeURI(this.webdavConfig.address);
|
||||
this.remoteBaseDir = remoteBaseDir;
|
||||
this.vaultFolderExists = false;
|
||||
this.saveUpdatedConfigFunc = saveUpdatedConfigFunc;
|
||||
}
|
||||
|
||||
init = async () => {
|
||||
// init client if not inited
|
||||
if (this.client !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.isIosApp && !this.webdavConfig.address.startsWith("https")) {
|
||||
throw Error(
|
||||
`Your webdav address could only be https, not http, because of the iOS restriction.`
|
||||
);
|
||||
}
|
||||
|
||||
const headers = {
|
||||
"Cache-Control": "no-cache",
|
||||
};
|
||||
if (
|
||||
this.webdavConfig.username !== "" &&
|
||||
this.webdavConfig.password !== ""
|
||||
) {
|
||||
this.client = createClient(this.webdavConfig.address, {
|
||||
username: this.webdavConfig.username,
|
||||
password: this.webdavConfig.password,
|
||||
headers: headers,
|
||||
authType:
|
||||
this.webdavConfig.authType === "digest"
|
||||
? AuthType.Digest
|
||||
: AuthType.Password,
|
||||
});
|
||||
} else {
|
||||
console.info("no password");
|
||||
this.client = createClient(this.webdavConfig.address, {
|
||||
headers: headers,
|
||||
});
|
||||
}
|
||||
|
||||
// check vault folder
|
||||
if (this.vaultFolderExists) {
|
||||
// pass
|
||||
} else {
|
||||
const res = await this.client.exists(`/${this.remoteBaseDir}/`);
|
||||
if (res) {
|
||||
// console.info("remote vault folder exits!");
|
||||
this.vaultFolderExists = true;
|
||||
} else {
|
||||
console.info("remote vault folder not exists, creating");
|
||||
await this.client.createDirectory(`/${this.remoteBaseDir}/`);
|
||||
console.info("remote vault folder created!");
|
||||
this.vaultFolderExists = true;
|
||||
}
|
||||
}
|
||||
|
||||
// adjust depth parameter
|
||||
if (
|
||||
this.webdavConfig.depth === "auto" ||
|
||||
this.webdavConfig.depth === "auto_1" ||
|
||||
this.webdavConfig.depth === "auto_infinity" ||
|
||||
this.webdavConfig.depth === "auto_unknown"
|
||||
) {
|
||||
this.webdavConfig.depth = "manual_1";
|
||||
this.webdavConfig.manualRecursive = true;
|
||||
if (this.saveUpdatedConfigFunc !== undefined) {
|
||||
await this.saveUpdatedConfigFunc();
|
||||
console.info(
|
||||
`webdav depth="auto_???" is changed to ${this.webdavConfig.depth}`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const getWebdavClient = (
|
||||
webdavConfig: WebdavConfig,
|
||||
remoteBaseDir: string,
|
||||
saveUpdatedConfigFunc: () => Promise<any>
|
||||
) => {
|
||||
return new WrappedWebdavClient(
|
||||
webdavConfig,
|
||||
remoteBaseDir,
|
||||
saveUpdatedConfigFunc
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param client
|
||||
* @param remotePath It should be prefix-ed already
|
||||
* @returns
|
||||
*/
|
||||
export const getRemoteMeta = async (
|
||||
client: WrappedWebdavClient,
|
||||
remotePath: string
|
||||
) => {
|
||||
await client.init();
|
||||
console.debug(`getRemoteMeta remotePath = ${remotePath}`);
|
||||
const res = (await client.client.stat(remotePath, {
|
||||
details: false,
|
||||
})) as FileStat;
|
||||
console.debug(`getRemoteMeta res=${JSON.stringify(res)}`);
|
||||
return fromWebdavItemToEntity(res, client.remoteBaseDir);
|
||||
};
|
||||
|
||||
export const uploadToRemote = async (
|
||||
client: WrappedWebdavClient,
|
||||
fileOrFolderPath: string,
|
||||
vault: Vault | undefined,
|
||||
isRecursively: boolean,
|
||||
cipher: Cipher,
|
||||
remoteEncryptedKey: string = "",
|
||||
uploadRaw: boolean = false,
|
||||
rawContent: string | ArrayBuffer = ""
|
||||
): Promise<UploadedType> => {
|
||||
await client.init();
|
||||
let uploadFile = fileOrFolderPath;
|
||||
if (!cipher.isPasswordEmpty()) {
|
||||
if (remoteEncryptedKey === undefined || remoteEncryptedKey === "") {
|
||||
throw Error(
|
||||
`uploadToRemote(webdav) you have password but remoteEncryptedKey is empty!`
|
||||
);
|
||||
}
|
||||
uploadFile = remoteEncryptedKey;
|
||||
}
|
||||
uploadFile = getWebdavPath(uploadFile, client.remoteBaseDir);
|
||||
|
||||
const isFolder = fileOrFolderPath.endsWith("/");
|
||||
|
||||
if (isFolder && isRecursively) {
|
||||
throw Error("upload function doesn't implement recursive function yet!");
|
||||
} else if (isFolder && !isRecursively) {
|
||||
if (uploadRaw) {
|
||||
throw Error(`you specify uploadRaw, but you also provide a folder key!`);
|
||||
}
|
||||
// folder
|
||||
if (cipher.isPasswordEmpty() || cipher.isFolderAware()) {
|
||||
// if not encrypted, || encrypted isFolderAware, mkdir a remote folder
|
||||
await client.client.createDirectory(uploadFile, {
|
||||
recursive: true,
|
||||
});
|
||||
const res = await getRemoteMeta(client, uploadFile);
|
||||
return {
|
||||
entity: res,
|
||||
};
|
||||
} else {
|
||||
// if encrypted && !isFolderAware(),
|
||||
// upload a fake file with the encrypted file name
|
||||
await client.client.putFileContents(uploadFile, "", {
|
||||
overwrite: true,
|
||||
onUploadProgress: (progress: any) => {
|
||||
// console.info(`Uploaded ${progress.loaded} bytes of ${progress.total}`);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
entity: await getRemoteMeta(client, uploadFile),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// file
|
||||
// we ignore isRecursively parameter here
|
||||
let localContent: ArrayBuffer | undefined = undefined;
|
||||
let mtimeCli: number | undefined = undefined;
|
||||
if (uploadRaw) {
|
||||
if (typeof rawContent === "string") {
|
||||
localContent = new TextEncoder().encode(rawContent).buffer;
|
||||
} else {
|
||||
localContent = rawContent;
|
||||
}
|
||||
} 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);
|
||||
mtimeCli = (await vault.adapter.stat(fileOrFolderPath))?.mtime;
|
||||
}
|
||||
let remoteContent = localContent;
|
||||
if (!cipher.isPasswordEmpty()) {
|
||||
remoteContent = await cipher.encryptContent(localContent);
|
||||
}
|
||||
// updated 20220326: the algorithm guarantee this
|
||||
// // we need to create folders before uploading
|
||||
// const dir = getPathFolder(uploadFile);
|
||||
// if (dir !== "/" && dir !== "") {
|
||||
// await client.client.createDirectory(dir, { recursive: true });
|
||||
// }
|
||||
await client.client.putFileContents(uploadFile, remoteContent, {
|
||||
overwrite: true,
|
||||
onUploadProgress: (progress: any) => {
|
||||
console.info(`Uploaded ${progress.loaded} bytes of ${progress.total}`);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
entity: await getRemoteMeta(client, uploadFile),
|
||||
mtimeCli: mtimeCli,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const listAllFromRemote = async (client: WrappedWebdavClient) => {
|
||||
await client.init();
|
||||
|
||||
let contents = [] as FileStat[];
|
||||
if (
|
||||
client.webdavConfig.depth === "auto" ||
|
||||
client.webdavConfig.depth === "auto_unknown" ||
|
||||
client.webdavConfig.depth === "auto_1" ||
|
||||
client.webdavConfig.depth === "auto_infinity" /* don't trust auto now */ ||
|
||||
client.webdavConfig.depth === "manual_1"
|
||||
) {
|
||||
// the remote doesn't support infinity propfind,
|
||||
// we need to do a bfs here
|
||||
const q = new Queue([`/${client.remoteBaseDir}`]);
|
||||
const CHUNK_SIZE = 10;
|
||||
while (q.length > 0) {
|
||||
const itemsToFetch: string[] = [];
|
||||
while (q.length > 0) {
|
||||
itemsToFetch.push(q.pop()!);
|
||||
}
|
||||
const itemsToFetchChunks = chunk(itemsToFetch, CHUNK_SIZE);
|
||||
// console.debug(itemsToFetchChunks);
|
||||
const subContents = [] as FileStat[];
|
||||
for (const singleChunk of itemsToFetchChunks) {
|
||||
const r = singleChunk.map((x) => {
|
||||
return client.client.getDirectoryContents(x, {
|
||||
deep: false,
|
||||
details: false /* no need for verbose details here */,
|
||||
// TODO: to support .obsidian,
|
||||
// we need to load all files including dot,
|
||||
// anyway to reduce the resources?
|
||||
// glob: "/**" /* avoid dot files by using glob */,
|
||||
}) as Promise<FileStat[]>;
|
||||
});
|
||||
const r2 = flatten(await Promise.all(r));
|
||||
subContents.push(...r2);
|
||||
}
|
||||
for (let i = 0; i < subContents.length; ++i) {
|
||||
const f = subContents[i];
|
||||
contents.push(f);
|
||||
if (f.type === "directory") {
|
||||
q.push(f.filename);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// the remote supports infinity propfind
|
||||
contents = (await client.client.getDirectoryContents(
|
||||
`/${client.remoteBaseDir}`,
|
||||
{
|
||||
deep: true,
|
||||
details: false /* no need for verbose details here */,
|
||||
// TODO: to support .obsidian,
|
||||
// we need to load all files including dot,
|
||||
// anyway to reduce the resources?
|
||||
// glob: "/**" /* avoid dot files by using glob */,
|
||||
}
|
||||
)) as FileStat[];
|
||||
}
|
||||
return contents.map((x) => fromWebdavItemToEntity(x, client.remoteBaseDir));
|
||||
};
|
||||
|
||||
const downloadFromRemoteRaw = async (
|
||||
client: WrappedWebdavClient,
|
||||
remotePath: string
|
||||
) => {
|
||||
await client.init();
|
||||
// console.info(`getWebdavPath=${remotePath}`);
|
||||
const buff = (await client.client.getFileContents(remotePath)) as BufferLike;
|
||||
if (buff instanceof ArrayBuffer) {
|
||||
return buff;
|
||||
} else if (buff instanceof Buffer) {
|
||||
return bufferToArrayBuffer(buff);
|
||||
}
|
||||
throw Error(`unexpected file content result with type ${typeof buff}`);
|
||||
};
|
||||
|
||||
export const downloadFromRemote = async (
|
||||
client: WrappedWebdavClient,
|
||||
fileOrFolderPath: string,
|
||||
vault: Vault,
|
||||
mtime: number,
|
||||
cipher: Cipher,
|
||||
remoteEncryptedKey: string = "",
|
||||
skipSaving: boolean = false
|
||||
) => {
|
||||
await client.init();
|
||||
|
||||
const isFolder = fileOrFolderPath.endsWith("/");
|
||||
|
||||
if (!skipSaving) {
|
||||
await mkdirpInVault(fileOrFolderPath, vault);
|
||||
}
|
||||
|
||||
// the file is always local file
|
||||
// we need to encrypt it
|
||||
|
||||
if (isFolder) {
|
||||
// mkdirp locally is enough
|
||||
// do nothing here
|
||||
return new ArrayBuffer(0);
|
||||
} else {
|
||||
let downloadFile = fileOrFolderPath;
|
||||
if (!cipher.isPasswordEmpty()) {
|
||||
downloadFile = remoteEncryptedKey;
|
||||
}
|
||||
downloadFile = getWebdavPath(downloadFile, client.remoteBaseDir);
|
||||
// console.info(`downloadFile=${downloadFile}`);
|
||||
const remoteContent = await downloadFromRemoteRaw(client, downloadFile);
|
||||
let localContent = remoteContent;
|
||||
if (!cipher.isPasswordEmpty()) {
|
||||
localContent = await cipher.decryptContent(remoteContent);
|
||||
}
|
||||
if (!skipSaving) {
|
||||
await vault.adapter.writeBinary(fileOrFolderPath, localContent, {
|
||||
mtime: mtime,
|
||||
});
|
||||
}
|
||||
return localContent;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteFromRemote = async (
|
||||
client: WrappedWebdavClient,
|
||||
fileOrFolderPath: string,
|
||||
cipher: Cipher,
|
||||
remoteEncryptedKey: string = ""
|
||||
) => {
|
||||
if (fileOrFolderPath === "/") {
|
||||
return;
|
||||
}
|
||||
let remoteFileName = fileOrFolderPath;
|
||||
if (!cipher.isPasswordEmpty()) {
|
||||
remoteFileName = remoteEncryptedKey;
|
||||
}
|
||||
remoteFileName = getWebdavPath(remoteFileName, client.remoteBaseDir);
|
||||
|
||||
await client.init();
|
||||
try {
|
||||
await client.client.deleteFile(remoteFileName);
|
||||
// console.info(`delete ${remoteFileName} succeeded`);
|
||||
} catch (err) {
|
||||
console.error("some error while deleting");
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
export const checkConnectivity = async (
|
||||
client: WrappedWebdavClient,
|
||||
callbackFunc?: any
|
||||
) => {
|
||||
if (
|
||||
!(
|
||||
client.webdavConfig.address.startsWith("http://") ||
|
||||
client.webdavConfig.address.startsWith("https://")
|
||||
)
|
||||
) {
|
||||
const err = "Error: the url should start with http(s):// but it does not!";
|
||||
console.error(err);
|
||||
if (callbackFunc !== undefined) {
|
||||
callbackFunc(err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await client.init();
|
||||
const results = await getRemoteMeta(client, `/${client.remoteBaseDir}/`);
|
||||
if (results === undefined) {
|
||||
const err = "results is undefined";
|
||||
console.error(err);
|
||||
if (callbackFunc !== undefined) {
|
||||
callbackFunc(err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (callbackFunc !== undefined) {
|
||||
callbackFunc(err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
+375
-123
@@ -11,21 +11,32 @@ import {
|
||||
import type { TextComponent } from "obsidian";
|
||||
import { createElement, Eye, EyeOff } from "lucide";
|
||||
import {
|
||||
API_VER_ENSURE_REQURL_OK,
|
||||
API_VER_REQURL,
|
||||
ConflictActionType,
|
||||
DEFAULT_DEBUG_FOLDER,
|
||||
EmptyFolderCleanType,
|
||||
SUPPORTED_SERVICES_TYPE,
|
||||
SUPPORTED_SERVICES_TYPE_WITH_REMOTE_BASE_DIR,
|
||||
SyncDirectionType,
|
||||
VALID_REQURL,
|
||||
WebdavAuthType,
|
||||
WebdavDepthType,
|
||||
CipherMethodType,
|
||||
QRExportType,
|
||||
} from "./baseTypes";
|
||||
import { exportVaultSyncPlansToFiles } from "./debugMode";
|
||||
import { exportQrCodeUri } from "./importExport";
|
||||
|
||||
import {
|
||||
API_VER_ENSURE_REQURL_OK,
|
||||
API_VER_REQURL,
|
||||
VALID_REQURL,
|
||||
} from "./baseTypesObs";
|
||||
import {
|
||||
exportVaultProfilerResultsToFiles,
|
||||
exportVaultSyncPlansToFiles,
|
||||
} from "./debugMode";
|
||||
import {
|
||||
exportQrCodeUri,
|
||||
importQrCodeUri,
|
||||
parseUriByHand,
|
||||
} from "./importExport";
|
||||
import {
|
||||
clearAllPrevSyncRecordByVault,
|
||||
clearAllSyncPlanRecords,
|
||||
@@ -33,17 +44,17 @@ import {
|
||||
upsertLastSuccessSyncTimeByVault,
|
||||
} from "./localdb";
|
||||
import type RemotelySavePlugin from "./main"; // unavoidable
|
||||
import { RemoteClient } from "./remote";
|
||||
import { FakeFs } from "./fsAll";
|
||||
import {
|
||||
DEFAULT_DROPBOX_CONFIG,
|
||||
getAuthUrlAndVerifier as getAuthUrlAndVerifierDropbox,
|
||||
sendAuthReq as sendAuthReqDropbox,
|
||||
setConfigBySuccessfullAuthInplace,
|
||||
} from "./remoteForDropbox";
|
||||
} from "./fsDropbox";
|
||||
import {
|
||||
DEFAULT_ONEDRIVE_CONFIG,
|
||||
getAuthUrlAndVerifier as getAuthUrlAndVerifierOnedrive,
|
||||
} from "./remoteForOnedrive";
|
||||
} from "./fsOnedrive";
|
||||
import { messyConfigToNormal } from "./configPersist";
|
||||
import type { TransItemType } from "./i18n";
|
||||
import {
|
||||
@@ -51,7 +62,9 @@ import {
|
||||
checkHasSpecialCharForDir,
|
||||
stringToFragment,
|
||||
} from "./misc";
|
||||
import { simpleTransRemotePrefix } from "./remoteForS3";
|
||||
import { simpleTransRemotePrefix } from "./fsS3";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import { getClient } from "./fsGetter";
|
||||
|
||||
class PasswordModal extends Modal {
|
||||
plugin: RemotelySavePlugin;
|
||||
@@ -459,16 +472,12 @@ class DropboxAuthModal extends Modal {
|
||||
authRes!,
|
||||
() => self.plugin.saveSettings()
|
||||
);
|
||||
const client = new RemoteClient(
|
||||
"dropbox",
|
||||
undefined,
|
||||
undefined,
|
||||
this.plugin.settings.dropbox,
|
||||
undefined,
|
||||
const client = getClient(
|
||||
this.plugin.settings,
|
||||
this.app.vault.getName(),
|
||||
() => self.plugin.saveSettings()
|
||||
() => this.plugin.saveSettings()
|
||||
);
|
||||
const username = await client.getUser();
|
||||
const username = await client.getUserDisplayName();
|
||||
this.plugin.settings.dropbox.username = username;
|
||||
await this.plugin.saveSettings();
|
||||
new Notice(
|
||||
@@ -544,6 +553,15 @@ export class OnedriveAuthModal extends Modal {
|
||||
text: val,
|
||||
});
|
||||
});
|
||||
if (Platform.isLinux) {
|
||||
t("modal_onedriveauth_shortdesc_linux")
|
||||
.split("\n")
|
||||
.forEach((val) => {
|
||||
contentEl.createEl("p", {
|
||||
text: stringToFragment(val),
|
||||
});
|
||||
});
|
||||
}
|
||||
const div2 = contentEl.createDiv();
|
||||
div2.createEl(
|
||||
"button",
|
||||
@@ -695,9 +713,11 @@ class SyncConfigDirModal extends Modal {
|
||||
|
||||
class ExportSettingsQrCodeModal extends Modal {
|
||||
plugin: RemotelySavePlugin;
|
||||
constructor(app: App, plugin: RemotelySavePlugin) {
|
||||
exportType: QRExportType;
|
||||
constructor(app: App, plugin: RemotelySavePlugin, exportType: QRExportType) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.exportType = exportType;
|
||||
}
|
||||
|
||||
async onOpen() {
|
||||
@@ -710,7 +730,8 @@ class ExportSettingsQrCodeModal extends Modal {
|
||||
const { rawUri, imgUri } = await exportQrCodeUri(
|
||||
this.plugin.settings,
|
||||
this.app.vault.getName(),
|
||||
this.plugin.manifest.version
|
||||
this.plugin.manifest.version,
|
||||
this.exportType
|
||||
);
|
||||
|
||||
const div1 = contentEl.createDiv();
|
||||
@@ -790,6 +811,7 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
||||
|
||||
display(): void {
|
||||
let { containerEl } = this;
|
||||
containerEl.style.setProperty("overflow-wrap", "break-word");
|
||||
|
||||
containerEl.empty();
|
||||
|
||||
@@ -1035,6 +1057,46 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
||||
).open();
|
||||
});
|
||||
});
|
||||
new Setting(s3Div)
|
||||
.setName(t("settings_s3_reverse_proxy_no_sign_url"))
|
||||
.setDesc(t("settings_s3_reverse_proxy_no_sign_url_desc"))
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("")
|
||||
.setValue(this.plugin.settings.s3.reverseProxyNoSignUrl ?? "")
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.s3.reverseProxyNoSignUrl = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(s3Div)
|
||||
.setName(t("settings_s3_generatefolderobject"))
|
||||
.setDesc(t("settings_s3_generatefolderobject_desc"))
|
||||
.addDropdown((dropdown) => {
|
||||
dropdown
|
||||
.addOption(
|
||||
"notgenerate",
|
||||
t("settings_s3_generatefolderobject_notgenerate")
|
||||
)
|
||||
.addOption(
|
||||
"generate",
|
||||
t("settings_s3_generatefolderobject_generate")
|
||||
);
|
||||
|
||||
dropdown
|
||||
.setValue(
|
||||
`${this.plugin.settings.s3.generateFolderObject ? "generate" : "notgenerate"}`
|
||||
)
|
||||
.onChange(async (val) => {
|
||||
if (val === "generate") {
|
||||
this.plugin.settings.s3.generateFolderObject = true;
|
||||
} else {
|
||||
this.plugin.settings.s3.generateFolderObject = false;
|
||||
}
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(s3Div)
|
||||
.setName(t("settings_checkonnectivity"))
|
||||
@@ -1043,9 +1105,13 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
||||
button.setButtonText(t("settings_checkonnectivity_button"));
|
||||
button.onClick(async () => {
|
||||
new Notice(t("settings_checkonnectivity_checking"));
|
||||
const client = new RemoteClient("s3", this.plugin.settings.s3);
|
||||
const client = getClient(
|
||||
this.plugin.settings,
|
||||
this.app.vault.getName(),
|
||||
() => this.plugin.saveSettings()
|
||||
);
|
||||
const errors = { msg: "" };
|
||||
const res = await client.checkConnectivity((err: any) => {
|
||||
const res = await client.checkConnect((err: any) => {
|
||||
errors.msg = err;
|
||||
});
|
||||
if (res) {
|
||||
@@ -1109,14 +1175,10 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
||||
button.onClick(async () => {
|
||||
try {
|
||||
const self = this;
|
||||
const client = new RemoteClient(
|
||||
"dropbox",
|
||||
undefined,
|
||||
undefined,
|
||||
this.plugin.settings.dropbox,
|
||||
undefined,
|
||||
const client = getClient(
|
||||
this.plugin.settings,
|
||||
this.app.vault.getName(),
|
||||
() => self.plugin.saveSettings()
|
||||
() => this.plugin.saveSettings()
|
||||
);
|
||||
await client.revokeAuth();
|
||||
this.plugin.settings.dropbox = JSON.parse(
|
||||
@@ -1224,18 +1286,14 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
||||
button.onClick(async () => {
|
||||
new Notice(t("settings_checkonnectivity_checking"));
|
||||
const self = this;
|
||||
const client = new RemoteClient(
|
||||
"dropbox",
|
||||
undefined,
|
||||
undefined,
|
||||
this.plugin.settings.dropbox,
|
||||
undefined,
|
||||
const client = getClient(
|
||||
this.plugin.settings,
|
||||
this.app.vault.getName(),
|
||||
() => self.plugin.saveSettings()
|
||||
() => this.plugin.saveSettings()
|
||||
);
|
||||
|
||||
const errors = { msg: "" };
|
||||
const res = await client.checkConnectivity((err: any) => {
|
||||
const res = await client.checkConnect((err: any) => {
|
||||
errors.msg = `${err}`;
|
||||
});
|
||||
if (res) {
|
||||
@@ -1373,18 +1431,13 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
||||
button.onClick(async () => {
|
||||
new Notice(t("settings_checkonnectivity_checking"));
|
||||
const self = this;
|
||||
const client = new RemoteClient(
|
||||
"onedrive",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
this.plugin.settings.onedrive,
|
||||
const client = getClient(
|
||||
this.plugin.settings,
|
||||
this.app.vault.getName(),
|
||||
() => self.plugin.saveSettings()
|
||||
() => this.plugin.saveSettings()
|
||||
);
|
||||
|
||||
const errors = { msg: "" };
|
||||
const res = await client.checkConnectivity((err: any) => {
|
||||
const res = await client.checkConnect((err: any) => {
|
||||
errors.msg = `${err}`;
|
||||
});
|
||||
if (res) {
|
||||
@@ -1583,17 +1636,13 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
||||
button.onClick(async () => {
|
||||
new Notice(t("settings_checkonnectivity_checking"));
|
||||
const self = this;
|
||||
const client = new RemoteClient(
|
||||
"webdav",
|
||||
undefined,
|
||||
this.plugin.settings.webdav,
|
||||
undefined,
|
||||
undefined,
|
||||
const client = getClient(
|
||||
this.plugin.settings,
|
||||
this.app.vault.getName(),
|
||||
() => self.plugin.saveSettings()
|
||||
() => this.plugin.saveSettings()
|
||||
);
|
||||
const errors = { msg: "" };
|
||||
const res = await client.checkConnectivity((err: any) => {
|
||||
const res = await client.checkConnect((err: any) => {
|
||||
errors.msg = `${err}`;
|
||||
});
|
||||
if (res) {
|
||||
@@ -1609,6 +1658,132 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
||||
});
|
||||
});
|
||||
|
||||
//////////////////////////////////////////////////
|
||||
// below for webdis
|
||||
//////////////////////////////////////////////////
|
||||
|
||||
const webdisDiv = containerEl.createEl("div", { cls: "webdis-hide" });
|
||||
webdisDiv.toggleClass(
|
||||
"webdis-hide",
|
||||
this.plugin.settings.serviceType !== "webdis"
|
||||
);
|
||||
|
||||
webdisDiv.createEl("h2", { text: t("settings_webdis") });
|
||||
|
||||
const webdisLongDescDiv = webdisDiv.createEl("div", {
|
||||
cls: "settings-long-desc",
|
||||
});
|
||||
|
||||
for (const c of [
|
||||
t("settings_webdis_disclaimer1"),
|
||||
t("settings_webdis_disclaimer2"),
|
||||
]) {
|
||||
webdisLongDescDiv.createEl("p", {
|
||||
text: c,
|
||||
cls: "webdis-disclaimer",
|
||||
});
|
||||
}
|
||||
|
||||
webdisLongDescDiv.createEl("p", {
|
||||
text: t("settings_webdis_folder", {
|
||||
remoteBaseDir:
|
||||
this.plugin.settings.webdis.remoteBaseDir || this.app.vault.getName(),
|
||||
}),
|
||||
});
|
||||
|
||||
new Setting(webdisDiv)
|
||||
.setName(t("settings_webdis_addr"))
|
||||
.setDesc(t("settings_webdis_addr_desc"))
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("https://")
|
||||
.setValue(this.plugin.settings.webdis.address)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.webdis.address = value.trim();
|
||||
// normally saved
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(webdisDiv)
|
||||
.setName(t("settings_webdis_user"))
|
||||
.setDesc(t("settings_webdis_user_desc"))
|
||||
.addText((text) => {
|
||||
wrapTextWithPasswordHide(text);
|
||||
text
|
||||
.setPlaceholder("")
|
||||
.setValue(this.plugin.settings.webdis.username ?? "")
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.webdis.username = (value ?? "").trim();
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(webdisDiv)
|
||||
.setName(t("settings_webdis_password"))
|
||||
.setDesc(t("settings_webdis_password_desc"))
|
||||
.addText((text) => {
|
||||
wrapTextWithPasswordHide(text);
|
||||
text
|
||||
.setPlaceholder("")
|
||||
.setValue(this.plugin.settings.webdis.password ?? "")
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.webdis.password = (value ?? "").trim();
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
let newWebdisRemoteBaseDir =
|
||||
this.plugin.settings.webdis.remoteBaseDir || "";
|
||||
new Setting(webdisDiv)
|
||||
.setName(t("settings_remotebasedir"))
|
||||
.setDesc(t("settings_remotebasedir_desc"))
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(this.app.vault.getName())
|
||||
.setValue(newWebdisRemoteBaseDir)
|
||||
.onChange((value) => {
|
||||
newWebdisRemoteBaseDir = value.trim();
|
||||
})
|
||||
)
|
||||
.addButton((button) => {
|
||||
button.setButtonText(t("confirm"));
|
||||
button.onClick(() => {
|
||||
new ChangeRemoteBaseDirModal(
|
||||
this.app,
|
||||
this.plugin,
|
||||
newWebdisRemoteBaseDir,
|
||||
"webdis"
|
||||
).open();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(webdisDiv)
|
||||
.setName(t("settings_checkonnectivity"))
|
||||
.setDesc(t("settings_checkonnectivity_desc"))
|
||||
.addButton(async (button) => {
|
||||
button.setButtonText(t("settings_checkonnectivity_button"));
|
||||
button.onClick(async () => {
|
||||
new Notice(t("settings_checkonnectivity_checking"));
|
||||
const self = this;
|
||||
const client = getClient(
|
||||
this.plugin.settings,
|
||||
this.app.vault.getName(),
|
||||
() => this.plugin.saveSettings()
|
||||
);
|
||||
const errors = { msg: "" };
|
||||
const res = await client.checkConnect((err: any) => {
|
||||
errors.msg = `${err}`;
|
||||
});
|
||||
if (res) {
|
||||
new Notice(t("settings_webdis_connect_succ"));
|
||||
} else {
|
||||
new Notice(t("settings_webdis_connect_fail"));
|
||||
new Notice(errors.msg);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
//////////////////////////////////////////////////
|
||||
// below for general chooser (part 2/2)
|
||||
//////////////////////////////////////////////////
|
||||
@@ -1623,6 +1798,8 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
||||
dropdown.addOption("dropbox", t("settings_chooseservice_dropbox"));
|
||||
dropdown.addOption("webdav", t("settings_chooseservice_webdav"));
|
||||
dropdown.addOption("onedrive", t("settings_chooseservice_onedrive"));
|
||||
dropdown.addOption("webdis", t("settings_chooseservice_webdis"));
|
||||
|
||||
dropdown
|
||||
.setValue(this.plugin.settings.serviceType)
|
||||
.onChange(async (val) => {
|
||||
@@ -1643,6 +1820,10 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
||||
"webdav-hide",
|
||||
this.plugin.settings.serviceType !== "webdav"
|
||||
);
|
||||
webdisDiv.toggleClass(
|
||||
"webdis-hide",
|
||||
this.plugin.settings.serviceType !== "webdis"
|
||||
);
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
@@ -1756,72 +1937,22 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
||||
});
|
||||
|
||||
new Setting(basicDiv)
|
||||
.setName(t("settings_saverun"))
|
||||
.setDesc(t("settings_saverun_desc"))
|
||||
.setName(t("settings_synconsave"))
|
||||
.setDesc(t("settings_synconsave_desc"))
|
||||
.addDropdown((dropdown) => {
|
||||
dropdown.addOption("-1", t("settings_saverun_notset"));
|
||||
dropdown.addOption(`${1000 * 1}`, t("settings_saverun_1sec"));
|
||||
dropdown.addOption(`${1000 * 5}`, t("settings_saverun_5sec"));
|
||||
dropdown.addOption(`${1000 * 10}`, t("settings_saverun_10sec"));
|
||||
dropdown.addOption(`${1000 * 60}`, t("settings_saverun_1min"));
|
||||
let runScheduled = false;
|
||||
dropdown.addOption("-1", t("settings_synconsave_disable"));
|
||||
dropdown.addOption("1000", t("settings_synconsave_enable"));
|
||||
// for backward compatibility, we need to use a number representing seconds
|
||||
let syncOnSaveEnabled = false;
|
||||
if ((this.plugin.settings.syncOnSaveAfterMilliseconds ?? -1) > 0) {
|
||||
syncOnSaveEnabled = true;
|
||||
}
|
||||
dropdown
|
||||
.setValue(`${this.plugin.settings.syncOnSaveAfterMilliseconds}`)
|
||||
.setValue(`${syncOnSaveEnabled ? "1000" : "-1"}`)
|
||||
.onChange(async (val: string) => {
|
||||
const realVal = parseInt(val);
|
||||
this.plugin.settings.syncOnSaveAfterMilliseconds = realVal;
|
||||
this.plugin.settings.syncOnSaveAfterMilliseconds = parseInt(val);
|
||||
await this.plugin.saveSettings();
|
||||
if (
|
||||
(realVal === undefined || realVal === null || realVal <= 0) &&
|
||||
this.plugin.syncOnSaveIntervalID !== undefined
|
||||
) {
|
||||
// clear
|
||||
window.clearInterval(this.plugin.syncOnSaveIntervalID);
|
||||
this.plugin.syncOnSaveIntervalID = undefined;
|
||||
} else if (
|
||||
realVal !== undefined &&
|
||||
realVal !== null &&
|
||||
realVal > 0
|
||||
) {
|
||||
const intervalID = window.setInterval(() => {
|
||||
const currentFile = this.app.workspace.getActiveFile();
|
||||
|
||||
if (currentFile) {
|
||||
// get the last modified time of the current file
|
||||
// if it has been modified within the last syncOnSaveAfterMilliseconds
|
||||
// then schedule a run for syncOnSaveAfterMilliseconds after it was modified
|
||||
const lastModified = currentFile.stat.mtime;
|
||||
const currentTime = Date.now();
|
||||
// console.debug(
|
||||
// `Checking if file was modified within last ${
|
||||
// this.plugin.settings.syncOnSaveAfterMilliseconds / 1000
|
||||
// } seconds, last modified: ${
|
||||
// (currentTime - lastModified) / 1000
|
||||
// } seconds ago`
|
||||
// );
|
||||
if (
|
||||
currentTime - lastModified <
|
||||
this.plugin.settings.syncOnSaveAfterMilliseconds!
|
||||
) {
|
||||
if (!runScheduled) {
|
||||
const scheduleTimeFromNow =
|
||||
this.plugin.settings.syncOnSaveAfterMilliseconds! -
|
||||
(currentTime - lastModified);
|
||||
console.info(
|
||||
`schedule a run for ${scheduleTimeFromNow} milliseconds later`
|
||||
);
|
||||
runScheduled = true;
|
||||
setTimeout(() => {
|
||||
this.plugin.syncRun("auto_sync_on_save");
|
||||
runScheduled = false;
|
||||
}, scheduleTimeFromNow);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, realVal);
|
||||
this.plugin.syncOnSaveIntervalID = intervalID;
|
||||
this.plugin.registerInterval(intervalID);
|
||||
}
|
||||
this.plugin.toggleSyncOnSaveIfSet();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2022,7 +2153,7 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
||||
t("settings_cleanemptyfolder_clean_both")
|
||||
);
|
||||
dropdown
|
||||
.setValue(this.plugin.settings.howToCleanEmptyFolder ?? "skip")
|
||||
.setValue(this.plugin.settings.howToCleanEmptyFolder ?? "clean_both")
|
||||
.onChange(async (val) => {
|
||||
this.plugin.settings.howToCleanEmptyFolder =
|
||||
val as EmptyFolderCleanType;
|
||||
@@ -2098,10 +2229,16 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
||||
.onChange(async (val) => {
|
||||
if (val === "enable") {
|
||||
this.plugin.settings.enableMobileStatusBar = true;
|
||||
changeMobileStatusBar("enable");
|
||||
this.plugin.appContainerObserver =
|
||||
changeMobileStatusBar("enable");
|
||||
} else {
|
||||
this.plugin.settings.enableMobileStatusBar = false;
|
||||
changeMobileStatusBar("disable");
|
||||
changeMobileStatusBar(
|
||||
"disable",
|
||||
this.plugin.appContainerObserver
|
||||
);
|
||||
this.plugin.appContainerObserver?.disconnect();
|
||||
this.plugin.appContainerObserver = undefined;
|
||||
}
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
@@ -2117,20 +2254,95 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
||||
importExportDiv.createEl("h2", {
|
||||
text: t("settings_importexport"),
|
||||
});
|
||||
if (Platform.isMobile) {
|
||||
importExportDiv.addClass("setting-need-wrapping-mobile");
|
||||
}
|
||||
|
||||
new Setting(importExportDiv)
|
||||
.setName(t("settings_export"))
|
||||
.setDesc(t("settings_export_desc"))
|
||||
.addButton(async (button) => {
|
||||
button.setButtonText(t("settings_export_desc_button"));
|
||||
button.setButtonText(t("settings_export_all_but_oauth2_button"));
|
||||
button.onClick(async () => {
|
||||
new ExportSettingsQrCodeModal(this.app, this.plugin).open();
|
||||
new ExportSettingsQrCodeModal(
|
||||
this.app,
|
||||
this.plugin,
|
||||
"all_but_oauth2"
|
||||
).open();
|
||||
});
|
||||
})
|
||||
.addButton(async (button) => {
|
||||
button.setButtonText(t("settings_export_dropbox_button"));
|
||||
button.onClick(async () => {
|
||||
new ExportSettingsQrCodeModal(
|
||||
this.app,
|
||||
this.plugin,
|
||||
"dropbox"
|
||||
).open();
|
||||
});
|
||||
})
|
||||
.addButton(async (button) => {
|
||||
button.setButtonText(t("settings_export_onedrive_button"));
|
||||
button.onClick(async () => {
|
||||
new ExportSettingsQrCodeModal(
|
||||
this.app,
|
||||
this.plugin,
|
||||
"onedrive"
|
||||
).open();
|
||||
});
|
||||
});
|
||||
|
||||
let importSettingVal = "";
|
||||
new Setting(importExportDiv)
|
||||
.setName(t("settings_import"))
|
||||
.setDesc(t("settings_import_desc"));
|
||||
.setDesc(t("settings_import_desc"))
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("obsidian://remotely-save?func=settings&...")
|
||||
.setValue("")
|
||||
.onChange((val) => {
|
||||
importSettingVal = val;
|
||||
})
|
||||
)
|
||||
.addButton(async (button) => {
|
||||
button.setButtonText(t("confirm"));
|
||||
button.onClick(async () => {
|
||||
if (importSettingVal !== "") {
|
||||
// console.debug(importSettingVal);
|
||||
try {
|
||||
const inputParams = parseUriByHand(importSettingVal);
|
||||
const parsed = importQrCodeUri(
|
||||
inputParams,
|
||||
this.app.vault.getName()
|
||||
);
|
||||
if (parsed.status === "error") {
|
||||
new Notice(parsed.message);
|
||||
} else {
|
||||
const copied = cloneDeep(parsed.result);
|
||||
// new Notice(JSON.stringify(copied))
|
||||
this.plugin.settings = Object.assign(
|
||||
{},
|
||||
this.plugin.settings,
|
||||
copied
|
||||
);
|
||||
this.plugin.saveSettings();
|
||||
new Notice(
|
||||
t("protocol_saveqr", {
|
||||
manifestName: this.plugin.manifest.name,
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
new Notice(`${e}`);
|
||||
}
|
||||
|
||||
importSettingVal = "";
|
||||
} else {
|
||||
new Notice(t("settings_import_error_notice"));
|
||||
importSettingVal = "";
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
//////////////////////////////////////////////////
|
||||
// below for debug
|
||||
@@ -2198,12 +2410,37 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
||||
.setName(t("settings_syncplans"))
|
||||
.setDesc(t("settings_syncplans_desc"))
|
||||
.addButton(async (button) => {
|
||||
button.setButtonText(t("settings_syncplans_button_json"));
|
||||
button.setButtonText(t("settings_syncplans_button_1"));
|
||||
button.onClick(async () => {
|
||||
await exportVaultSyncPlansToFiles(
|
||||
this.plugin.db,
|
||||
this.app.vault,
|
||||
this.plugin.vaultRandomID
|
||||
this.plugin.vaultRandomID,
|
||||
1
|
||||
);
|
||||
new Notice(t("settings_syncplans_notice"));
|
||||
});
|
||||
})
|
||||
.addButton(async (button) => {
|
||||
button.setButtonText(t("settings_syncplans_button_5"));
|
||||
button.onClick(async () => {
|
||||
await exportVaultSyncPlansToFiles(
|
||||
this.plugin.db,
|
||||
this.app.vault,
|
||||
this.plugin.vaultRandomID,
|
||||
5
|
||||
);
|
||||
new Notice(t("settings_syncplans_notice"));
|
||||
});
|
||||
})
|
||||
.addButton(async (button) => {
|
||||
button.setButtonText(t("settings_syncplans_button_all"));
|
||||
button.onClick(async () => {
|
||||
await exportVaultSyncPlansToFiles(
|
||||
this.plugin.db,
|
||||
this.app.vault,
|
||||
this.plugin.vaultRandomID,
|
||||
-1
|
||||
);
|
||||
new Notice(t("settings_syncplans_notice"));
|
||||
});
|
||||
@@ -2234,6 +2471,21 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(debugDiv)
|
||||
.setName(t("settings_profiler_results"))
|
||||
.setDesc(t("settings_profiler_results_desc"))
|
||||
.addButton(async (button) => {
|
||||
button.setButtonText(t("settings_profiler_results_button_all"));
|
||||
button.onClick(async () => {
|
||||
await exportVaultProfilerResultsToFiles(
|
||||
this.plugin.db,
|
||||
this.app.vault,
|
||||
this.plugin.vaultRandomID
|
||||
);
|
||||
new Notice(t("settings_profiler_results_notice"));
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(debugDiv)
|
||||
.setName(t("settings_outputbasepathvaultid"))
|
||||
.setDesc(t("settings_outputbasepathvaultid_desc"))
|
||||
|
||||
+498
-349
File diff suppressed because it is too large
Load Diff
@@ -119,7 +119,7 @@ export class SyncAlgoV3Modal extends Modal {
|
||||
this.plugin.saveAgreeToUseNewSyncAlgorithm();
|
||||
this.plugin.enableAutoSyncIfSet();
|
||||
this.plugin.enableInitSyncIfSet();
|
||||
this.plugin.enableSyncOnSaveIfSet();
|
||||
this.plugin.toggleSyncOnSaveIfSet();
|
||||
} else {
|
||||
console.info("do not agree to use the new algorithm");
|
||||
this.plugin.unload();
|
||||
|
||||
+11
@@ -61,6 +61,13 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.webdis-disclaimer {
|
||||
font-weight: bold;
|
||||
}
|
||||
.webdis-hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.qrcode-img {
|
||||
width: 350px;
|
||||
height: 350px;
|
||||
@@ -74,3 +81,7 @@
|
||||
color: red;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.setting-need-wrapping-mobile .setting-item-control {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import * as chai from "chai";
|
||||
import chaiAsPromised from "chai-as-promised";
|
||||
import { strict as assert } from "assert";
|
||||
|
||||
import { RemotelySavePluginSettings } from "../src/baseTypes";
|
||||
import { messyConfigToNormal, normalConfigToMessy } from "../src/configPersist";
|
||||
|
||||
chai.use(chaiAsPromised);
|
||||
const expect = chai.expect;
|
||||
|
||||
const DEFAULT_SETTINGS: RemotelySavePluginSettings = {
|
||||
s3: {
|
||||
s3AccessKeyID: "acc",
|
||||
@@ -20,6 +16,9 @@ const DEFAULT_SETTINGS: RemotelySavePluginSettings = {
|
||||
onedrive: {
|
||||
username: "test 🍎 emoji",
|
||||
} as any,
|
||||
webdis: {
|
||||
address: "addr",
|
||||
} as any,
|
||||
password: "password",
|
||||
serviceType: "s3",
|
||||
currLogLevel: "info",
|
||||
@@ -32,6 +31,6 @@ describe("Config Persist tests", () => {
|
||||
const k = DEFAULT_SETTINGS;
|
||||
const k2 = normalConfigToMessy(k);
|
||||
const k3 = messyConfigToNormal(k2);
|
||||
expect(k3).to.deep.equal(k);
|
||||
assert.deepEqual(k3, k);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as chai from "chai";
|
||||
import chaiAsPromised from "chai-as-promised";
|
||||
import { strict as assert } from "assert";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import {
|
||||
@@ -13,9 +12,6 @@ import {
|
||||
} from "../src/encryptOpenSSL";
|
||||
import { base64ToBase64url, bufferToArrayBuffer } from "../src/misc";
|
||||
|
||||
chai.use(chaiAsPromised);
|
||||
const expect = chai.expect;
|
||||
|
||||
describe("Encryption OpenSSL tests", () => {
|
||||
beforeEach(function () {
|
||||
global.window = {
|
||||
@@ -26,7 +22,7 @@ describe("Encryption OpenSSL tests", () => {
|
||||
it("should encrypt string", async () => {
|
||||
const k = "dkjdhkfhdkjgsdklxxd";
|
||||
const password = "hey";
|
||||
expect(await encryptStringToBase32(k, password)).to.not.equal(k);
|
||||
assert.notEqual(await encryptStringToBase32(k, password), k);
|
||||
});
|
||||
|
||||
it("should encrypt string and return different results each time", async () => {
|
||||
@@ -34,7 +30,7 @@ describe("Encryption OpenSSL tests", () => {
|
||||
const password = "hey";
|
||||
const res1 = await encryptStringToBase32(k, password);
|
||||
const res2 = await encryptStringToBase32(k, password);
|
||||
expect(res1).to.not.equal(res2);
|
||||
assert.notEqual(res1, res2);
|
||||
});
|
||||
|
||||
it("should raise error using different password", async () => {
|
||||
@@ -42,7 +38,7 @@ describe("Encryption OpenSSL tests", () => {
|
||||
const password = "hey";
|
||||
const password2 = "hey2";
|
||||
const enc = await encryptStringToBase32(k, password);
|
||||
await expect(decryptBase32ToString(enc, password2)).to.be.rejected;
|
||||
await assert.rejects(decryptBase32ToString(enc, password2));
|
||||
});
|
||||
|
||||
it("should encrypt and decrypt string and get the same result returned", async () => {
|
||||
@@ -52,7 +48,7 @@ describe("Encryption OpenSSL tests", () => {
|
||||
// console.log(enc);
|
||||
const dec = await decryptBase32ToString(enc, password);
|
||||
// console.log(dec);
|
||||
expect(dec).equal(k);
|
||||
assert.equal(dec, k);
|
||||
});
|
||||
|
||||
it("should encrypt text file and get the same result as openssl", async () => {
|
||||
@@ -78,7 +74,7 @@ describe("Encryption OpenSSL tests", () => {
|
||||
// we output base32, so we need some transformation
|
||||
const opensslBase64urlRes = base64ToBase64url(opensslBase64Res);
|
||||
|
||||
expect(enc).equal(opensslBase64urlRes);
|
||||
assert.equal(enc, opensslBase64urlRes);
|
||||
});
|
||||
|
||||
it("should encrypt binary file and get the same result as openssl", async () => {
|
||||
@@ -102,7 +98,7 @@ describe("Encryption OpenSSL tests", () => {
|
||||
|
||||
// openssl enc -p -aes-256-cbc -S 8302F586FAB491EC -pbkdf2 -iter 20000 -pass pass:somepassword -in mona_lisa/1374px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg -out mona_lisa/1374px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg.enc
|
||||
|
||||
expect(Buffer.from(enc).equals(Buffer.from(opensslArrBuf))).to.be.true;
|
||||
assert.ok(Buffer.from(enc).equals(Buffer.from(opensslArrBuf)));
|
||||
});
|
||||
|
||||
it("should encrypt binary file not deterministically", async () => {
|
||||
@@ -116,7 +112,7 @@ describe("Encryption OpenSSL tests", () => {
|
||||
const res1 = await encryptArrayBuffer(fileArrBuf, password);
|
||||
const res2 = await encryptArrayBuffer(fileArrBuf, password);
|
||||
|
||||
expect(Buffer.from(res1).equals(Buffer.from(res2))).to.be.false;
|
||||
assert.ok(!Buffer.from(res1).equals(Buffer.from(res2)));
|
||||
});
|
||||
|
||||
it("should decrypt binary file and get the same result as openssl", async () => {
|
||||
@@ -132,36 +128,36 @@ describe("Encryption OpenSSL tests", () => {
|
||||
await fs.readFileSync(path.join(testFolder, testFileName))
|
||||
);
|
||||
|
||||
expect(Buffer.from(dec).equals(Buffer.from(opensslArrBuf))).to.be.true;
|
||||
assert.deepEqual(Buffer.from(dec), Buffer.from(opensslArrBuf));
|
||||
});
|
||||
|
||||
it("should get size from origin to encrypted correctly", () => {
|
||||
expect(() => getSizeFromOrigToEnc(-1)).to.throw();
|
||||
expect(() => getSizeFromOrigToEnc(0.5)).to.throw();
|
||||
expect(getSizeFromOrigToEnc(0)).equals(32);
|
||||
expect(getSizeFromOrigToEnc(15)).equals(32);
|
||||
expect(getSizeFromOrigToEnc(16)).equals(48);
|
||||
expect(getSizeFromOrigToEnc(31)).equals(48);
|
||||
expect(getSizeFromOrigToEnc(32)).equals(64);
|
||||
expect(getSizeFromOrigToEnc(14787203)).equals(14787232);
|
||||
assert.throws(() => getSizeFromOrigToEnc(-1));
|
||||
assert.throws(() => getSizeFromOrigToEnc(0.5));
|
||||
assert.equal(getSizeFromOrigToEnc(0), 32);
|
||||
assert.equal(getSizeFromOrigToEnc(15), 32);
|
||||
assert.equal(getSizeFromOrigToEnc(16), 48);
|
||||
assert.equal(getSizeFromOrigToEnc(31), 48);
|
||||
assert.equal(getSizeFromOrigToEnc(32), 64);
|
||||
assert.equal(getSizeFromOrigToEnc(14787203), 14787232);
|
||||
});
|
||||
|
||||
it("should get size from encrypted to origin correctly", () => {
|
||||
expect(() => getSizeFromEncToOrig(-1)).to.throw();
|
||||
expect(() => getSizeFromEncToOrig(30)).to.throw();
|
||||
assert.throws(() => getSizeFromEncToOrig(-1));
|
||||
assert.throws(() => getSizeFromEncToOrig(30));
|
||||
|
||||
expect(getSizeFromEncToOrig(32)).to.deep.equal({
|
||||
assert.deepEqual(getSizeFromEncToOrig(32), {
|
||||
minSize: 0,
|
||||
maxSize: 15,
|
||||
});
|
||||
expect(getSizeFromEncToOrig(48)).to.deep.equal({
|
||||
assert.deepEqual(getSizeFromEncToOrig(48), {
|
||||
minSize: 16,
|
||||
maxSize: 31,
|
||||
});
|
||||
|
||||
expect(() => getSizeFromEncToOrig(14787231)).to.throw();
|
||||
assert.throws(() => getSizeFromEncToOrig(14787231));
|
||||
|
||||
let { minSize, maxSize } = getSizeFromEncToOrig(14787232);
|
||||
expect(minSize <= 14787203 && 14787203 <= maxSize).to.be.true;
|
||||
assert.ok(minSize <= 14787203 && 14787203 <= maxSize);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { strict as assert } from "assert";
|
||||
import { getOrigPath } from "../src/fsWebdis";
|
||||
|
||||
describe("Webdis operations tests", () => {
|
||||
it("should get orig keys correctly", () => {
|
||||
const input = "rs:fs:v1:库名字/something dev.md:meta";
|
||||
const output = getOrigPath(input, "库名字");
|
||||
const expected = "something dev.md";
|
||||
|
||||
assert.equal(output, expected);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,9 @@
|
||||
import * as chai from "chai";
|
||||
import chaiAsPromised from "chai-as-promised";
|
||||
|
||||
import { strict as assert } from "assert";
|
||||
import {
|
||||
isEqualMetadataOnRemote,
|
||||
MetadataOnRemote,
|
||||
} from "../src/metadataOnRemote";
|
||||
|
||||
chai.use(chaiAsPromised);
|
||||
const expect = chai.expect;
|
||||
|
||||
describe("Metadata operations tests", () => {
|
||||
it("should compare objects deeply", async () => {
|
||||
const a: MetadataOnRemote = {
|
||||
@@ -24,7 +19,7 @@ describe("Metadata operations tests", () => {
|
||||
],
|
||||
};
|
||||
|
||||
expect(isEqualMetadataOnRemote(a, b));
|
||||
assert.ok(isEqualMetadataOnRemote(a, b));
|
||||
});
|
||||
|
||||
it("should find diff", async () => {
|
||||
@@ -41,7 +36,7 @@ describe("Metadata operations tests", () => {
|
||||
],
|
||||
};
|
||||
|
||||
expect(!isEqualMetadataOnRemote(a, b));
|
||||
assert.ok(!isEqualMetadataOnRemote(a, b));
|
||||
});
|
||||
|
||||
it("should treat undefined correctly", async () => {
|
||||
@@ -53,22 +48,22 @@ describe("Metadata operations tests", () => {
|
||||
],
|
||||
};
|
||||
|
||||
expect(!isEqualMetadataOnRemote(a, b));
|
||||
assert.ok(!isEqualMetadataOnRemote(a, b));
|
||||
|
||||
b = { deletions: [] };
|
||||
expect(isEqualMetadataOnRemote(a, b));
|
||||
assert.ok(isEqualMetadataOnRemote(a, b));
|
||||
|
||||
b = { deletions: undefined };
|
||||
expect(isEqualMetadataOnRemote(a, b));
|
||||
assert.ok(isEqualMetadataOnRemote(a, b));
|
||||
|
||||
b = undefined;
|
||||
expect(isEqualMetadataOnRemote(a, b));
|
||||
assert.ok(isEqualMetadataOnRemote(a, b));
|
||||
});
|
||||
|
||||
it("should ignore generated at fields", async () => {
|
||||
const a: MetadataOnRemote = {
|
||||
deletions: [
|
||||
{ key: "xxxx", actionWhen: 1 },
|
||||
{ key: "xxx", actionWhen: 1 },
|
||||
{ key: "yyy", actionWhen: 2 },
|
||||
],
|
||||
generatedWhen: 1,
|
||||
@@ -81,6 +76,6 @@ describe("Metadata operations tests", () => {
|
||||
generatedWhen: 2,
|
||||
};
|
||||
|
||||
expect(isEqualMetadataOnRemote(a, b));
|
||||
assert.ok(isEqualMetadataOnRemote(a, b));
|
||||
});
|
||||
});
|
||||
|
||||
+122
-69
@@ -1,139 +1,139 @@
|
||||
import { expect } from "chai";
|
||||
import { strict as assert } from "assert";
|
||||
import { JSDOM } from "jsdom";
|
||||
import * as misc from "../src/misc";
|
||||
|
||||
describe("Misc: hidden file", () => {
|
||||
it("should find hidden file correctly", () => {
|
||||
let item = "";
|
||||
expect(misc.isHiddenPath(item)).to.be.false;
|
||||
assert.ok(!misc.isHiddenPath(item));
|
||||
|
||||
item = ".";
|
||||
expect(misc.isHiddenPath(item)).to.be.false;
|
||||
assert.ok(!misc.isHiddenPath(item));
|
||||
|
||||
item = "..";
|
||||
expect(misc.isHiddenPath(item)).to.be.false;
|
||||
assert.ok(!misc.isHiddenPath(item));
|
||||
|
||||
item = "/x/y/z/../././../a/b/c";
|
||||
expect(misc.isHiddenPath(item)).to.be.false;
|
||||
assert.ok(!misc.isHiddenPath(item));
|
||||
|
||||
item = ".hidden";
|
||||
expect(misc.isHiddenPath(item)).to.be.true;
|
||||
assert.ok(misc.isHiddenPath(item));
|
||||
|
||||
item = "_hidden_loose";
|
||||
expect(misc.isHiddenPath(item)).to.be.true;
|
||||
expect(misc.isHiddenPath(item, true, false)).to.be.false;
|
||||
assert.ok(misc.isHiddenPath(item));
|
||||
assert.ok(!misc.isHiddenPath(item, true, false));
|
||||
|
||||
item = "/sdd/_hidden_loose";
|
||||
expect(misc.isHiddenPath(item)).to.be.true;
|
||||
assert.ok(misc.isHiddenPath(item));
|
||||
|
||||
item = "what/../_hidden_loose/what/what/what";
|
||||
expect(misc.isHiddenPath(item)).to.be.true;
|
||||
assert.ok(misc.isHiddenPath(item));
|
||||
|
||||
item = "what/../_hidden_loose/what/what/what";
|
||||
expect(misc.isHiddenPath(item, true, false)).to.be.false;
|
||||
assert.ok(!misc.isHiddenPath(item, true, false));
|
||||
|
||||
item = "what/../_hidden_loose/../.hidden/what/what/what";
|
||||
expect(misc.isHiddenPath(item, true, false)).to.be.true;
|
||||
assert.ok(misc.isHiddenPath(item, true, false));
|
||||
|
||||
item = "what/../_hidden_loose/../.hidden/what/what/what";
|
||||
expect(misc.isHiddenPath(item, false, true)).to.be.false;
|
||||
assert.ok(!misc.isHiddenPath(item, false, true));
|
||||
|
||||
item = "what/_hidden_loose/what/what/what";
|
||||
expect(misc.isHiddenPath(item, false, true)).to.be.true;
|
||||
expect(misc.isHiddenPath(item, true, false)).to.be.false;
|
||||
assert.ok(misc.isHiddenPath(item, false, true));
|
||||
assert.ok(!misc.isHiddenPath(item, true, false));
|
||||
|
||||
item = "what/.hidden/what/what/what";
|
||||
expect(misc.isHiddenPath(item, false, true)).to.be.false;
|
||||
expect(misc.isHiddenPath(item, true, false)).to.be.true;
|
||||
assert.ok(!misc.isHiddenPath(item, false, true));
|
||||
assert.ok(misc.isHiddenPath(item, true, false));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Misc: get folder levels", () => {
|
||||
it("should ignore empty path", () => {
|
||||
const item = "";
|
||||
expect(misc.getFolderLevels(item)).to.be.empty;
|
||||
assert.equal(misc.getFolderLevels(item).length, 0);
|
||||
});
|
||||
|
||||
it("should ignore single file", () => {
|
||||
const item = "xxx";
|
||||
expect(misc.getFolderLevels(item)).to.be.empty;
|
||||
assert.equal(misc.getFolderLevels(item).length, 0);
|
||||
});
|
||||
|
||||
it("should detect path ending with /", () => {
|
||||
const item = "xxx/";
|
||||
const res = ["xxx"];
|
||||
expect(misc.getFolderLevels(item)).to.deep.equal(res);
|
||||
assert.deepEqual(misc.getFolderLevels(item), res);
|
||||
});
|
||||
|
||||
it("should correctly split folders and files", () => {
|
||||
const item = "xxx/yyy/zzz.md";
|
||||
const res = ["xxx", "xxx/yyy"];
|
||||
expect(misc.getFolderLevels(item)).to.deep.equal(res);
|
||||
assert.deepEqual(misc.getFolderLevels(item), res);
|
||||
|
||||
const item2 = "xxx/yyy/zzz";
|
||||
const res2 = ["xxx", "xxx/yyy"];
|
||||
expect(misc.getFolderLevels(item2)).to.deep.equal(res2);
|
||||
assert.deepEqual(misc.getFolderLevels(item2), res2);
|
||||
|
||||
const item3 = "xxx/yyy/zzz/";
|
||||
const res3 = ["xxx", "xxx/yyy", "xxx/yyy/zzz"];
|
||||
expect(misc.getFolderLevels(item3)).to.deep.equal(res3);
|
||||
assert.deepEqual(misc.getFolderLevels(item3), res3);
|
||||
});
|
||||
|
||||
it("should correctly add ending slash if required", () => {
|
||||
const item = "xxx/yyy/zzz.md";
|
||||
const res = ["xxx/", "xxx/yyy/"];
|
||||
expect(misc.getFolderLevels(item, true)).to.deep.equal(res);
|
||||
assert.deepEqual(misc.getFolderLevels(item, true), res);
|
||||
|
||||
const item2 = "xxx/yyy/zzz";
|
||||
const res2 = ["xxx/", "xxx/yyy/"];
|
||||
expect(misc.getFolderLevels(item2, true)).to.deep.equal(res2);
|
||||
assert.deepEqual(misc.getFolderLevels(item2, true), res2);
|
||||
|
||||
const item3 = "xxx/yyy/zzz/";
|
||||
const res3 = ["xxx/", "xxx/yyy/", "xxx/yyy/zzz/"];
|
||||
expect(misc.getFolderLevels(item3, true)).to.deep.equal(res3);
|
||||
assert.deepEqual(misc.getFolderLevels(item3, true), res3);
|
||||
});
|
||||
|
||||
it("should treat path starting with / correctly", () => {
|
||||
const item = "/xxx/yyy/zzz.md";
|
||||
const res = ["/xxx", "/xxx/yyy"];
|
||||
expect(misc.getFolderLevels(item)).to.deep.equal(res);
|
||||
assert.deepEqual(misc.getFolderLevels(item), res);
|
||||
|
||||
const item2 = "/xxx/yyy/zzz";
|
||||
const res2 = ["/xxx", "/xxx/yyy"];
|
||||
expect(misc.getFolderLevels(item2)).to.deep.equal(res2);
|
||||
assert.deepEqual(misc.getFolderLevels(item2), res2);
|
||||
|
||||
const item3 = "/xxx/yyy/zzz/";
|
||||
const res3 = ["/xxx", "/xxx/yyy", "/xxx/yyy/zzz"];
|
||||
expect(misc.getFolderLevels(item3)).to.deep.equal(res3);
|
||||
assert.deepEqual(misc.getFolderLevels(item3), res3);
|
||||
|
||||
const item4 = "/xxx";
|
||||
const res4 = [] as string[];
|
||||
expect(misc.getFolderLevels(item4)).to.deep.equal(res4);
|
||||
assert.deepEqual(misc.getFolderLevels(item4), res4);
|
||||
|
||||
const item5 = "/";
|
||||
const res5 = [] as string[];
|
||||
expect(misc.getFolderLevels(item5)).to.deep.equal(res5);
|
||||
assert.deepEqual(misc.getFolderLevels(item5), res5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Misc: get parent folder", () => {
|
||||
it("should treat empty path correctly", () => {
|
||||
const item = "";
|
||||
expect(misc.getParentFolder(item)).equals("/");
|
||||
assert.equal(misc.getParentFolder(item), "/");
|
||||
});
|
||||
|
||||
it("should treat one level path correctly", () => {
|
||||
let item = "abc/";
|
||||
expect(misc.getParentFolder(item)).equals("/");
|
||||
assert.equal(misc.getParentFolder(item), "/");
|
||||
item = "/efg/";
|
||||
expect(misc.getParentFolder(item)).equals("/");
|
||||
assert.equal(misc.getParentFolder(item), "/");
|
||||
});
|
||||
|
||||
it("should treat more levels path correctly", () => {
|
||||
let item = "abc/efg";
|
||||
expect(misc.getParentFolder(item)).equals("abc/");
|
||||
assert.equal(misc.getParentFolder(item), "abc/");
|
||||
item = "/hij/klm/";
|
||||
expect(misc.getParentFolder(item)).equals("/hij/");
|
||||
assert.equal(misc.getParentFolder(item), "/hij/");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -141,18 +141,18 @@ describe("Misc: vaild file name tests", () => {
|
||||
it("should treat no ascii correctly", async () => {
|
||||
const x = misc.isVaildText("😄🍎 apple 苹果");
|
||||
// console.log(x)
|
||||
expect(x).to.be.true;
|
||||
assert.ok(x);
|
||||
});
|
||||
|
||||
it("should find not-printable chars correctly", async () => {
|
||||
const x = misc.isVaildText("😄🍎 apple 苹果\u0000");
|
||||
// console.log(x)
|
||||
expect(x).to.be.false;
|
||||
assert.ok(!x);
|
||||
});
|
||||
|
||||
it("should allow spaces/slashes/...", async () => {
|
||||
const x = misc.isVaildText("😄🍎 apple 苹果/-_=/\\*%^&@#$`");
|
||||
expect(x).to.be.true;
|
||||
assert.ok(x);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -160,21 +160,21 @@ describe("Misc: get dirname", () => {
|
||||
it("should return itself for folder", async () => {
|
||||
const x = misc.getPathFolder("ssss/");
|
||||
// console.log(x)
|
||||
expect(x).to.equal("ssss/");
|
||||
assert.equal(x, "ssss/");
|
||||
});
|
||||
|
||||
it("should return folder for file", async () => {
|
||||
const x = misc.getPathFolder("sss/yyy");
|
||||
// console.log(x)
|
||||
expect(x).to.equal("sss/");
|
||||
assert.equal(x, "sss/");
|
||||
});
|
||||
|
||||
it("should treat / specially", async () => {
|
||||
const x = misc.getPathFolder("/");
|
||||
expect(x).to.equal("/");
|
||||
assert.equal(x, "/");
|
||||
|
||||
const y = misc.getPathFolder("/abc");
|
||||
expect(y).to.equal("/");
|
||||
assert.equal(y, "/");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -188,7 +188,7 @@ describe("Misc: extract svg", () => {
|
||||
const x = "<svg><rect/><g/></svg>";
|
||||
const y = misc.extractSvgSub(x);
|
||||
// console.log(x)
|
||||
expect(y).to.equal("<rect/><g/>");
|
||||
assert.equal(y, "<rect/><g/>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -202,7 +202,7 @@ describe("Misc: get split ranges", () => {
|
||||
end: 10,
|
||||
},
|
||||
];
|
||||
expect(k).to.deep.equal(k2);
|
||||
assert.deepEqual(k, k2);
|
||||
});
|
||||
|
||||
it("should deal with 0 remainder", () => {
|
||||
@@ -219,7 +219,7 @@ describe("Misc: get split ranges", () => {
|
||||
end: 20,
|
||||
},
|
||||
];
|
||||
expect(k).to.deep.equal(k2);
|
||||
assert.deepEqual(k, k2);
|
||||
});
|
||||
|
||||
it("should deal with not-0 remainder", () => {
|
||||
@@ -241,47 +241,100 @@ describe("Misc: get split ranges", () => {
|
||||
end: 25,
|
||||
},
|
||||
];
|
||||
expect(k).to.deep.equal(k2);
|
||||
assert.deepEqual(k, k2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Misc: at which level", () => {
|
||||
it("should throw error on some parameters", () => {
|
||||
expect(() => misc.atWhichLevel(undefined)).to.throw();
|
||||
expect(() => misc.atWhichLevel("")).to.throw();
|
||||
expect(() => misc.atWhichLevel("..")).to.throw();
|
||||
expect(() => misc.atWhichLevel(".")).to.throw();
|
||||
expect(() => misc.atWhichLevel("/")).to.throw();
|
||||
expect(() => misc.atWhichLevel("/xxyy")).to.throw();
|
||||
assert.throws(() => misc.atWhichLevel(undefined));
|
||||
assert.throws(() => misc.atWhichLevel(""));
|
||||
assert.throws(() => misc.atWhichLevel(".."));
|
||||
assert.throws(() => misc.atWhichLevel("."));
|
||||
assert.throws(() => misc.atWhichLevel("/"));
|
||||
assert.throws(() => misc.atWhichLevel("/xxyy"));
|
||||
});
|
||||
|
||||
it("should treat folders correctly", () => {
|
||||
expect(misc.atWhichLevel("x/")).to.be.equal(1);
|
||||
expect(misc.atWhichLevel("x/y/")).to.be.equal(2);
|
||||
assert.equal(misc.atWhichLevel("x/"), 1);
|
||||
assert.equal(misc.atWhichLevel("x/y/"), 2);
|
||||
});
|
||||
|
||||
it("should treat files correctly", () => {
|
||||
expect(misc.atWhichLevel("x.md")).to.be.equal(1);
|
||||
expect(misc.atWhichLevel("x/y.md")).to.be.equal(2);
|
||||
expect(misc.atWhichLevel("x/y/z.md")).to.be.equal(3);
|
||||
assert.equal(misc.atWhichLevel("x.md"), 1);
|
||||
assert.equal(misc.atWhichLevel("x/y.md"), 2);
|
||||
assert.equal(misc.atWhichLevel("x/y/z.md"), 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Misc: special char for dir", () => {
|
||||
it("should return false for normal string", () => {
|
||||
expect(misc.checkHasSpecialCharForDir("")).to.be.false;
|
||||
expect(misc.checkHasSpecialCharForDir("xxx")).to.be.false;
|
||||
expect(misc.checkHasSpecialCharForDir("yyy_xxx")).to.be.false;
|
||||
expect(misc.checkHasSpecialCharForDir("yyy.xxx")).to.be.false;
|
||||
expect(misc.checkHasSpecialCharForDir("yyy?xxx")).to.be.false;
|
||||
assert.ok(!misc.checkHasSpecialCharForDir(""));
|
||||
assert.ok(!misc.checkHasSpecialCharForDir("xxx"));
|
||||
assert.ok(!misc.checkHasSpecialCharForDir("yyy_xxx"));
|
||||
assert.ok(!misc.checkHasSpecialCharForDir("yyy.xxx"));
|
||||
assert.ok(!misc.checkHasSpecialCharForDir("yyy?xxx"));
|
||||
});
|
||||
|
||||
it("should return true for special cases", () => {
|
||||
expect(misc.checkHasSpecialCharForDir("?")).to.be.true;
|
||||
expect(misc.checkHasSpecialCharForDir("/")).to.be.true;
|
||||
expect(misc.checkHasSpecialCharForDir("\\")).to.be.true;
|
||||
expect(misc.checkHasSpecialCharForDir("xxx/yyy")).to.be.true;
|
||||
expect(misc.checkHasSpecialCharForDir("xxx\\yyy")).to.be.true;
|
||||
expect(misc.checkHasSpecialCharForDir("xxx?yyy")).to.be.true;
|
||||
assert.ok(misc.checkHasSpecialCharForDir("?"));
|
||||
assert.ok(misc.checkHasSpecialCharForDir("/"));
|
||||
assert.ok(misc.checkHasSpecialCharForDir("\\"));
|
||||
assert.ok(misc.checkHasSpecialCharForDir("xxx/yyy"));
|
||||
assert.ok(misc.checkHasSpecialCharForDir("xxx\\yyy"));
|
||||
assert.ok(misc.checkHasSpecialCharForDir("xxx?yyy"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Misc: Dropbox: should fix the folder name cases", () => {
|
||||
it("should do nothing on empty folders", () => {
|
||||
const input: any[] = [];
|
||||
assert.equal(misc.fixEntityListCasesInplace(input).length, 0);
|
||||
});
|
||||
|
||||
it("should sort folders by length by side effect", () => {
|
||||
const input = [
|
||||
{ keyRaw: "aaaa/" },
|
||||
{ keyRaw: "bbb/" },
|
||||
{ keyRaw: "c/" },
|
||||
{ keyRaw: "dd/" },
|
||||
];
|
||||
|
||||
const output = [
|
||||
{ keyRaw: "c/" },
|
||||
{ keyRaw: "dd/" },
|
||||
{ keyRaw: "bbb/" },
|
||||
{ keyRaw: "aaaa/" },
|
||||
];
|
||||
assert.deepEqual(misc.fixEntityListCasesInplace(input), output);
|
||||
});
|
||||
|
||||
it("should fix folder names", () => {
|
||||
const input = [
|
||||
{ keyRaw: "AAA/" },
|
||||
{ keyRaw: "aaa/bbb/CCC.md" },
|
||||
{ keyRaw: "aaa/BBB/" },
|
||||
|
||||
{ keyRaw: "ddd/" },
|
||||
{ keyRaw: "DDD/EEE/fff.md" },
|
||||
{ keyRaw: "DDD/eee/" },
|
||||
|
||||
{ keyRaw: "Ggg/" },
|
||||
{ keyRaw: "ggG/hHH你好/Fff世界.md" },
|
||||
{ keyRaw: "ggG/Hhh你好/" },
|
||||
];
|
||||
|
||||
const output = [
|
||||
{ keyRaw: "AAA/" },
|
||||
{ keyRaw: "ddd/" },
|
||||
{ keyRaw: "Ggg/" },
|
||||
{ keyRaw: "AAA/BBB/" },
|
||||
{ keyRaw: "ddd/eee/" },
|
||||
{ keyRaw: "Ggg/Hhh你好/" },
|
||||
{ keyRaw: "AAA/BBB/CCC.md" },
|
||||
{ keyRaw: "ddd/eee/fff.md" },
|
||||
{ keyRaw: "Ggg/Hhh你好/Fff世界.md" },
|
||||
];
|
||||
assert.deepEqual(misc.fixEntityListCasesInplace(input), output);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,7 @@ module.exports = {
|
||||
// crypto: false,
|
||||
// domain: require.resolve("domain-browser"),
|
||||
// events: require.resolve("events"),
|
||||
fs: false,
|
||||
http: false,
|
||||
// http: require.resolve("stream-http"),
|
||||
https: false,
|
||||
@@ -87,6 +88,7 @@ module.exports = {
|
||||
url: require.resolve("url/"),
|
||||
// util: require.resolve("util"),
|
||||
// vm: require.resolve("vm-browserify"),
|
||||
vm: false,
|
||||
// zlib: require.resolve("browserify-zlib"),
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user