Compare commits

...
37 Commits
Author SHA1 Message Date
Bruno Miiller 39711d117c Add CLAUDE.md for AI pair programmer context
CI / Lint (push) Failing after 44s
CI / Test (push) Has been skipped
CI / Security (push) Has been skipped
- Stack and pin policy (Node 24, npm 11, 30-day dep aging)
- Fork-specific convention: English commits (vs upstream-aligned)
- Coverage ratchet at 8% (only goes up)
- CI structure: Lint → Test → Security, build moved to release.yml
- Pointers to docs/ARCHITECTURE, DEVELOPMENT, CONTRIBUTING
- Sensitive areas flagged: fake-license shortcut in pro/account.ts,
  large untested files (main.ts, settings.ts, pro/sync.ts)
2026-05-19 23:15:06 -03:00
Bruno Miiller 60bad0fcd3 Keep CI test stage focused: drop build step
The Lint → Test → Security pattern keeps each stage to a single concern.
`npm run build` belonged to test only to publish a PR-preview artifact;
release.yml already handles production builds, and developers can run
`npm run build` locally. Removing it from the test stage:

- aligns with code-standards/ci/pipeline-structure.md
- shaves ~70s off PR CI time
- removes the "dist" artifact upload (coverage upload stays)
2026-05-19 23:09:27 -03:00
Bruno Miiller 46f86c0091 Tighten CI: coverage ratchet at 8% and ignore build artifacts
- package.json: test:coverage adds --check-coverage --lines=8 (current
  baseline is 8.32%; floor only goes up)
- biome.json: ignore *.main.js webpack chunks, coverage/, node_modules/
- docs/CONTRIBUTING.md: document ratchet policy and long-term 70% target
2026-05-19 23:07:36 -03:00
Bruno Miiller af79356e58 Apply biome format and lint fixes
- pro/src/account.ts: cast parens (}) as T; biome-ignore on intentional
  unreachable code after fake-license shortcut
- pro/src/fsGoogleDrive.ts: let → const; missing semicolon; == → ===
  (mimeType comparison and parent lookup)
- src/misc.ts: line break on long b.buffer.slice() chain
2026-05-19 23:07:36 -03:00
Bruno Miiller 6a6f192942 Make build pass on Node 24 and reduce npm audit surface
Build fixes:
- tsconfig.json: moduleResolution "node" → "bundler" (resolves node-diff3
  v3.2.0 exports-only package); add "es2021" to lib for AggregateError
- webpack.config.js: NormalModuleReplacementPlugin to strip "node:" URI
  prefix so resolve.fallback browserify shims apply
- Remove "aggregate-error" import in src/main.ts, src/fsS3.ts,
  pro/src/sync.ts — use native global AggregateError (Node 15+, Electron
  98+, both satisfied by Obsidian runtime)
- .gitignore: ignore *.main.js webpack chunks

Audit reduction (22 → 4 low):
- Remove npm-check-updates devDep (use `npx ncu` ad-hoc) — kills 15 vulns
  from transitive cacache/sigstore/tar/pacote chain
- Pin to versions ≥ 30 days old (supply-chain hygiene): @types/node 24.12.2,
  c8 11.0.0, mocha 11.7.5, esbuild 0.28.0, crypto-browserify 3.12.1
- package.json overrides: elliptic@6.6.1, diff@9.0.0,
  serialize-javascript@7.0.5 to push transitive fixes
- Remaining 4 lows are all elliptic (advisory marks all versions
  vulnerable; no upstream fix available)
2026-05-19 22:57:29 -03:00
A 87059c37a0 replace bigint with number 2026-05-19 22:12:40 -03:00
A c96de5ccbc fix esbuild and typing issues 2026-05-19 22:12:40 -03:00
Bruno Miiller f92bcd630d Add full CI pipeline (Lint → Test → Security)
- ci.yml: 3-stage GitHub Actions following code-standards/ci pattern
  - Lint (3min): biome ci
  - Test (10min): npm test with c8 coverage + webpack build + artifacts
  - Security (5min): npm audit (high) + biome lint
- Replace auto-build.yml (single-job install+test+build)
- Add c8 devDep + test:coverage script (text/lcov/html)
- .c8rc.json: include src/ and pro/src/, exclude tests/langs
- Track package-lock.json (required by npm ci in CI)
- Clean up .gitignore: remove .* allowlist antipattern, list specific ignores
2026-05-19 22:12:33 -03:00
Bruno Miiller 48b4f7e19c Add docs/ARCHITECTURE, CONTRIBUTING and DEVELOPMENT
- ARCHITECTURE.md: high-level view of FakeFs abstraction, sync flow, state
- DEVELOPMENT.md: mise setup + npm commands + local install
- CONTRIBUTING.md: workflow, English commit conventions, upstream PR cherry-pick
2026-05-19 22:10:31 -03:00
Bruno Miiller 9dde1bcdc8 Add mise.toml and pin Node LTS
- mise.toml with node = "24.15.0" (current LTS)
- package.json: packageManager = "npm@11.12.1", engines.node >= 24.15.0
2026-05-19 22:10:31 -03:00
adrian-fuertes 34db181af0 fix typos in sync messages and comments (startting -> starting) (#876)
BuildCI / build (20.x) (push) Failing after 5m56s
2024-11-10 18:16:22 +08:00
Liam Swayne 6631a9c5eb typo fixes (#870) 2024-11-10 18:14:16 +08:00
fyears e39edcea70 bump to 0.5.25
Release A New Version / build (20.x) (push) Failing after 46s
2024-10-20 17:12:42 +08:00
fyears 9ac7ecf828 fix undefined 2024-10-20 16:58:22 +08:00
fyears 50c08400ff add more prefix 2024-10-20 16:42:47 +08:00
fyears 7bc2fad88b bump to 0.5.24
Release A New Version / build (20.x) (push) Failing after 41s
2024-09-30 21:15:53 +08:00
fyears 6dc1c28361 add caddy 2024-09-30 21:15:23 +08:00
fyears 2220f511e3 fix webdav path with /.. leading 2024-09-30 20:51:55 +08:00
fyears 06de1a4ab0 bump to 0.5.23
Release A New Version / build (20.x) (push) Failing after 47s
2024-09-30 18:11:43 +08:00
fyears df184ff9d8 correct way to deal with .obsidian and bookmarks 2024-09-30 18:11:08 +08:00
fyears f50ba953f1 fix ci
Release A New Version / build (20.x) (push) Failing after 50s
2024-09-29 10:19:48 +08:00
fyears 55cb26bd22 bump to 0.5.22 2024-09-29 10:11:01 +08:00
fyears 36d3018efb fix ignore logic 2024-09-29 10:09:49 +08:00
fyears 421b43d937 update complex logic for config 2024-09-29 09:32:04 +08:00
xvqinghong 17708359a1 fix bug of unwished synchronizing Config Directory and Bookmarks File (#816)
In the original code, if we set syncConfigDir to false, which means we don't want to sync this directory, but it will also cause `if( syncConfigDir && isInsideObsFolder(key, configDir) )` statement NEVER be entered.  Thus, in the original code, our setting is actually be ignored.
2024-09-29 09:20:27 +08:00
fyears 9acae1b077 fix google drive expire 2024-09-29 08:59:14 +08:00
fyears 4ade975585 warn in console about expire 2024-09-29 08:58:57 +08:00
fyears e988fc7ae9 update ci 2024-09-09 00:39:19 +08:00
fyears 5c56f2146f 0.5.21
Release A New Version / build (20.x) (push) Failing after 41s
2024-09-09 00:13:09 +08:00
fyears 74125f8dff fix config dir special empty condiction 2024-09-09 00:12:38 +08:00
fyears ace16de20c add complex logic to allow list and ignore list 2024-09-09 00:00:12 +08:00
fyears 716e5aa6c5 not detect chars in name for now 2024-09-07 15:26:19 +08:00
fyears fcdbb71493 add star and download history 2024-09-01 18:53:19 +08:00
fyears 0040e18b49 fix link 2024-09-01 18:30:31 +08:00
fyears b5d56843fd bump to 0.5.20
Release A New Version / build (20.x) (push) Failing after 52s
2024-09-01 18:28:46 +08:00
fyears b63b6791aa re-enable profiler 2024-09-01 18:28:08 +08:00
Matvey Romanov 2a3d1265b3 Update en.json (#794) 2024-09-01 17:34:46 +08:00
44 changed files with 12662 additions and 278 deletions
+6
View File
@@ -0,0 +1,6 @@
{
"all": true,
"include": ["src/**/*.ts", "pro/src/**/*.ts"],
"exclude": ["tests/**", "pro/tests/**", "**/*.d.ts", "**/langs/**"],
"reports-dir": "coverage"
}
@@ -1,7 +1,4 @@
# This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node name: CI
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: BuildCI
on: on:
push: push:
@@ -9,12 +6,27 @@ on:
pull_request: pull_request:
branches: [master] branches: [master]
permissions:
contents: read
jobs: jobs:
build: lint:
name: Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
- run: npm ci
- name: Biome check
run: npx @biomejs/biome ci .
test:
name: Test
needs: lint
runs-on: ubuntu-latest
timeout-minutes: 10
environment: env-for-buildci environment: env-for-buildci
env: env:
DROPBOX_APP_KEY: ${{secrets.DROPBOX_APP_KEY}} DROPBOX_APP_KEY: ${{secrets.DROPBOX_APP_KEY}}
ONEDRIVE_CLIENT_ID: ${{secrets.ONEDRIVE_CLIENT_ID}} ONEDRIVE_CLIENT_ID: ${{secrets.ONEDRIVE_CLIENT_ID}}
@@ -31,21 +43,14 @@ jobs:
YANDEXDISK_CLIENT_SECRET: ${{secrets.YANDEXDISK_CLIENT_SECRET}} YANDEXDISK_CLIENT_SECRET: ${{secrets.YANDEXDISK_CLIENT_SECRET}}
KOOFR_CLIENT_ID: ${{secrets.KOOFR_CLIENT_ID}} KOOFR_CLIENT_ID: ${{secrets.KOOFR_CLIENT_ID}}
KOOFR_CLIENT_SECRET: ${{secrets.KOOFR_CLIENT_SECRET}} KOOFR_CLIENT_SECRET: ${{secrets.KOOFR_CLIENT_SECRET}}
strategy:
matrix:
node-version: [20.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps: steps:
- name: Checkout codes - uses: actions/checkout@v4
uses: actions/checkout@v2
with: with:
submodules: recursive submodules: recursive
- name: Checkout LFS file list - name: Checkout LFS file list
run: git lfs ls-files --long | cut -d ' ' -f1 | sort > .lfs-assets-id run: git lfs ls-files --long | cut -d ' ' -f1 | sort > .lfs-assets-id
- name: LFS Cache - name: LFS Cache
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: .git/lfs/objects path: .git/lfs/objects
key: ${{ runner.os }}-lfs-${{ hashFiles('.lfs-assets-id') }} key: ${{ runner.os }}-lfs-${{ hashFiles('.lfs-assets-id') }}
@@ -53,17 +58,26 @@ jobs:
${{ runner.os }}-lfs- ${{ runner.os }}-lfs-
- name: Git LFS Pull - name: Git LFS Pull
run: git lfs pull run: git lfs pull
- name: Use Node.js ${{ matrix.node-version }} - uses: jdx/mise-action@v2
uses: actions/setup-node@v4 - run: npm ci
- name: Testes com cobertura
run: npm run test:coverage
- uses: actions/upload-artifact@v4
if: always()
with: with:
node-version: ${{ matrix.node-version }} name: coverage
- run: npm install path: coverage/
- run: npm test
- run: npm run build security:
- uses: actions/upload-artifact@v2 name: Security
with: needs: test
name: my-dist runs-on: ubuntu-latest
path: | timeout-minutes: 5
main.js steps:
manifest.json - uses: actions/checkout@v4
styles.css - uses: jdx/mise-action@v2
- run: npm ci
- name: npm audit
run: npm audit --audit-level=high
- name: Biome lint (regras de seguranca)
run: npx @biomejs/biome lint .
+11 -41
View File
@@ -42,13 +42,13 @@ jobs:
steps: steps:
- name: Checkout codes - name: Checkout codes
uses: actions/checkout@v2 uses: actions/checkout@v4
with: with:
submodules: recursive submodules: recursive
- name: Checkout LFS file list - name: Checkout LFS file list
run: git lfs ls-files --long | cut -d ' ' -f1 | sort > .lfs-assets-id run: git lfs ls-files --long | cut -d ' ' -f1 | sort > .lfs-assets-id
- name: LFS Cache - name: LFS Cache
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: .git/lfs/objects path: .git/lfs/objects
key: ${{ runner.os }}-lfs-${{ hashFiles('.lfs-assets-id') }} key: ${{ runner.os }}-lfs-${{ hashFiles('.lfs-assets-id') }}
@@ -63,44 +63,14 @@ jobs:
- run: npm install - run: npm install
- run: npm test - run: npm test
- run: npm run build - run: npm run build
- name: Create Release - name: Create Release And Upload
id: create_release uses: softprops/action-gh-release@v2
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ github.ref }}
with: with:
tag_name: ${{ github.ref }} files: |
release_name: ${{ github.ref }} main.js
manifest.json
styles.css
token: ${{ secrets.GITHUB_TOKEN }}
draft: false draft: false
prerelease: true prerelease: false
- name: Upload main.js make_latest: true
id: upload-main
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./main.js
asset_name: main.js
asset_content_type: text/javascript
- name: Upload manifest.json
id: upload-manifest
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./manifest.json
asset_name: manifest.json
asset_content_type: application/json
- name: Upload styles.css
id: upload-styles
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./styles.css
asset_name: styles.css
asset_content_type: text/css
+13 -5
View File
@@ -1,14 +1,14 @@
# Intellij # Intellij
*.iml *.iml
.idea .idea/
# npm # npm
node_modules node_modules/
package-lock.json
pnpm-lock.yaml pnpm-lock.yaml
# build # build
main.js main.js
*.main.js
*.js.map *.js.map
# obsidian # obsidian
@@ -17,5 +17,13 @@ data.json
# debug # debug
logs.txt logs.txt
# hidden files # coverage
.* coverage/
# env / secrets
.env
.env.local
# OS
.DS_Store
Thumbs.db
+84
View File
@@ -0,0 +1,84 @@
# CLAUDE.md
Guia para Claude Code (claude.ai/code) trabalhar neste fork.
## Sobre o projeto
Fork de [`remotely-save/remotely-save`](https://github.com/remotely-save/remotely-save), plugin Obsidian que sincroniza vaults com WebDAV/S3/Dropbox/OneDrive/etc. Upstream com main branch parada desde 2024-11. Este fork retoma manutenção.
Detalhes técnicos completos em:
- `docs/ARCHITECTURE.md` — abstração `FakeFs`, fluxo de sync, state em IndexedDB
- `docs/DEVELOPMENT.md` — setup mise, comandos npm
- `docs/CONTRIBUTING.md` — workflow, convenções, política de cobertura
## Stack
- TypeScript 5.5 + Node 24.15.0 (pin via `mise.toml`)
- npm 11 (pin via `packageManager`)
- Webpack (build principal) + esbuild (alternativo)
- Biome (lint + format)
- Mocha + chai-as-promised + tsx (testes)
- c8 (cobertura)
## Convenções específicas deste fork
### Commits
**Inglês**, não pt-BR (alinhar com upstream). Imperativo, primeira letra maiúscula, ~72 caracteres na primeira linha. Corpo opcional com bullets `-`. Citar PR upstream quando aplicável (`based on upstream PR #1094`).
### Dependências
- **Só versões com ≥ 30 dias** publicadas no npm registry — checar `time` antes de subir
- Pinar **exato** (sem `^`/`~`) quando você mexer numa versão
- Usar `overrides` para forçar transitivas seguras quando o upstream não atualizou
### Cobertura
Floor atual: **8%** (ratchet — só sobe). Configurado em `package.json:scripts.test:coverage` com `c8 --check-coverage --lines=N`. Ao subir cobertura, atualizar `N` no script.
### Lint
Pode usar `// biome-ignore <rule>: <razão>` para suprimir regra com justificativa explícita. Sem suprimir silenciosamente.
### Estilo de código
- **SRP**: uma coisa por função, uma responsabilidade por módulo.
- **Nomes**: específicos e únicos. Evitar `data`, `handler`, `Manager`. Preferir nomes que retornem menos de 5 hits num grep do codebase.
- **Tipos**: explícitos. Sem `any`, sem `Dict`, sem funções sem tipo.
- **Funções**: ≤ 50 linhas. Dividir se passar.
- **Controle de fluxo**: early returns, no máximo 2 níveis de aninhamento.
- **Exceções**: incluir o valor ofensor e o formato esperado na mensagem.
## CI (`.github/workflows/ci.yml`)
Padrão `Lint → Test → Security`, cada um com responsabilidade única:
| Stage | Comando | Timeout |
|---|---|---|
| Lint | `npx @biomejs/biome ci .` | 3min |
| Test | `npm run test:coverage` | 10min |
| Security | `npm audit --audit-level=high` + `npx @biomejs/biome lint .` | 5min |
Build de produção fica em `release.yml` (não no CI de PR). Local: `npm run build`.
## Cherry-pick de PRs do upstream
```bash
git fetch upstream pull/<N>/head:pr-<N>
git cherry-pick <commits>
```
Validar local (`npm run format && npm test && npm run build`) antes de commit. Mencionar o PR no commit message.
## Cuidados ao mexer
- `pro/src/account.ts:200-201` tem código intencional para liberar pro features sem servidor. Não remover.
- `src/main.ts` (~2k LOC) e `src/settings.ts` (~3k LOC) são funções gigantes — refactors em passos pequenos.
- `pro/src/sync.ts:doActualSync` é o coração. **Sem testes** atualmente. Qualquer mudança aqui precisa teste novo.
- Backends `fs*.ts` seguem `FakeFs`. Não criar novo sem implementar a interface completa.
## Filosofia
- AI como pair programmer: narrar decisões técnicas (especialmente em tsconfig/build/CI). Perguntar em vez de assumir. Explicar trade-off.
- Mudança incremental: commits pequenos e bisect-friendly.
- Cada feature ou bugfix inclui teste.
+14 -1
View File
@@ -122,6 +122,7 @@ Additionally, the plugin author may occasionally visit Obsidian official forum a
- [Open Media Vault](./docs/remote_services/webdav_openmediavault/README.md) - [Open Media Vault](./docs/remote_services/webdav_openmediavault/README.md)
- [Nginx (`ngx_http_dav_module`, `nginx-dav-ext-module`, with Docker)](./docs/remote_services/webdav_nginx/README.md) - [Nginx (`ngx_http_dav_module`, `nginx-dav-ext-module`, with Docker)](./docs/remote_services/webdav_nginx/README.md)
- [Apache (with Docker)](./docs/remote_services/webdav_apache/README.md) - [Apache (with Docker)](./docs/remote_services/webdav_apache/README.md)
- [Caddy with `http.handlers.webdav` module](./docs/remote_services/webdav_caddy/README.md)
- Very old version of Obsidian needs [configuring CORS](./docs/remote_services/webdav_general/webav_cors.md). - Very old version of Obsidian needs [configuring CORS](./docs/remote_services/webdav_general/webav_cors.md).
- Your data would be synced to a `${vaultName}` sub folder on your webdav server. - Your data would be synced to a `${vaultName}` sub folder on your webdav server.
- Password-based end-to-end encryption is also supported. But please be aware that **the vault name itself is not encrypted**. - Password-based end-to-end encryption is also supported. But please be aware that **the vault name itself is not encrypted**.
@@ -201,8 +202,20 @@ See [PRO](./docs/pro/README.md) for more details.
## How To Debug ## How To Debug
See [here](./docs/how_to_debug/README.md) for more details. If you see any errors, please check the doc [here](./docs/how_to_debug/README.md) for more details.
Moreover, sometimes the program runs but slowly, you want to check the performance by [enabling the profiler](./docs/check_performance/README.md).
## Bonus: Import And Export Not-Oauth2 Plugin Settings By QR Code ## Bonus: Import And Export Not-Oauth2 Plugin Settings By QR Code
See [here](./docs/import_export_some_settings.md) for more details. See [here](./docs/import_export_some_settings.md) for more details.
## Download History
Download history can be viewed on the unofficial [Obsidian Stats](https://www.moritzjung.dev/obsidian-stats/plugins/remotely-save/#downloads) (NOT affiliated with official Obsidian and GitHub and Remotely Save).
## Star History
(NOT affiliated with official Obsidian and GitHub and Remotely Save.)
[![Star History Chart](https://api.star-history.com/svg?repos=remotely-save/remotely-save&type=Date)](https://star-history.com/#remotely-save/remotely-save&Date)
+14 -2
View File
@@ -122,6 +122,7 @@
- [Open Media Vault](./docs/remote_services/webdav_openmediavault/README.md) - [Open Media Vault](./docs/remote_services/webdav_openmediavault/README.md)
- [Nginx (`ngx_http_dav_module`, `nginx-dav-ext-module`, with Docker)](./docs/remote_services/webdav_nginx/README.md) - [Nginx (`ngx_http_dav_module`, `nginx-dav-ext-module`, with Docker)](./docs/remote_services/webdav_nginx/README.md)
- [Apache (with Docker)](./docs/remote_services/webdav_apache/README.md) - [Apache (with Docker)](./docs/remote_services/webdav_apache/README.md)
- [Caddy with `http.handlers.webdav` module](./docs/remote_services/webdav_caddy/README.md)
- 非常旧版本的Obsidian需要[配置 CORS](./docs/remote_services/webdav_general/webav_cors.md)。 - 非常旧版本的Obsidian需要[配置 CORS](./docs/remote_services/webdav_general/webav_cors.md)。
- 你的数据会同步到你的webdav服务器上的 `${vaultName}` 子文件夹。 - 你的数据会同步到你的webdav服务器上的 `${vaultName}` 子文件夹。
- 基于密码的端到端加密也是可以的。但请注意,**vault 名称本身未加密**。 - 基于密码的端到端加密也是可以的。但请注意,**vault 名称本身未加密**。
@@ -199,11 +200,22 @@ PRO(付费)功能“智能冲突”为用户提供了另一个选项:合
详见[PRO](./docs/pro/README.md)了解更多详情。 详见[PRO](./docs/pro/README.md)了解更多详情。
## 如何调试 ## 如何调试debug
详见[这里](./docs/how_to_debug/README.md)了解更多详情 如发生错误,查看[这里文档](./docs/how_to_debug/README.md)了解调试方式
如果没有发生错误,但是运行起来很慢,你需要[开启“性能收集”](./docs/check_performance/README)来看看有没有哪一步特别慢。
## 额外功能:通过 QR 码导入和导出非 OAuth2 插件设置 ## 额外功能:通过 QR 码导入和导出非 OAuth2 插件设置
详见[这里](./docs/import_export_some_settings.md)了解更多详情。 详见[这里](./docs/import_export_some_settings.md)了解更多详情。
## 下载历史
下载历史可以从非官方的 [Obsidian Stats](https://www.moritzjung.dev/obsidian-stats/plugins/remotely-save/#downloads) 查阅。(和官方 ObsidianGitHubRemotely Save 均无利益关系。)
## 星星历史
(和官方 ObsidianGitHubRemotely Save 均无利益关系。)
[![星星历史 曲线图](https://api.star-history.com/svg?repos=remotely-save/remotely-save&type=Date)](https://star-history.com/#remotely-save/remotely-save&Date)
+1 -1
View File
@@ -4,7 +4,7 @@
"enabled": true "enabled": true
}, },
"files": { "files": {
"ignore": ["main.js"] "ignore": ["main.js", "*.main.js", "coverage/**", "node_modules/**"]
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,
+71
View File
@@ -0,0 +1,71 @@
# Arquitetura
Plugin Obsidian (TypeScript) que sincroniza vaults com serviços de armazenamento remoto (WebDAV, S3, Dropbox, OneDrive, etc).
## Estrutura
```
src/ código público (MIT)
├── main.ts entry point, estende Plugin do Obsidian (~2k linhas)
├── settings.ts UI de settings (~3k linhas)
├── fsAll.ts abstração FakeFs — interface comum dos backends
├── fsGetter.ts roteamento backend → instância (switch por tipo)
├── fs<Backend>.ts implementações: S3, WebDAV, Dropbox, OneDrive, Webdis, Local, Encrypt
├── localdb.ts IndexedDB via LocalForage — state local
├── configPersist.ts serialização da config no disco do plugin
├── i18n.ts Mustache + moment, idiomas em src/langs/
└── ...
pro/ código tier pago (PolyForm Strict License)
├── src/
│ ├── sync.ts motor de sincronização (~2k linhas, doActualSync + dispatchDecision)
│ ├── fs<Backend>.ts GoogleDrive, Box, pCloud, YandexDisk, Koofr, Azure, OnedriveFull
│ └── ...
└── tests/
tests/ testes Mocha (estrutura básica, sem cobertura do core)
docs/ documentação
.github/workflows/ CI
```
## Abstração FakeFs
Todo backend implementa a classe abstrata `FakeFs` (`src/fsAll.ts`):
```typescript
abstract class FakeFs {
walk() / walkPartial() / stat() / mkdir() / writeFile()
readFile() / rename() / rm()
checkConnect() / getUserDisplayName() / revokeAuth() / allowEmptyFile()
}
```
`fsGetter.ts` faz o roteamento via switch — ao adicionar backend novo, registrar nele.
## Fluxo de sincronização
1. `main.ts` dispara sync (manual, auto-sync, evento de save)
2. `pro/src/sync.ts:doActualSync()` carrega árvore local + remota
3. Diff 3-way usando snapshot anterior (`prevSyncRecordsTbl` em IndexedDB)
4. `dispatchDecision()` aplica decisão por entry (copy, merge, delete, skip)
5. Conflitos: 3-way merge via `node-diff3` (apenas Markdown < 1MB, tier pro)
6. Erros agregados em `AggregateError`; falha até 3x antes de abortar
## State
LocalForage (IndexedDB) com 8 tabelas em `src/localdb.ts`:
- `prevSyncRecordsTbl` — snapshot da última sync
- `fileContentHistoryTbl` — histórico de conteúdo (smart conflict)
- `syncPlansTbl` — log das operações planejadas
- `versionTbl`, `loggerOutputTbl`, `profilerResultsTbl` — metadados
Config persiste via API do Obsidian em `<vault>/.obsidian/plugins/remotely-save/data.json`,
com obfuscação base64 reverse (decorativa, não criptográfica).
## Pontos de atenção
- **mtime no WebDAV**: padrão WebDAV não define mtime — Nextcloud expõe via header custom (`OC-LastModified`). Inconsistência pode causar falsa detecção de modificação.
- **OAuth2 duplicado**: cada backend implementa fluxo próprio (~100-300 linhas duplicadas). Sem extração comum.
- **Sem retry HTTP**: 429/503 quebram sync. PR upstream #1034 propõe retry para WebDAV.
- **141 `: any` residuais**: concentrados em callbacks de error e payloads OAuth2.
+67
View File
@@ -0,0 +1,67 @@
# Contribuindo
## Workflow
1. Branch a partir de `master`: `git checkout -b <tipo>/<descrição>`
- `feat/` — feature nova
- `fix/` — bugfix
- `refactor/` — refactor sem mudança de comportamento
- `docs/` — só documentação
- `chore/` — config, build, CI
2. Commits pequenos, foco único
3. Antes de abrir PR: `npm run format && npm test && npm run build`
4. Abrir PR descrevendo o porquê (não o quê — o diff mostra o quê)
## Mensagens de commit
- Inglês, imperativo (alinhado com o upstream)
- Primeira linha curta (~72 caracteres), inicia com letra maiúscula
- Corpo opcional em lista com `- ` detalhando o porquê
Exemplo:
```
Add WebDAV retry on 429/503
- new retryWithBackoff helper in src/fsWebdav.ts
- apply to walk/stat/writeFile methods
- based on upstream PR #1034
```
## Cherry-pick de PRs do upstream
Upstream: https://github.com/remotely-save/remotely-save
```bash
git remote add upstream https://github.com/remotely-save/remotely-save.git
git fetch upstream pull/<N>/head:pr-<N>
git cherry-pick <commits-de-pr-N>
```
Citar o PR upstream na mensagem de commit (`baseado no PR upstream #<N>`).
## Estilo de código
- Biome formata e linta (`npm run format`)
- TypeScript strict — evitar `any`
- Funções idealmente 420 linhas; refatorar se passar
- Nomes específicos, não `data`/`handler`/`Manager`
- Sem comentários óbvios — explicar só o porquê quando não-trivial
- Early returns (no máximo 2 níveis de aninhamento)
## Testes
- Toda feature ou bugfix inclui teste
- Localização: `tests/` para src/, `pro/tests/` para pro/
- Comando: `npm run test:coverage`
### Política de cobertura (ratchet)
Cobertura mínima de linhas é um **piso que só sobe**. Atualmente em `8%`
(via `c8 --check-coverage --lines=8` no script `test:coverage`).
Quando você adicionar testes que elevam a cobertura geral, **suba o piso**
no script `test:coverage` para o novo valor (truncado). Nunca abaixar.
Meta de longo prazo: 70% (padrão code-standards). Áreas zero-cobertura
prioritárias: `src/main.ts`, `src/settings.ts`, backends `fs*.ts`.
+64
View File
@@ -0,0 +1,64 @@
# Desenvolvimento
## Setup
Pré-requisito: [mise](https://mise.jdx.dev/) instalado.
```bash
mise install # instala Node conforme mise.toml
npm ci # instala dependências do lockfile
```
## Comandos
```bash
npm run dev # webpack watch (dev)
npm run build # build de produção (webpack)
npm run dev2 # esbuild watch (alternativo)
npm run build2 # build de produção (esbuild + tsc check)
npm test # mocha (tests/ + pro/tests/)
npm run format # biome check --write
npm run clean # remove main.js
```
## Instalar build local no Obsidian
Após `npm run build`, copiar para o vault:
```bash
cp main.js manifest.json styles.css \
/path/to/vault/.obsidian/plugins/remotely-save/
```
Reload do Obsidian (Ctrl+R) para carregar a nova versão.
## Testes
Framework: Mocha + chai-as-promised + tsx (sem transpile separado).
Estrutura:
```
tests/ testes do código público (src/)
pro/tests/ testes do código pro/
```
Comando: `npm test`.
## Lint/format
Biome (`biome.json` na raiz):
```bash
npm run format # corrige automaticamente
npx @biomejs/biome check # só verifica
```
## Release
Disparado via tag git. Workflow `.github/workflows/release.yml` constrói e publica artefatos:
```bash
git tag x.y.z
git push origin x.y.z
```
+13
View File
@@ -0,0 +1,13 @@
# Check performance
1. Go to settings, scroll to the very end, and enable the "Enable Profiler" option.
2. Also enable "Enable Profiler Printing".
3. Check Console Output (directly or via `vConsole` plugin). More details are [here](../how_to_debug/README.md).
4. Sync!
5. You can also "Export Profiler Results" afterwards. A new file `_debug_remotely_save/profiler_results_exported_on_xxxx.md` will be generated.
In the console or exported files, you can see the time cost of each steps.
![profiler settings](./profiler_settings.png)
![profiler exported](./profiler_exported.png)
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5a71d0746a75c93aa4cb5be3f7964bfdf74722a10505f5a1965790738eaebdc4
size 265289
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4ffd1557c2f714c56bfacd8166b85749d6bfa15c2f9c7363942b443d51288083
size 81259
@@ -0,0 +1,33 @@
# Caddy with `http.handlers.webdav` module
> modified from the instruction from @cyruz-git in https://github.com/remotely-save/remotely-save/issues/825
## Link
<https://caddyserver.com/download?package=github.com%2Fmholt%2Fcaddy-webdav>
## Steps
1. Download caddy with webdav module from <https://caddyserver.com/download?package=github.com%2Fmholt%2Fcaddy-webdav>. Or you can install Caddy then install the plugins.
2. Create a folder for storing webdav server files. Like `/usr/local/mywebdav`.
3. Create a `Caddyfile` (yeah the file name itself is `Caddyfile`.) like this:
```caddy
:8080 {
route /dav/* {
root /usr/local/mywebdav
basicauth {
# Username "Bob", password "hiccup"
Bob $2a$14$Zkx19XLiW6VYouLHR5NmfOFU0z2GTNmpkT/5qqR7hx4IjWJPDhjvG
}
uri strip_prefix /dav
webdav
}
}
```
The password hash is generated like [this](https://caddyserver.com/docs/caddyfile/directives/basic_auth).
4. In Remotely Save, setup:
* address `http://localhost:8080/dav/`
* username `Bob`
* password `hiccup`
* auth type: `basic`
5. Check the connection and sync!
+1 -1
View File
@@ -57,7 +57,7 @@ esbuild
inject: ["./esbuild.injecthelper.mjs"], inject: ["./esbuild.injecthelper.mjs"],
format: "cjs", format: "cjs",
// watch: !prod, // no longer valid in esbuild 0.17 // watch: !prod, // no longer valid in esbuild 0.17
target: "es2016", target: "es2020",
logLevel: "info", logLevel: "info",
sourcemap: prod ? false : "inline", sourcemap: prod ? false : "inline",
treeShaking: true, treeShaking: true,
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "remotely-save", "id": "remotely-save",
"name": "Remotely Save", "name": "Remotely Save",
"version": "0.5.19", "version": "0.5.25",
"minAppVersion": "0.13.21", "minAppVersion": "0.13.21",
"description": "Yet another unofficial plugin allowing users to synchronize notes between local device and the cloud service.", "description": "Yet another unofficial plugin allowing users to synchronize notes between local device and the cloud service.",
"author": "fyears", "author": "fyears",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "remotely-save", "id": "remotely-save",
"name": "Remotely Save", "name": "Remotely Save",
"version": "0.5.19", "version": "0.5.25",
"minAppVersion": "0.13.21", "minAppVersion": "0.13.21",
"description": "Yet another unofficial plugin allowing users to synchronize notes between local device and the cloud service.", "description": "Yet another unofficial plugin allowing users to synchronize notes between local device and the cloud service.",
"author": "fyears", "author": "fyears",
+2
View File
@@ -0,0 +1,2 @@
[tools]
node = "24.15.0"
+11423
View File
File diff suppressed because it is too large Load Diff
+17 -8
View File
@@ -1,7 +1,11 @@
{ {
"name": "remotely-save", "name": "remotely-save",
"version": "0.5.19", "version": "0.5.25",
"description": "This is yet another sync plugin for Obsidian app.", "description": "This is yet another sync plugin for Obsidian app.",
"packageManager": "npm@11.12.1",
"engines": {
"node": ">=24.15.0"
},
"scripts": { "scripts": {
"dev2": "node esbuild.config.mjs --watch", "dev2": "node esbuild.config.mjs --watch",
"build2": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", "build2": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
@@ -9,7 +13,8 @@
"dev": "webpack --mode development --watch", "dev": "webpack --mode development --watch",
"format": "npx @biomejs/biome check --write .", "format": "npx @biomejs/biome check --write .",
"clean": "npx rimraf main.js", "clean": "npx rimraf main.js",
"test": "mocha --import=tsx 'tests/**/*.ts' 'pro/tests/**/*.ts'" "test": "mocha --import=tsx 'tests/**/*.ts' 'pro/tests/**/*.ts'",
"test:coverage": "c8 --check-coverage --lines=8 --reporter=text --reporter=lcov --reporter=html npm test"
}, },
"browser": { "browser": {
"path": "path-browserify", "path": "path-browserify",
@@ -21,11 +26,17 @@
"vm": false "vm": false
}, },
"source": "main.ts", "source": "main.ts",
"overrides": {
"elliptic": "6.6.1",
"diff": "9.0.0",
"serialize-javascript": "7.0.5"
},
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.8.3", "@biomejs/biome": "1.8.3",
"c8": "11.0.0",
"@microsoft/microsoft-graph-types": "^2.40.0", "@microsoft/microsoft-graph-types": "^2.40.0",
"@types/chai": "^4.3.16", "@types/chai": "^4.3.16",
"@types/chai-as-promised": "^7.1.8", "@types/chai-as-promised": "^7.1.8",
@@ -34,16 +45,15 @@
"@types/mime-types": "^2.1.4", "@types/mime-types": "^2.1.4",
"@types/mocha": "^10.0.7", "@types/mocha": "^10.0.7",
"@types/mustache": "^4.2.5", "@types/mustache": "^4.2.5",
"@types/node": "^20.14.12", "@types/node": "24.12.2",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"builtin-modules": "^4.0.0", "builtin-modules": "^4.0.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"esbuild": "^0.23.0", "esbuild": "0.28.0",
"esbuild-plugin-inline-worker": "^0.1.1", "esbuild-plugin-inline-worker": "^0.1.1",
"jsdom": "^24.1.1", "jsdom": "^24.1.1",
"mocha": "^10.7.0", "mocha": "11.7.5",
"npm-check-updates": "^16.14.20",
"obsidian": "^1.5.7", "obsidian": "^1.5.7",
"openapi-typescript": "^7.1.0", "openapi-typescript": "^7.1.0",
"ts-loader": "^9.5.1", "ts-loader": "^9.5.1",
@@ -70,12 +80,11 @@
"@smithy/protocol-http": "^4.1.0", "@smithy/protocol-http": "^4.1.0",
"@smithy/querystring-builder": "^3.0.3", "@smithy/querystring-builder": "^3.0.3",
"acorn": "^8.12.1", "acorn": "^8.12.1",
"aggregate-error": "^5.0.0",
"assert": "^2.1.0", "assert": "^2.1.0",
"aws-crt": "^1.21.3", "aws-crt": "^1.21.3",
"box-typescript-sdk-gen": "^1.3.0", "box-typescript-sdk-gen": "^1.3.0",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"crypto-browserify": "^3.12.0", "crypto-browserify": "3.12.1",
"dropbox": "^10.34.0", "dropbox": "^10.34.0",
"emoji-regex": "^10.3.0", "emoji-regex": "^10.3.0",
"http-status-codes": "^2.3.0", "http-status-codes": "^2.3.0",
+26
View File
@@ -173,6 +173,32 @@ export const getAndSaveProFeatures = async (
pluginVersion: string, pluginVersion: string,
saveUpdatedConfigFunc: () => Promise<any> | undefined saveUpdatedConfigFunc: () => Promise<any> | undefined
) => { ) => {
const features = [
"feature-smart_conflict",
"feature-onedrive_full",
"feature-google_drive",
"feature-box",
"feature-pcloud",
"feature-yandex_disk",
"feature-koofr",
"feature-azure_blob_storage",
];
const res = {
proFeatures: features.map(
(i) =>
({
featureName: i,
enableAtTimeMs: 1e12,
expireAtTimeMs: 3e12,
}) as FeatureInfo
),
};
config.enabledProFeatures = res.proFeatures;
await saveUpdatedConfigFunc?.();
return res;
// biome-ignore lint/correctness/noUnreachable: original upstream flow kept for reference (the fake-license shortcut above is intentional)
const access = await getAccessToken(config, saveUpdatedConfigFunc); const access = await getAccessToken(config, saveUpdatedConfigFunc);
const resp1 = await fetch(`${site}/api/v1/pro/list`, { const resp1 = await fetch(`${site}/api/v1/pro/list`, {
+2 -2
View File
@@ -37,8 +37,8 @@ export type PRO_FEATURE_TYPE =
export interface FeatureInfo { export interface FeatureInfo {
featureName: PRO_FEATURE_TYPE; featureName: PRO_FEATURE_TYPE;
enableAtTimeMs: bigint; enableAtTimeMs: number;
expireAtTimeMs: bigint; expireAtTimeMs: number;
} }
export interface ProConfig { export interface ProConfig {
+1 -1
View File
@@ -78,7 +78,7 @@ const fromBlobPropsToEntity = (
let hash: undefined | string = undefined; let hash: undefined | string = undefined;
if (props.contentMD5 !== undefined) { if (props.contentMD5 !== undefined) {
hash = arrayBufferToHex(props.contentMD5.buffer); hash = arrayBufferToHex(props.contentMD5.buffer as ArrayBuffer);
} }
const entity: Entity = { const entity: Entity = {
+155 -79
View File
@@ -2,7 +2,6 @@
// https://developers.google.com/identity/protocols/oauth2/javascript-implicit-flow // https://developers.google.com/identity/protocols/oauth2/javascript-implicit-flow
// https://developers.google.com/identity/protocols/oauth2/web-server // https://developers.google.com/identity/protocols/oauth2/web-server
import { entries } from "lodash";
import * as mime from "mime-types"; import * as mime from "mime-types";
import { requestUrl } from "obsidian"; import { requestUrl } from "obsidian";
import PQueue from "p-queue"; import PQueue from "p-queue";
@@ -177,6 +176,7 @@ export class FakeFsGoogleDrive extends FakeFs {
keyToGDEntity: Record<string, GDEntity>; keyToGDEntity: Record<string, GDEntity>;
baseDirID: string; baseDirID: string;
ready = false;
constructor( constructor(
googleDriveConfig: GoogleDriveConfig, googleDriveConfig: GoogleDriveConfig,
@@ -199,13 +199,17 @@ export class FakeFsGoogleDrive extends FakeFs {
await this._getAccessToken(); await this._getAccessToken();
// check vault folder exists // check vault folder exists
if (this.vaultFolderExists) { if (!this.vaultFolderExists) {
// pass const q = `name='${this.remoteBaseDir}' and mimeType='application/vnd.google-apps.folder' and trashed=false`;
} else { const url = new URL("https://www.googleapis.com/drive/v3/files");
const q = encodeURIComponent( url.searchParams.set("q", q);
`name='${this.remoteBaseDir}' and mimeType='application/vnd.google-apps.folder' and trashed=false` url.searchParams.set("pageSize", "1000");
url.searchParams.set(
"fields",
"kind,nextPageToken," +
"files(kind,fileExtension,md5Checksum,mimeType,parents,size,spaces,id,name,trashed,createdTime,modifiedTime,quotaBytesUsed,originalFilename,fullFileExtension,sha1Checksum,sha256Checksum)"
); );
const url: string = `https://www.googleapis.com/drive/v3/files?q=${q}&pageSize=1000&fields=kind,nextPageToken,files(kind,fileExtension,md5Checksum,mimeType,parents,size,spaces,id,name,trashed,createdTime,modifiedTime,quotaBytesUsed,originalFilename,fullFileExtension,sha1Checksum,sha256Checksum)`; url.searchParams.set("orderBy", "modifiedTime desc");
const k = await fetch(url, { const k = await fetch(url, {
method: "GET", method: "GET",
headers: { headers: {
@@ -274,8 +278,16 @@ export class FakeFsGoogleDrive extends FakeFs {
/** /**
* https://developers.google.com/drive/api/reference/rest/v3/files/list * https://developers.google.com/drive/api/reference/rest/v3/files/list
*/ */
async walk(): Promise<Entity[]> { async walk(): Promise<GDEntity[]> {
await this._init(); await this._init();
// const allFiles = await this._listAllFiles();
// this.keyToGDEntity = allFiles.reduce((p, c) => {
// p[c.keyRaw] = c;
// return p;
// }, {} as Record<string, GDEntity>); // rebuild cache
// return allFiles;
const allFiles: GDEntity[] = []; const allFiles: GDEntity[] = [];
// bfs // bfs
@@ -289,39 +301,23 @@ export class FakeFsGoogleDrive extends FakeFs {
throw error; throw error;
}); });
let parents = [ const newWalkTask = (id: string, folderPath: string) => {
{ return async () => {
id: this.baseDirID, // special init, from already created root folder ID const filesUnderFolder = await this._listFolder(id, folderPath);
folderPath: "", for (const f of filesUnderFolder) {
}, allFiles.push(f);
]; if (f.isFolder) {
while (parents.length !== 0) { // keyRaw itself already has a tailing slash, no more slash here
const children: typeof parents = []; // keyRaw itself also already has full path
for (const { id, folderPath } of parents) { queue.add(newWalkTask(f.id, f.keyRaw));
queue.add(async () => {
const filesUnderFolder = await this._walkFolder(id, folderPath);
for (const f of filesUnderFolder) {
allFiles.push(f);
if (f.isFolder) {
// keyRaw itself already has a tailing slash, no more slash here
// keyRaw itself also already has full path
const child = {
id: f.id,
folderPath: f.keyRaw,
};
// console.debug(
// `looping result of _walkFolder(${id},${folderPath}), adding child=${JSON.stringify(
// child
// )}`
// );
children.push(child);
}
} }
}); }
} };
await queue.onIdle(); };
parents = children;
} queue.add(newWalkTask(this.baseDirID, "")); // special init, from already created root folder ID
await queue.onIdle();
// console.debug(`in the end of walk:`); // console.debug(`in the end of walk:`);
// console.debug(allFiles); // console.debug(allFiles);
@@ -329,25 +325,97 @@ export class FakeFsGoogleDrive extends FakeFs {
return allFiles; return allFiles;
} }
async _walkFolder(parentID: string, parentFolderPath: string) { async _listAllFiles(): Promise<GDEntity[]> {
const allFileRes: File[] = [];
let nextPageToken = "";
do {
const q = `'${this.baseDirID}' in parents and trashed=false`;
const url = new URL("https://www.googleapis.com/drive/v3/files");
url.searchParams.set("q", q);
url.searchParams.set("pageSize", "1000");
url.searchParams.set(
"fields",
"kind,nextPageToken,files(kind,fileExtension,md5Checksum,mimeType,parents,size,spaces,id,name,trashed,createdTime,modifiedTime,quotaBytesUsed,originalFilename,fullFileExtension,sha1Checksum,sha256Checksum)"
);
url.searchParams.set("orderBy", "modifiedTime");
url.searchParams.set("pageToken", nextPageToken);
const res = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${await this._getAccessToken()}`,
},
});
if (res.status !== 200) {
throw Error(`Error on list all files`);
}
const fileRes = await res.json();
(fileRes.files as File[]).forEach((i) => allFileRes.push(i));
nextPageToken = fileRes.nextPageToken;
} while (nextPageToken !== undefined);
const allFolderRes = allFileRes.filter(
(i) => i.mimeType === FOLDER_MIME_TYPE
);
const allFolders = [
{
id: this.baseDirID, // special init, from already created root folder ID
folderPath: "",
},
];
for (const targetFolder of allFolders) {
allFolderRes
.filter((i) => i.parents?.includes(targetFolder.id))
.forEach((i) => {
allFolders.push({
id: i.id!,
folderPath: `${targetFolder}${i.name!}/`,
});
});
}
const allFiles: GDEntity[] = [];
for (const file of allFileRes) {
if (!file.parents) continue;
file.parents.forEach((parent) => {
const folder = allFolders.find((folder) => folder.id === parent);
if (!folder) return;
const entity = fromFileToGDEntity(file, folder.id, folder.folderPath);
allFiles.push(entity);
});
}
return allFiles;
}
async _listFolder(parentID: string, parentFolderPath: string) {
// console.debug( // console.debug(
// `input of single level: parentID=${parentID}, parentFolderPath=${parentFolderPath}` // `input of single level: parentID=${parentID}, parentFolderPath=${parentFolderPath}`
// ); // );
const filesOneLevel: GDEntity[] = []; const filesOneLevel: GDEntity[] = [];
let nextPageToken: string | undefined = undefined; let nextPageToken = "";
if (parentID === undefined || parentID === "" || parentID === "root") { if (parentID === undefined || parentID === "" || parentID === "root") {
// we should never start from root // we should never start from root
// because we encapsulate the vault inside a folder // because we encapsulate the vault inside a folder
throw Error(`something goes wrong walking folder`); throw Error(`something goes wrong walking folder`);
} }
do { do {
const q = encodeURIComponent( const q = `'${parentID}' in parents and trashed=false`;
`'${parentID}' in parents and trashed=false` const url = new URL("https://www.googleapis.com/drive/v3/files");
url.searchParams.set("q", q);
url.searchParams.set("pageSize", "1000");
url.searchParams.set(
"fields",
"kind,nextPageToken,files(kind,fileExtension,md5Checksum,mimeType,parents,size,spaces,id,name,trashed,createdTime,modifiedTime,quotaBytesUsed,originalFilename,fullFileExtension,sha1Checksum,sha256Checksum)"
); );
const pageToken = url.searchParams.set("orderBy", "modifiedTime");
nextPageToken !== undefined ? `&pageToken=${nextPageToken}` : ""; url.searchParams.set("pageToken", nextPageToken);
const url: string = `https://www.googleapis.com/drive/v3/files?q=${q}&pageSize=1000&fields=kind,nextPageToken,files(kind,fileExtension,md5Checksum,mimeType,parents,size,spaces,id,name,trashed,createdTime,modifiedTime,quotaBytesUsed,originalFilename,fullFileExtension,sha1Checksum,sha256Checksum)${pageToken}`;
const k = await fetch(url, { const k = await fetch(url, {
method: "GET", method: "GET",
@@ -377,7 +445,7 @@ export class FakeFsGoogleDrive extends FakeFs {
async walkPartial(): Promise<Entity[]> { async walkPartial(): Promise<Entity[]> {
await this._init(); await this._init();
const filesInLevel = await this._walkFolder(this.baseDirID, ""); const filesInLevel = await this._listFolder(this.baseDirID, "");
return filesInLevel; return filesInLevel;
} }
@@ -508,6 +576,8 @@ export class FakeFsGoogleDrive extends FakeFs {
// "xxx" => [] // "xxx" => []
// "xxx/yyy/zzz.md" => ["xxx", "xxx/yyy"] // "xxx/yyy/zzz.md" => ["xxx", "xxx/yyy"]
const folderLevels = getFolderLevels(key); const folderLevels = getFolderLevels(key);
console.log(key);
console.log(folderLevels);
if (folderLevels.length === 0) { if (folderLevels.length === 0) {
// root // root
parentID = this.baseDirID; parentID = this.baseDirID;
@@ -522,34 +592,42 @@ export class FakeFsGoogleDrive extends FakeFs {
parentID = this.keyToGDEntity[parentFolderPath].id; parentID = this.keyToGDEntity[parentFolderPath].id;
} }
const targetFileId = this.keyToGDEntity[key]?.id;
const fileItself = key.split("/").pop()!; const fileItself = key.split("/").pop()!;
const meta: any = {
name: fileItself,
modifiedTime: unixTimeToStr(mtime, true),
createdTime: unixTimeToStr(ctime, true),
};
if (!targetFileId) meta.parents = [parentID];
if (content.byteLength <= 5 * 1024 * 1024) { if (content.byteLength <= 5 * 1024 * 1024) {
const formData = new FormData(); const formData = new FormData();
const meta: any = {
name: fileItself,
modifiedTime: unixTimeToStr(mtime, true),
createdTime: unixTimeToStr(ctime, true),
parents: [parentID],
};
formData.append( formData.append(
"metadata", "metadata",
new Blob([JSON.stringify(meta)], { new Blob(targetFileId ? [] : [JSON.stringify(meta)], {
type: "application/json; charset=UTF-8", type: "application/json; charset=UTF-8",
}) })
); );
formData.append("media", new Blob([content], { type: contentType })); formData.append("media", new Blob([content], { type: contentType }));
const res = await fetch( const url = new URL("https://www.googleapis.com/upload/drive/v3/files");
"https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=kind,fileExtension,md5Checksum,mimeType,parents,size,spaces,id,name,trashed,createdTime,modifiedTime,quotaBytesUsed,originalFilename,fullFileExtension,sha1Checksum,sha256Checksum", if (targetFileId) url.pathname += `/${targetFileId}`;
{ url.searchParams.set("uploadType", "multipart");
method: "POST", url.searchParams.set(
headers: { "fields",
Authorization: `Bearer ${await this._getAccessToken()}`, "kind,fileExtension,md5Checksum,mimeType,parents,size,spaces,id,name,trashed,createdTime,modifiedTime,quotaBytesUsed,originalFilename,fullFileExtension,sha1Checksum,sha256Checksum"
},
body: formData,
}
); );
const res = await fetch(url, {
method: targetFileId ? "PATCH" : "POST",
headers: {
Authorization: `Bearer ${await this._getAccessToken()}`,
},
body: formData,
});
if (res.status !== 200 && res.status !== 201) { if (res.status !== 200 && res.status !== 201) {
throw Error(`create file ${key} failed! meta=${JSON.stringify(meta)}`); throw Error(`create file ${key} failed! meta=${JSON.stringify(meta)}`);
} }
@@ -564,13 +642,7 @@ export class FakeFsGoogleDrive extends FakeFs {
this.keyToGDEntity[key] = entity; this.keyToGDEntity[key] = entity;
return entity; return entity;
} else { } else {
const meta: any = { const bodyStr = targetFileId ? "" : JSON.stringify(meta);
name: fileItself,
modifiedTime: unixTimeToStr(mtime, true),
createdTime: unixTimeToStr(ctime, true),
parents: [parentID],
};
const bodyStr = JSON.stringify(meta);
const headers: HeadersInit = { const headers: HeadersInit = {
Authorization: `Bearer ${await this._getAccessToken()}`, Authorization: `Bearer ${await this._getAccessToken()}`,
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -578,14 +650,18 @@ export class FakeFsGoogleDrive extends FakeFs {
"X-Upload-Content-Type": contentType, "X-Upload-Content-Type": contentType,
"X-Upload-Content-Length": `${content.byteLength}`, "X-Upload-Content-Length": `${content.byteLength}`,
}; };
const res = await fetch( const url = new URL("https://www.googleapis.com/upload/drive/v3/files");
"https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable&fields=kind,fileExtension,md5Checksum,mimeType,parents,size,spaces,id,name,trashed,createdTime,modifiedTime,quotaBytesUsed,originalFilename,fullFileExtension,sha1Checksum,sha256Checksum", if (targetFileId) url.pathname += `/${targetFileId}`;
{ url.searchParams.set("uploadType", "resumable");
method: "POST", url.searchParams.set(
headers: headers, "fields",
body: bodyStr, "kind,fileExtension,md5Checksum,mimeType,parents,size,spaces,id,name,trashed,createdTime,modifiedTime,quotaBytesUsed,originalFilename,fullFileExtension,sha1Checksum,sha256Checksum"
}
); );
const res = await fetch(url, {
method: targetFileId ? "PATCH" : "POST",
headers: headers,
body: bodyStr,
});
if (res.status !== 200) { if (res.status !== 200) {
throw Error( throw Error(
`create resumable file ${key} failed! meta=${JSON.stringify( `create resumable file ${key} failed! meta=${JSON.stringify(
+1 -1
View File
@@ -575,7 +575,7 @@ export class FakeFsOnedriveFull extends FakeFs {
*/ */
async _putUint8ArrayByRange( async _putUint8ArrayByRange(
pathFragOrig: string, pathFragOrig: string,
payload: Uint8Array, payload: Uint8Array<ArrayBuffer>,
rangeStart: number, rangeStart: number,
rangeEnd: number, rangeEnd: number,
size: number size: number
+1 -1
View File
@@ -268,7 +268,7 @@
"settings_azureblobstorage_parts": "Parts Concurrency", "settings_azureblobstorage_parts": "Parts Concurrency",
"settings_azureblobstorage_parts_desc": "Large files are split into small parts to upload. How many parts do you want to upload in parallel at most?", "settings_azureblobstorage_parts_desc": "Large files are split into small parts to upload. How many parts do you want to upload in parallel at most?",
"settings_azureblobstorage_generatefolderobject": "Generate Folder Object Or Not", "settings_azureblobstorage_generatefolderobject": "Generate Folder Object Or Not",
"settings_azureblobstorage_generatefolderobject_desc": "Azure Blob Storage doesn't have \"real\" folder. If you set \"Generate\" here, the plugin will upload a zero-byte object endding with \"/\" to represent the folder. By default, the plugin skips generating folder object.", "settings_azureblobstorage_generatefolderobject_desc": "Azure Blob Storage doesn't have \"real\" folder. If you set \"Generate\" here, the plugin will upload a zero-byte object ending with \"/\" to represent the folder. By default, the plugin skips generating folder object.",
"settings_azureblobstorage_generatefolderobject_notgenerate": "Not generate (default)", "settings_azureblobstorage_generatefolderobject_notgenerate": "Not generate (default)",
"settings_azureblobstorage_generatefolderobject_generate": "Generate", "settings_azureblobstorage_generatefolderobject_generate": "Generate",
"settings_azureblobstorage_connect_succ": "Great! We can connect to Azure Blob Storage!", "settings_azureblobstorage_connect_succ": "Great! We can connect to Azure Blob Storage!",
+4
View File
@@ -95,6 +95,10 @@ class GoogleDriveAuthModal extends Modal {
k.expires_in * 1000; k.expires_in * 1000;
this.plugin.settings.googledrive.accessTokenExpiresAtTimeMs = this.plugin.settings.googledrive.accessTokenExpiresAtTimeMs =
ts + k.expires_in * 1000 - 60 * 2 * 1000; ts + k.expires_in * 1000 - 60 * 2 * 1000;
// manually set it expired after 60 days;
this.plugin.settings.googledrive.credentialsShouldBeDeletedAtTimeMs =
Date.now() + 1000 * 60 * 60 * 24 * 59;
await this.plugin.saveSettings(); await this.plugin.saveSettings();
// try to remove data in clipboard // try to remove data in clipboard
+279 -70
View File
@@ -1,5 +1,3 @@
// biome-ignore lint/suspicious/noShadowRestrictedNames: <explanation>
import AggregateError from "aggregate-error";
import PQueue from "p-queue"; import PQueue from "p-queue";
import XRegExp from "xregexp"; import XRegExp from "xregexp";
import type { import type {
@@ -29,6 +27,7 @@ import {
import { import {
atWhichLevel, atWhichLevel,
checkValidName, checkValidName,
getFolderLevels,
getParentFolder, getParentFolder,
isHiddenPath, isHiddenPath,
isSpecialFolderNameToSkip, isSpecialFolderNameToSkip,
@@ -139,7 +138,14 @@ const isBookmarksFile = (x: string, configDir: string) => {
); );
}; };
const isSkipItemByName = ( interface IsSkipResult {
enableAllowMode: boolean;
isExplictlyAllowed: boolean;
isExplictlyIgnored: boolean;
finalIsIgnored: boolean;
}
export const checkIsSkipItemOrNotByName = (
key: string, key: string,
syncConfigDir: boolean, syncConfigDir: boolean,
syncBookmarks: boolean, syncBookmarks: boolean,
@@ -147,13 +153,15 @@ const isSkipItemByName = (
configDir: string, configDir: string,
ignorePaths: string[], ignorePaths: string[],
onlyAllowPaths: string[] onlyAllowPaths: string[]
) => { ): IsSkipResult => {
if (key === undefined) { if (key === undefined) {
throw Error(`isSkipItemByName meets undefinded key!`); throw Error(`checkIsSkipItemOrNotByName meets undefinded key!`);
} }
let finalIsIgnored: boolean | undefined = undefined;
let enableAllowMode = false; let enableAllowMode = false;
let isInAllowList = false; let isExplictlyAllowed = false;
if (onlyAllowPaths !== undefined && onlyAllowPaths.length > 0) { if (onlyAllowPaths !== undefined && onlyAllowPaths.length > 0) {
for (const r of onlyAllowPaths) { for (const r of onlyAllowPaths) {
if (r.trim() === "") { if (r.trim() === "") {
@@ -163,7 +171,7 @@ const isSkipItemByName = (
enableAllowMode = true; // we really want to check the allow list enableAllowMode = true; // we really want to check the allow list
if (XRegExp(r, "A").test(key)) { if (XRegExp(r, "A").test(key)) {
isInAllowList = true; isExplictlyAllowed = true;
} }
} }
} }
@@ -173,10 +181,11 @@ const isSkipItemByName = (
// and is deferred to next checking steps // and is deferred to next checking steps
// if the key doesn't meet the allow list, // if the key doesn't meet the allow list,
// it must be skippable. // it must be skippable.
if (enableAllowMode && !isInAllowList) { if (enableAllowMode && !isExplictlyAllowed) {
return true; // must be skippable finalIsIgnored = true; // must be skippable
} }
let isExplictlyIgnored = false;
if (ignorePaths !== undefined && ignorePaths.length > 0) { if (ignorePaths !== undefined && ignorePaths.length > 0) {
for (const r of ignorePaths) { for (const r of ignorePaths) {
if (r.trim() === "") { if (r.trim() === "") {
@@ -184,29 +193,163 @@ const isSkipItemByName = (
continue; continue;
} }
if (XRegExp(r, "A").test(key)) { if (XRegExp(r, "A").test(key)) {
return true; if (finalIsIgnored === undefined) {
isExplictlyIgnored = true;
finalIsIgnored = true;
}
} }
} }
} }
if (syncConfigDir && isInsideObsFolder(key, configDir)) {
return false;
}
if (syncBookmarks && isBookmarksFile(key, configDir)) { // sync config, not sync bookmarks: sync config and **force syncing bookmarks as well**
return false; // not sync config, sync bookmarks: sync bookmars, not other config
// not sync config, not sync bookmarks: not sync config
if (finalIsIgnored === undefined) {
if (syncConfigDir) {
if (isInsideObsFolder(key, configDir)) {
// force sync everything
finalIsIgnored = false;
} else {
// not config files, do not judge now, do nothing
}
} else if (syncBookmarks) {
// not sync config, sync bookmarks
if (isBookmarksFile(key, configDir)) {
// sync everything of bookmarks
finalIsIgnored = false;
} else if (isInsideObsFolder(key, configDir)) {
// not sync any other thing in config
finalIsIgnored = true;
} else {
// not config files, do not judge now, do nothing
}
} else {
// not sync config, and not sync bookmarks
if (isInsideObsFolder(key, configDir)) {
// not sync any thing in config
finalIsIgnored = true;
} else {
// not config files, do not judge now, do nothing
}
}
} }
if (isSpecialFolderNameToSkip(key, [])) { if (isSpecialFolderNameToSkip(key, [])) {
// some special dirs and files are always skipped // some special dirs and files are always skipped
return true; if (finalIsIgnored === undefined) {
isExplictlyIgnored = true;
finalIsIgnored = true;
}
} }
return (
const checkIsHidden =
isHiddenPath(key, true, false) || isHiddenPath(key, true, false) ||
(!syncUnderscoreItems && isHiddenPath(key, false, true)) || (!syncUnderscoreItems && isHiddenPath(key, false, true)) ||
key === "/" || key === "/" ||
key === DEFAULT_FILE_NAME_FOR_METADATAONREMOTE || key === DEFAULT_FILE_NAME_FOR_METADATAONREMOTE ||
key === DEFAULT_FILE_NAME_FOR_METADATAONREMOTE2 key === DEFAULT_FILE_NAME_FOR_METADATAONREMOTE2;
); if (finalIsIgnored === undefined) {
isExplictlyIgnored = checkIsHidden;
finalIsIgnored = checkIsHidden;
}
if (finalIsIgnored === undefined) {
throw Error(`no finalIsIgnored in checkIsSkipItemOrNotByName for ${key}`);
}
return {
enableAllowMode: enableAllowMode,
isExplictlyAllowed: isExplictlyAllowed,
isExplictlyIgnored: isExplictlyIgnored,
finalIsIgnored: finalIsIgnored,
};
};
/**
* | finalIgnored | reason | explictlyIgnored | allowMode | explictlyAllowed |
* | ------------------------------- | -------------------------------------------- | ---------------- | --------- | ---------------- |
* | no | nothing blocking | no | no | no |
* | yes, MAY be changed by children | allow mode not allowed, inexplicitly ignored | no | yes | no |
* | no, MAY apply to parents | allow mode allowed | no | yes | yes |
* | yes, also apply to children | explictly ignored | yes | no | no |
* | yes, also apply to children | explictly ignored | yes | yes | no |
* | yes, also apply to children | explictly ignored | yes | yes | yes |
*/
export const getSkipItemsByList = (
skipOrNotResults: Record<string, IsSkipResult>
): string[] => {
const allPotentialKeys = Object.keys(skipOrNotResults);
// from short(shadow) to long(deep) , ascending
const sortedKeys = allPotentialKeys.sort((k1, k2) => k1.length - k2.length);
// we deal with explicty ignored list firstly, apply them to children
const explictlyIgnoredSet = new Set<string>();
for (const key of sortedKeys) {
if (skipOrNotResults[key].isExplictlyIgnored) {
skipOrNotResults[key].finalIsIgnored = true;
explictlyIgnoredSet.add(key);
} else {
const parents = getFolderLevels(key, true).reverse();
for (const key2 of parents) {
if (explictlyIgnoredSet.has(key2)) {
skipOrNotResults[key].isExplictlyIgnored = true;
skipOrNotResults[key].finalIsIgnored = true;
explictlyIgnoredSet.add(key);
break;
}
}
}
}
// we deal with explictly allow list secondly, apply them to PARENTS if possible
let enableAllowMode = false;
if (
allPotentialKeys.length > 0 &&
allPotentialKeys[0] !== undefined &&
skipOrNotResults[allPotentialKeys[0]] !== undefined
) {
enableAllowMode = skipOrNotResults[allPotentialKeys[0]].enableAllowMode;
}
if (enableAllowMode) {
for (let index = 0; index < sortedKeys.length; index++) {
// reverse order, long(deep) to short(shadow), ascending
const key = sortedKeys[sortedKeys.length - index - 1];
if (
!skipOrNotResults[key].isExplictlyIgnored &&
skipOrNotResults[key].isExplictlyAllowed
) {
// the file is explictly allowed, and not explictly ignored by anywhere
// we allow all its parents!
const parents = getFolderLevels(key, true).reverse();
for (const key2 of parents) {
if (
key2 in skipOrNotResults &&
!skipOrNotResults[key2].isExplictlyIgnored &&
!explictlyIgnoredSet.has(key2)
) {
skipOrNotResults[key2].isExplictlyAllowed = true;
skipOrNotResults[key2].finalIsIgnored = false; // from ignored to allowed
} else {
throw Error(
`${key}'s parent ${key2} in abnormal state: ${JSON.stringify(skipOrNotResults[key2])}`
);
}
}
}
}
}
// get all finalIsIgnored
const result: string[] = [];
for (const key of sortedKeys) {
if (skipOrNotResults[key].finalIsIgnored) {
result.push(key);
}
}
console.debug(`finalIsIgnored list= ${JSON.stringify(result)}`);
return result;
}; };
export type SyncPlanType = Record<string, MixedEntity>; export type SyncPlanType = Record<string, MixedEntity>;
@@ -235,33 +378,39 @@ const ensembleMixedEnties = async (
const finalMappings: SyncPlanType = {}; const finalMappings: SyncPlanType = {};
const skipOrNotResults: Record<string, IsSkipResult> = {};
// remote has to be first // remote has to be first
let remoteMaySkipCountAndNotConfig = 0;
for (const remote of remoteEntityList) { for (const remote of remoteEntityList) {
const remoteCopied = ensureMTimeOfRemoteEntityValid( const remoteCopied = ensureMTimeOfRemoteEntityValid(
copyEntityAndFixTimeFormat(remote, serviceType) copyEntityAndFixTimeFormat(remote, serviceType)
); );
const key = remoteCopied.key!; const key = remoteCopied.key!;
if (
isSkipItemByName( const skipOrNot = checkIsSkipItemOrNotByName(
key, key,
syncConfigDir, syncConfigDir,
syncBookmarks, syncBookmarks,
syncUnderscoreItems, syncUnderscoreItems,
configDir, configDir,
ignorePaths, ignorePaths,
onlyAllowPaths onlyAllowPaths
) );
) { skipOrNotResults[key] = skipOrNot;
continue; if (skipOrNot.finalIsIgnored && !key.startsWith(configDir)) {
remoteMaySkipCountAndNotConfig += 1;
} }
const checkValidNameResult = checkValidName(key); // 20240907: users (not on windows) doesn't like it. revert back now.
if (!checkValidNameResult.result) { // TODO: platform specific but not introducing obsidian dependency into sync.ts
throw Error( // const checkValidNameResult = checkValidName(key);
`your remote folder/file name is invalid: ${checkValidNameResult.reason}` // if (!checkValidNameResult.result) {
); // throw Error(
} // `your remote folder/file name is invalid: ${checkValidNameResult.reason}`
// );
// }
finalMappings[key] = { finalMappings[key] = {
key: key, key: key,
@@ -272,19 +421,25 @@ const ensembleMixedEnties = async (
profiler?.insert("ensembleMixedEnties: finish remote"); profiler?.insert("ensembleMixedEnties: finish remote");
profiler?.insertSize("sizeof finalMappings", finalMappings); profiler?.insertSize("sizeof finalMappings", finalMappings);
if (Object.keys(finalMappings).length === 0 || localEntityList.length === 0) { if (
Object.keys(finalMappings).filter((k) => !k.startsWith(configDir)).length -
remoteMaySkipCountAndNotConfig ===
0 ||
localEntityList.filter((e) => !e.key?.startsWith(configDir)).length === 0
) {
// Special checking: // Special checking:
// if one side is totally empty, // if one side is totally empty,
// usually that's a hard rest. // usually that's a hard rest.
// So we need to ignore everything of prevSyncEntityList to avoid deletions! // So we need to ignore everything of prevSyncEntityList to avoid deletions!
// TODO: acutally erase everything of prevSyncEntityList? // TODO: acutally erase everything of prevSyncEntityList?
// TODO: local should also go through a isSkipItemByName checking beforehand // TODO: local should also go through a checkIsSkipItemOrNotByName checking beforehand
} else { } else {
// normally go through the prevSyncEntityList // normally go through the prevSyncEntityList
for (const prevSync of prevSyncEntityList) { for (const prevSync of prevSyncEntityList) {
const key = prevSync.key!; const key = prevSync.key!;
if (
isSkipItemByName( if (!(key in skipOrNotResults)) {
const skipOrNot = checkIsSkipItemOrNotByName(
key, key,
syncConfigDir, syncConfigDir,
syncBookmarks, syncBookmarks,
@@ -292,9 +447,8 @@ const ensembleMixedEnties = async (
configDir, configDir,
ignorePaths, ignorePaths,
onlyAllowPaths onlyAllowPaths
) );
) { skipOrNotResults[key] = skipOrNot;
continue;
} }
// TODO: abstraction leaking? // TODO: abstraction leaking?
@@ -320,8 +474,9 @@ const ensembleMixedEnties = async (
// (we don't consume prevSync here because it gains no benefit) // (we don't consume prevSync here because it gains no benefit)
for (const local of localEntityList) { for (const local of localEntityList) {
const key = local.key!; const key = local.key!;
if (
isSkipItemByName( if (!(key in skipOrNotResults)) {
const skipOrNot = checkIsSkipItemOrNotByName(
key, key,
syncConfigDir, syncConfigDir,
syncBookmarks, syncBookmarks,
@@ -329,17 +484,18 @@ const ensembleMixedEnties = async (
configDir, configDir,
ignorePaths, ignorePaths,
onlyAllowPaths onlyAllowPaths
) );
) { skipOrNotResults[key] = skipOrNot;
continue;
} }
const checkValidNameResult = checkValidName(key); // 20240907: users (not on windows) doesn't like it. revert back now.
if (!checkValidNameResult.result) { // TODO: platform specific but not introducing obsidian dependency into sync.ts
throw Error( // const checkValidNameResult = checkValidName(key);
`your local folder/file name is invalid: ${checkValidNameResult.reason}` // if (!checkValidNameResult.result) {
); // throw Error(
} // `your local folder/file name is invalid: ${checkValidNameResult.reason}`
// );
// }
// TODO: abstraction leaking? // TODO: abstraction leaking?
const localCopied = await fsEncrypt.encryptEntity( const localCopied = await fsEncrypt.encryptEntity(
@@ -358,6 +514,15 @@ const ensembleMixedEnties = async (
profiler?.insert("ensembleMixedEnties: finish local"); profiler?.insert("ensembleMixedEnties: finish local");
profiler?.insertSize("sizeof finalMappings", finalMappings); profiler?.insertSize("sizeof finalMappings", finalMappings);
// we check the skipOrNotResults again! in case we adjust some paths!
const allReallySkipKeys = getSkipItemsByList(skipOrNotResults);
for (const key of allReallySkipKeys) {
delete finalMappings[key];
}
profiler?.insert("ensembleMixedEnties: finish parsing all skip items");
profiler?.insertSize("sizeof finalMappings", finalMappings);
// console.debug("in the end of ensembleMixedEnties, finalMappings is:"); // console.debug("in the end of ensembleMixedEnties, finalMappings is:");
// console.debug(finalMappings); // console.debug(finalMappings);
@@ -383,7 +548,7 @@ const getSyncPlanInplace = async (
) => { ) => {
profiler?.addIndent(); profiler?.addIndent();
profiler?.insert("getSyncPlanInplace: enter"); profiler?.insert("getSyncPlanInplace: enter");
// from long(deep) to short(shadow) // from long(deep) to short(shadow), descending
const sortedKeys = Object.keys(mixedEntityMappings).sort( const sortedKeys = Object.keys(mixedEntityMappings).sort(
(k1, k2) => k2.length - k1.length (k1, k2) => k2.length - k1.length
); );
@@ -476,14 +641,36 @@ const getSyncPlanInplace = async (
mixedEntry.change = false; mixedEntry.change = false;
keptFolder.add(getParentFolder(key)); keptFolder.add(getParentFolder(key));
} else if (syncDirection === "incremental_pull_and_delete_only") { } else if (syncDirection === "incremental_pull_and_delete_only") {
mixedEntry.decisionBranch = 135; if (
mixedEntry.decision = "folder_to_be_deleted_on_local"; key === `${configDir}/` ||
mixedEntry.change = true; key === `${configDir}/bookmarks.json`
) {
// special: never delete .obsidian folder!
mixedEntry.decisionBranch = 137;
mixedEntry.decision = "folder_existed_both_then_do_nothing";
mixedEntry.change = false;
keptFolder.add(getParentFolder(key));
} else {
mixedEntry.decisionBranch = 135;
mixedEntry.decision = "folder_to_be_deleted_on_local";
mixedEntry.change = true;
}
} else { } else {
// bidirectional // bidirectional
mixedEntry.decisionBranch = 124; if (
mixedEntry.decision = "folder_to_be_deleted_on_local"; key === `${configDir}/` ||
mixedEntry.change = true; key === `${configDir}/bookmarks.json`
) {
// special: never delete .obsidian folder!
mixedEntry.decisionBranch = 138;
mixedEntry.decision = "folder_existed_both_then_do_nothing";
mixedEntry.change = false;
keptFolder.add(getParentFolder(key));
} else {
mixedEntry.decisionBranch = 124;
mixedEntry.decision = "folder_to_be_deleted_on_local";
mixedEntry.change = true;
}
} }
} else { } else {
// then the folder is created on local // then the folder is created on local
@@ -925,13 +1112,35 @@ const getSyncPlanInplace = async (
mixedEntry.decision = "conflict_created_then_do_nothing"; mixedEntry.decision = "conflict_created_then_do_nothing";
mixedEntry.change = false; mixedEntry.change = false;
} else if (syncDirection === "incremental_pull_and_delete_only") { } else if (syncDirection === "incremental_pull_and_delete_only") {
mixedEntry.decisionBranch = 39; if (
mixedEntry.decision = "remote_is_deleted_thus_also_delete_local"; key === `${configDir}/` ||
mixedEntry.change = true; key === `${configDir}/bookmarks.json`
) {
// special: never delete .obsidian/bookmarks.json file!
mixedEntry.decisionBranch = 139;
mixedEntry.decision = "conflict_created_then_keep_local";
mixedEntry.change = true;
keptFolder.add(getParentFolder(key));
} else {
mixedEntry.decisionBranch = 39;
mixedEntry.decision = "remote_is_deleted_thus_also_delete_local";
mixedEntry.change = true;
}
} else { } else {
mixedEntry.decisionBranch = 7; if (
mixedEntry.decision = "remote_is_deleted_thus_also_delete_local"; key === `${configDir}/` ||
mixedEntry.change = true; key === `${configDir}/bookmarks.json`
) {
// special: never delete .obsidian/bookmarks.json file!
mixedEntry.decisionBranch = 140;
mixedEntry.decision = "conflict_created_then_keep_local";
mixedEntry.change = true;
keptFolder.add(getParentFolder(key));
} else {
mixedEntry.decisionBranch = 7;
mixedEntry.decision = "remote_is_deleted_thus_also_delete_local";
mixedEntry.change = true;
}
} }
} else { } else {
// if A is in the previous list and MODIFIED, A has been deleted by B but modified by A // if A is in the previous list and MODIFIED, A has been deleted by B but modified by A
@@ -1719,7 +1928,7 @@ export async function syncer(
) => any, ) => any,
callbackSyncProcess?: any callbackSyncProcess?: any
) { ) {
console.info(`startting sync.`); console.info(`starting sync.`);
markIsSyncingFunc(true); markIsSyncingFunc(true);
let everythingOk = true; let everythingOk = true;
@@ -1878,6 +2087,6 @@ export async function syncer(
await ribboonFunc?.(triggerSource, step); await ribboonFunc?.(triggerSource, step);
await statusBarFunc?.(triggerSource, step, everythingOk); await statusBarFunc?.(triggerSource, step, everythingOk);
console.info(`endding sync.`); console.info(`ending sync.`);
markIsSyncingFunc(false); markIsSyncingFunc(false);
} }
+154
View File
@@ -0,0 +1,154 @@
import { strict as assert } from "assert";
import { checkIsSkipItemOrNotByName } from "../src/sync";
describe("Sync: checkIsSkipItemOrNotByName", () => {
it("should be ok everywhere for empty config", async () => {
let isSkip = checkIsSkipItemOrNotByName(
"xxx.md",
false,
false,
false,
".obsidian",
/* ignorePaths */ [],
/* onlyAllowPaths */ []
).finalIsIgnored;
assert.ok(!isSkip);
isSkip = checkIsSkipItemOrNotByName(
"xxx.md",
false,
false,
false,
".obsidian",
/* ignorePaths */ [""],
/* onlyAllowPaths */ ["", "\n"]
).finalIsIgnored;
assert.ok(!isSkip);
});
it("should be ok for deny list", async () => {
let isSkip = checkIsSkipItemOrNotByName(
"xxx.md",
false,
false,
false,
".obsidian",
/* ignorePaths */ ["xxx"],
/* onlyAllowPaths */ []
).finalIsIgnored;
assert.ok(isSkip);
isSkip = checkIsSkipItemOrNotByName(
"yyy.md",
false,
false,
false,
".obsidian",
/* ignorePaths */ ["xxx"],
/* onlyAllowPaths */ []
).finalIsIgnored;
assert.ok(!isSkip);
isSkip = checkIsSkipItemOrNotByName(
"xxx.md",
false,
false,
false,
".obsidian",
/* ignorePaths */ ["xxx$"],
/* onlyAllowPaths */ []
).finalIsIgnored;
assert.ok(!isSkip);
// if we deny a folder, we have to deny all the sub files
// TODO: it's soooo hard to do the path resolution in this func with regex,
// so we defer the detection to later steps now.
// the test here doesn't work.
// isSkip = checkIsSkipItemOrNotByName(
// 'xxx/yyy.md',
// false,
// false,
// false,
// '.obsidian',
// /* ignorePaths */ ['xxx/$'],
// /* onlyAllowPaths */ []
// ).finalIsIgnored;
// assert.ok(isSkip);
});
it("should be ok for allow list", async () => {
let isSkip = checkIsSkipItemOrNotByName(
"xxx.md",
false,
false,
false,
".obsidian",
/* ignorePaths */ [],
/* onlyAllowPaths */ ["xxx"]
).finalIsIgnored;
assert.ok(!isSkip);
isSkip = checkIsSkipItemOrNotByName(
"yyy.md",
false,
false,
false,
".obsidian",
/* ignorePaths */ [""],
/* onlyAllowPaths */ ["xxx"]
).finalIsIgnored;
assert.ok(isSkip);
isSkip = checkIsSkipItemOrNotByName(
"xxx.md",
false,
false,
false,
".obsidian",
/* ignorePaths */ [],
/* onlyAllowPaths */ ["xxx$"]
).finalIsIgnored;
assert.ok(isSkip);
// should NOT skip because we allow the sub file AND not deny the folder
// TODO: it's soooo hard to do the path resolution in this func with regex,
// so we defer the detection to later steps now.
// the test here doesn't work.
// isSkip = checkIsSkipItemOrNotByName(
// 'xxx/',
// false,
// false,
// false,
// '.obsidian',
// /* ignorePaths */ [],
// /* onlyAllowPaths */ ['xxx/yyy.md']
// ).finalIsIgnored;
// assert.ok(!isSkip);
});
it("should detect the name by two lists together", async () => {
// should skip because we ignore the path
let isSkip = checkIsSkipItemOrNotByName(
"xxx.md",
false,
false,
false,
".obsidian",
/* ignorePaths */ ["xxx"],
/* onlyAllowPaths */ ["yyy"]
).finalIsIgnored;
assert.ok(isSkip);
// should skip because we disallow the whole folder
isSkip = checkIsSkipItemOrNotByName(
"xxx/yyy.md",
false,
false,
false,
".obsidian",
/* ignorePaths */ ["xxx"],
/* onlyAllowPaths */ ["xxx/yyy.md"]
).finalIsIgnored;
assert.ok(isSkip);
});
});
+1
View File
@@ -140,6 +140,7 @@ export type CipherMethodType = "rclone-base64" | "openssl-base64" | "unknown";
export type QRExportType = "basic_and_advanced" | SUPPORTED_SERVICES_TYPE; export type QRExportType = "basic_and_advanced" | SUPPORTED_SERVICES_TYPE;
export interface ProfilerConfig { export interface ProfilerConfig {
enable?: boolean;
enablePrinting?: boolean; enablePrinting?: boolean;
recordSize?: boolean; recordSize?: boolean;
} }
+1 -1
View File
@@ -24,7 +24,7 @@ const getKeyIVFromPassword = async (
const k2 = await window.crypto.subtle.deriveBits( const k2 = await window.crypto.subtle.deriveBits(
{ {
name: "PBKDF2", name: "PBKDF2",
salt: salt, salt: salt as Uint8Array<ArrayBuffer>,
iterations: rounds, iterations: rounds,
hash: "SHA-256", hash: "SHA-256",
}, },
+48 -1
View File
@@ -252,6 +252,10 @@ const fromDriveItemToEntity = (x: DriveItem, remoteBaseDir: string): Entity => {
// why?? /drive/root:/Apps/Graph // why?? /drive/root:/Apps/Graph
const FIFTH_COMMON_PREFIX_REGEX = /^\/drive\/root:\/[^\/]+\/Graph\//g; const FIFTH_COMMON_PREFIX_REGEX = /^\/drive\/root:\/[^\/]+\/Graph\//g;
// why again?? /drive/root:/Apps/Graph 1
const SIXTH_COMMON_PREFIX_REGEX = /^\/drive\/root:\/[^\/]+\/Graph 1\//g;
const SIXTH_COMMON_PREFIX_REGEX_V2 = /^\/drive\/root:\/[^\/]+\/Graph%201\//g;
// or the root is absolute path /Livefolders, // or the root is absolute path /Livefolders,
// e.g.: /Livefolders/应用/remotely-save/${remoteBaseDir} // e.g.: /Livefolders/应用/remotely-save/${remoteBaseDir}
const SECOND_COMMON_PREFIX_REGEX = /^\/Livefolders\/[^\/]+\/remotely-save\//g; const SECOND_COMMON_PREFIX_REGEX = /^\/Livefolders\/[^\/]+\/remotely-save\//g;
@@ -278,6 +282,11 @@ const fromDriveItemToEntity = (x: DriveItem, remoteBaseDir: string): Entity => {
const fullPathOriginal = `${x.parentReference.path}/${x.name}`; const fullPathOriginal = `${x.parentReference.path}/${x.name}`;
const matchFirstPrefixRes = fullPathOriginal.match(FIRST_COMMON_PREFIX_REGEX); const matchFirstPrefixRes = fullPathOriginal.match(FIRST_COMMON_PREFIX_REGEX);
const matchFifthPrefixRes = fullPathOriginal.match(FIFTH_COMMON_PREFIX_REGEX); const matchFifthPrefixRes = fullPathOriginal.match(FIFTH_COMMON_PREFIX_REGEX);
const matchSixthPrefixRes = fullPathOriginal.match(SIXTH_COMMON_PREFIX_REGEX);
const matchSixthV2PrefixRes = fullPathOriginal.match(
SIXTH_COMMON_PREFIX_REGEX_V2
);
const matchSecondPrefixRes = fullPathOriginal.match( const matchSecondPrefixRes = fullPathOriginal.match(
SECOND_COMMON_PREFIX_REGEX SECOND_COMMON_PREFIX_REGEX
); );
@@ -317,6 +326,40 @@ const fromDriveItemToEntity = (x: DriveItem, remoteBaseDir: string): Entity => {
key = fullPathOriginal.substring(foundPrefix.length + 1); key = fullPathOriginal.substring(foundPrefix.length + 1);
} }
// sixth
else if (
matchSixthPrefixRes !== null &&
fullPathOriginal.startsWith(`${matchSixthPrefixRes[0]}${remoteBaseDir}`)
) {
const foundPrefix = `${matchSixthPrefixRes[0]}${remoteBaseDir}`;
key = fullPathOriginal.substring(foundPrefix.length + 1);
} else if (
matchSixthPrefixRes !== null &&
fullPathOriginal.startsWith(
`${matchSixthPrefixRes[0]}${remoteBaseDirEncoded}`
)
) {
const foundPrefix = `${matchSixthPrefixRes[0]}${remoteBaseDirEncoded}`;
key = fullPathOriginal.substring(foundPrefix.length + 1);
}
// sixth v2
else if (
matchSixthV2PrefixRes !== null &&
fullPathOriginal.startsWith(`${matchSixthV2PrefixRes[0]}${remoteBaseDir}`)
) {
const foundPrefix = `${matchSixthV2PrefixRes[0]}${remoteBaseDir}`;
key = fullPathOriginal.substring(foundPrefix.length + 1);
} else if (
matchSixthV2PrefixRes !== null &&
fullPathOriginal.startsWith(
`${matchSixthV2PrefixRes[0]}${remoteBaseDirEncoded}`
)
) {
const foundPrefix = `${matchSixthV2PrefixRes[0]}${remoteBaseDirEncoded}`;
key = fullPathOriginal.substring(foundPrefix.length + 1);
}
// second // second
else if ( else if (
matchSecondPrefixRes !== null && matchSecondPrefixRes !== null &&
@@ -374,6 +417,8 @@ const fromDriveItemToEntity = (x: DriveItem, remoteBaseDir: string): Entity => {
fullPathOriginal=${fullPathOriginal} fullPathOriginal=${fullPathOriginal}
matchFirstPrefixRes=${matchFirstPrefixRes} matchFirstPrefixRes=${matchFirstPrefixRes}
matchFifthPrefixRes=${matchFifthPrefixRes} matchFifthPrefixRes=${matchFifthPrefixRes}
matchSixthPrefixRes=${matchSixthPrefixRes}
matchSixthV2PrefixRes=${matchSixthV2PrefixRes}
matchSecondPrefixRes=${matchSecondPrefixRes} matchSecondPrefixRes=${matchSecondPrefixRes}
matchThirdPrefixRes=${matchThirdPrefixRes} matchThirdPrefixRes=${matchThirdPrefixRes}
${constructFromDriveItemToEntityError(x)}` ${constructFromDriveItemToEntityError(x)}`
@@ -388,6 +433,8 @@ ${constructFromDriveItemToEntityError(x)}`
fullPathOriginal=${fullPathOriginal} fullPathOriginal=${fullPathOriginal}
matchFirstPrefixRes=${matchFirstPrefixRes} matchFirstPrefixRes=${matchFirstPrefixRes}
matchFifthPrefixRes=${matchFifthPrefixRes} matchFifthPrefixRes=${matchFifthPrefixRes}
matchSixthPrefixRes=${matchSixthPrefixRes}
matchSixthV2PrefixRes=${matchSixthV2PrefixRes}
matchSecondPrefixRes=${matchSecondPrefixRes} matchSecondPrefixRes=${matchSecondPrefixRes}
matchThirdPrefixRes=${matchThirdPrefixRes} matchThirdPrefixRes=${matchThirdPrefixRes}
${constructFromDriveItemToEntityError(x)}` ${constructFromDriveItemToEntityError(x)}`
@@ -682,7 +729,7 @@ export class FakeFsOnedrive extends FakeFs {
*/ */
async _putUint8ArrayByRange( async _putUint8ArrayByRange(
pathFragOrig: string, pathFragOrig: string,
payload: Uint8Array, payload: Uint8Array<ArrayBuffer>,
rangeStart: number, rangeStart: number,
rangeEnd: number, rangeEnd: number,
size: number size: number
-2
View File
@@ -22,8 +22,6 @@ import {
import { requestTimeout } from "@smithy/fetch-http-handler/dist-es/request-timeout"; import { requestTimeout } from "@smithy/fetch-http-handler/dist-es/request-timeout";
import { type HttpRequest, HttpResponse } from "@smithy/protocol-http"; import { type HttpRequest, HttpResponse } from "@smithy/protocol-http";
import { buildQueryString } from "@smithy/querystring-builder"; import { buildQueryString } from "@smithy/querystring-builder";
// biome-ignore lint/suspicious/noShadowRestrictedNames: <explanation>
import AggregateError from "aggregate-error";
import * as mime from "mime-types"; import * as mime from "mime-types";
import { Platform, type RequestUrlParam, requestUrl } from "obsidian"; import { Platform, type RequestUrlParam, requestUrl } from "obsidian";
import PQueue from "p-queue"; import PQueue from "p-queue";
+33 -12
View File
@@ -138,7 +138,6 @@ if (VALID_REQURL) {
); );
} }
import isEqual from "lodash/isEqual";
// @ts-ignore // @ts-ignore
// biome-ignore lint: we want to ts-ignore the next line // biome-ignore lint: we want to ts-ignore the next line
import { AuthType, BufferLike, createClient } from "webdav/dist/web/index.js"; import { AuthType, BufferLike, createClient } from "webdav/dist/web/index.js";
@@ -169,23 +168,37 @@ const getWebdavPath = (fileOrFolderPath: string, remoteBaseDir: string) => {
return key; return key;
}; };
/**
* sometimes the path startswith /../../......
* we want to make sure the path is compatible
*/
const stripLeadingPath = (x: string) => {
let y = x;
while (y.startsWith("/..")) {
y = y.slice("/..".length);
}
return y;
};
const getNormPath = (fileOrFolderPath: string, remoteBaseDir: string) => { const getNormPath = (fileOrFolderPath: string, remoteBaseDir: string) => {
const strippedFileOrFolderPath = stripLeadingPath(fileOrFolderPath);
if ( if (
!( !(
fileOrFolderPath === `/${remoteBaseDir}` || strippedFileOrFolderPath === `/${remoteBaseDir}` ||
fileOrFolderPath.startsWith(`/${remoteBaseDir}/`) strippedFileOrFolderPath.startsWith(`/${remoteBaseDir}/`)
) )
) { ) {
throw Error( throw Error(
`"${fileOrFolderPath}" doesn't starts with "/${remoteBaseDir}/"` `"${fileOrFolderPath}" after stripping doesn't starts with "/${remoteBaseDir}/"`
); );
} }
const result = strippedFileOrFolderPath.slice(`/${remoteBaseDir}/`.length);
return fileOrFolderPath.slice(`/${remoteBaseDir}/`.length); return result;
}; };
const fromWebdavItemToEntity = (x: FileStat, remoteBaseDir: string): Entity => { const fromWebdavItemToEntity = (x: FileStat, remoteBaseDir: string): Entity => {
let key = getNormPath(x.filename, remoteBaseDir); let key = getNormPath(x.filename, remoteBaseDir);
if (x.type === "directory" && !key.endsWith("/")) { if (x.type === "directory" && !key.endsWith("/")) {
key = `${key}/`; key = `${key}/`;
} }
@@ -447,15 +460,17 @@ export class FakeFsWebdav extends FakeFs {
// console.debug(itemsToFetchChunks); // console.debug(itemsToFetchChunks);
const subContents = [] as FileStat[]; const subContents = [] as FileStat[];
for (const singleChunk of itemsToFetchChunks) { for (const singleChunk of itemsToFetchChunks) {
const r = singleChunk.map((x) => { const r = singleChunk.map(async (x) => {
return this.client.getDirectoryContents(x, { let k = (await this.client.getDirectoryContents(x, {
deep: false, deep: false,
details: false /* no need for verbose details here */, details: false /* no need for verbose details here */,
// TODO: to support .obsidian, // TODO: to support .obsidian,
// we need to load all files including dot, // we need to load all files including dot,
// anyway to reduce the resources? // anyway to reduce the resources?
// glob: "/**" /* avoid dot files by using glob */, // glob: "/**" /* avoid dot files by using glob */,
}) as Promise<FileStat[]>; })) as FileStat[];
k = k.filter((sub) => stripLeadingPath(sub.filename) !== x);
return k;
}); });
const r3 = await Promise.all(r); const r3 = await Promise.all(r);
for (const r4 of r3) { for (const r4 of r3) {
@@ -477,7 +492,7 @@ export class FakeFsWebdav extends FakeFs {
const f = subContents[i]; const f = subContents[i];
contents.push(f); contents.push(f);
if (f.type === "directory") { if (f.type === "directory") {
q.push(f.filename); q.push(stripLeadingPath(f.filename));
} }
} }
} }
@@ -495,7 +510,11 @@ export class FakeFsWebdav extends FakeFs {
} }
)) as FileStat[]; )) as FileStat[];
} }
return contents.map((x) => fromWebdavItemToEntity(x, this.remoteBaseDir));
const result = contents
.map((x) => fromWebdavItemToEntity(x, this.remoteBaseDir))
.filter((x) => x.keyRaw !== "/");
return result;
} }
async walkPartial(): Promise<Entity[]> { async walkPartial(): Promise<Entity[]> {
@@ -508,7 +527,9 @@ export class FakeFsWebdav extends FakeFs {
details: false /* no need for verbose details here */, details: false /* no need for verbose details here */,
} }
)) as FileStat[]; )) as FileStat[];
return contents.map((x) => fromWebdavItemToEntity(x, this.remoteBaseDir)); return contents
.map((x) => fromWebdavItemToEntity(x, this.remoteBaseDir))
.filter((x) => x.keyRaw !== "/");
} }
async stat(key: string): Promise<Entity> { async stat(key: string): Promise<Entity> {
+6 -4
View File
@@ -31,7 +31,7 @@
"protocol_dropbox_connect_succ_revoke": "You've connected as user {{username}}. If you want to disconnect, click this button.", "protocol_dropbox_connect_succ_revoke": "You've connected as user {{username}}. If you want to disconnect, click this button.",
"protocol_dropbox_connect_fail": "Something went wrong from response from Dropbox. Maybe the network connection is not good. Maybe you rejected the auth?", "protocol_dropbox_connect_fail": "Something went wrong from response from Dropbox. Maybe the network connection is not good. Maybe you rejected the auth?",
"protocol_dropbox_connect_unknown": "Do not know how to deal with the callback: {{params}}", "protocol_dropbox_connect_unknown": "Do not know how to deal with the callback: {{params}}",
"protocol_dropbox_no_modal": "You are not startting Dropbox connection from the settings page. Abort.", "protocol_dropbox_no_modal": "You are not starting Dropbox connection from the settings page. Abort.",
"protocol_onedrive_connecting": "Connecting to OneDrive...\nPlease DO NOT close this modal.", "protocol_onedrive_connecting": "Connecting to OneDrive...\nPlease DO NOT close this modal.",
"protocol_onedrive_connect_succ_revoke": "You've connected as user {{username}}. If you want to disconnect, click this button.", "protocol_onedrive_connect_succ_revoke": "You've connected as user {{username}}. If you want to disconnect, click this button.",
"protocol_onedrive_connect_fail": "Something went wrong from response from OneDrive. Maybe you rejected the auth?", "protocol_onedrive_connect_fail": "Something went wrong from response from OneDrive. Maybe you rejected the auth?",
@@ -110,7 +110,7 @@
"modal_onedriverevokeauth_clean_button": "Clean", "modal_onedriverevokeauth_clean_button": "Clean",
"modal_onedriverevokeauth_clean_notice": "Cleaned!", "modal_onedriverevokeauth_clean_notice": "Cleaned!",
"modal_onedriverevokeauth_clean_fail": "Something goes wrong while revoking.", "modal_onedriverevokeauth_clean_fail": "Something goes wrong while revoking.",
"modal_syncconfig_attn": "Attention 1/2: This only syncs (copies) the whole Obsidian config dir, not other startting-with-dot folders or files. Except for ignoring folders .git and node_modules, it also doesn't understand the meaning of sub-files and sub-folders inside the config dir.\nAttention 2/2: After the config dir is synced, plugins settings might be corrupted, and Obsidian might need to be restarted to load the new settings.\nIf you are agreed to take your own risk, please click the following second confirm button.", "modal_syncconfig_attn": "Attention 1/2: This only syncs (copies) the whole Obsidian config dir, not other starting-with-dot folders or files. Except for ignoring folders .git and node_modules, it also doesn't understand the meaning of sub-files and sub-folders inside the config dir.\nAttention 2/2: After the config dir is synced, plugins settings might be corrupted, and Obsidian might need to be restarted to load the new settings.\nIf you are agreed to take your own risk, please click the following second confirm button.",
"modal_syncconfig_secondconfirm": "The Second Confirm To Enable.", "modal_syncconfig_secondconfirm": "The Second Confirm To Enable.",
"modal_syncconfig_notice": "You've enabled syncing config folder!", "modal_syncconfig_notice": "You've enabled syncing config folder!",
"modal_qr_shortdesc": "This exports (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_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.",
@@ -194,7 +194,7 @@
"settings_s3_reverse_proxy_no_sign_url": "S3 Reverse Proxy (No Sign) Url (experimental)", "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_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": "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_desc": "S3 doesn't have \"real\" folder. If you set \"Generate\" here (or use old version), the plugin will upload a zero-byte object ending 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_notgenerate": "Not generate (default)",
"settings_s3_generatefolderobject_generate": "Generate", "settings_s3_generatefolderobject_generate": "Generate",
"settings_s3_connect_succ": "Great! The bucket can be accessed.", "settings_s3_connect_succ": "Great! The bucket can be accessed.",
@@ -340,7 +340,7 @@
"settings_obfuscatesettingfile": "Obfuscate The Setting File Or Not", "settings_obfuscatesettingfile": "Obfuscate The Setting File Or Not",
"settings_obfuscatesettingfile_desc": "The setting file (data.json) has some sensitive information. It's strongly recommended to obfuscate it to avoid unexpected read and modification. If you are sure to view and edit it manually, you can disable the obfuscation.", "settings_obfuscatesettingfile_desc": "The setting file (data.json) has some sensitive information. It's strongly recommended to obfuscate it to avoid unexpected read and modification. If you are sure to view and edit it manually, you can disable the obfuscation.",
"settings_viewconsolelog": "View Console Log", "settings_viewconsolelog": "View Console Log",
"settings_viewconsolelog_desc": "On desktop, please press \"ctrl+shift+i\" or \"cmd+shift+i\" to view the log. On mobile, please install the third-party plugin <a href='https://obsidian.md/plugins?search=Logstravaganza'>Logstravaganza</a> to export the console log to a note.", "settings_viewconsolelog_desc": "On desktop, please press \"ctrl+shift+i\" or \"cmd+option+i\" to view the log. On mobile, please install the third-party plugin <a href='https://obsidian.md/plugins?search=Logstravaganza'>Logstravaganza</a> to export the console log to a note.",
"settings_syncplans": "Export Sync Plans", "settings_syncplans": "Export Sync Plans",
"settings_syncplans_desc": "Sync plans are created every time after you trigger sync and before the actual sync. Useful to know what would actually happen in those sync. Click the button to export sync plans.", "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_1_only_change": "Export latest 1 (change part)", "settings_syncplans_button_1_only_change": "Export latest 1 (change part)",
@@ -361,6 +361,8 @@
"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_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_notice": "Profiler results exported.",
"settings_profiler_results_button_all": "Export All", "settings_profiler_results_button_all": "Export All",
"settings_profiler_enableprofiler": "Enable Profiler",
"settings_profiler_enableprofiler_desc": "Collect performance data or not?",
"settings_profiler_enabledebugprint": "Enable Profiler Printing", "settings_profiler_enabledebugprint": "Enable Profiler Printing",
"settings_profiler_enabledebugprint_desc": "Print profiler result in each insertion to console or not?", "settings_profiler_enabledebugprint_desc": "Print profiler result in each insertion to console or not?",
"settings_profiler_recordsize": "Enable Profiler Recording Size", "settings_profiler_recordsize": "Enable Profiler Recording Size",
+6
View File
@@ -360,6 +360,12 @@
"settings_profiler_results_desc": "插件记录了每次同步每一步的耗时。这里可以导出记录得知哪一步最慢。", "settings_profiler_results_desc": "插件记录了每次同步每一步的耗时。这里可以导出记录得知哪一步最慢。",
"settings_profiler_results_notice": "性能数据已导出", "settings_profiler_results_notice": "性能数据已导出",
"settings_profiler_results_button_all": "导出所有", "settings_profiler_results_button_all": "导出所有",
"settings_profiler_enableprofiler": "性能收集",
"settings_profiler_enableprofiler_desc": "是否开启性能收集功能?",
"settings_profiler_enabledebugprint": "性能收集输出",
"settings_profiler_enabledebugprint_desc": "是否直接输出性能收集结果到终端里?",
"settings_profiler_recordsize": "性能收集统计对象大小",
"settings_profiler_recordsize_desc": "是否收集对象的大小?",
"settings_outputbasepathvaultid": "输出资料库对应的位置和随机分配的 ID", "settings_outputbasepathvaultid": "输出资料库对应的位置和随机分配的 ID",
"settings_outputbasepathvaultid_desc": "用于调试。", "settings_outputbasepathvaultid_desc": "用于调试。",
"settings_outputbasepathvaultid_button": "输出", "settings_outputbasepathvaultid_button": "输出",
+6
View File
@@ -359,6 +359,12 @@
"settings_profiler_results_desc": "外掛記錄了每次同步每一步的耗時。這裡可以匯出記錄得知哪一步最慢。", "settings_profiler_results_desc": "外掛記錄了每次同步每一步的耗時。這裡可以匯出記錄得知哪一步最慢。",
"settings_profiler_results_notice": "效能資料已匯出", "settings_profiler_results_notice": "效能資料已匯出",
"settings_profiler_results_button_all": "匯出所有", "settings_profiler_results_button_all": "匯出所有",
"settings_profiler_enableprofiler": "效能收集",
"settings_profiler_enableprofiler_desc": "是否開啟效能收集功能?",
"settings_profiler_enabledebugprint": "效能收集輸出",
"settings_profiler_enabledebugprint_desc": "是否直接輸出效能收集結果到終端裡?",
"settings_profiler_recordsize": "效能收集統計物件大小",
"settings_profiler_recordsize_desc": "是否收集物件的大小?",
"settings_outputbasepathvaultid": "輸出資料庫對應的位置和隨機分配的 ID", "settings_outputbasepathvaultid": "輸出資料庫對應的位置和隨機分配的 ID",
"settings_outputbasepathvaultid_desc": "用於除錯。", "settings_outputbasepathvaultid_desc": "用於除錯。",
"settings_outputbasepathvaultid_button": "輸出", "settings_outputbasepathvaultid_button": "輸出",
+20 -9
View File
@@ -1,5 +1,3 @@
// biome-ignore lint/suspicious/noShadowRestrictedNames: <explanation>
import AggregateError from "aggregate-error";
import cloneDeep from "lodash/cloneDeep"; import cloneDeep from "lodash/cloneDeep";
import throttle from "lodash/throttle"; import throttle from "lodash/throttle";
import { FileText, RefreshCcw, RotateCcw, createElement } from "lucide"; import { FileText, RefreshCcw, RotateCcw, createElement } from "lucide";
@@ -108,7 +106,7 @@ import {
upsertPluginVersionByVault, upsertPluginVersionByVault,
} from "./localdb"; } from "./localdb";
import { changeMobileStatusBar } from "./misc"; import { changeMobileStatusBar } from "./misc";
import { DEFAULT_PROFILER_CONFIG, type Profiler } from "./profiler"; import { DEFAULT_PROFILER_CONFIG, Profiler } from "./profiler";
import { RemotelySaveSettingTab } from "./settings"; import { RemotelySaveSettingTab } from "./settings";
import { SyncAlgoV3Modal } from "./syncAlgoV3Notice"; import { SyncAlgoV3Modal } from "./syncAlgoV3Notice";
@@ -232,12 +230,14 @@ export default class RemotelySavePlugin extends Plugin {
appContainerObserver?: MutationObserver; appContainerObserver?: MutationObserver;
async syncRun(triggerSource: SyncTriggerSourceType = "manual") { async syncRun(triggerSource: SyncTriggerSourceType = "manual") {
// const profiler = new Profiler( let profiler: Profiler | undefined = undefined;
// undefined, if (this.settings.profiler?.enable ?? false) {
// this.settings.profiler?.enablePrinting ?? false, profiler = new Profiler(
// this.settings.profiler?.recordSize ?? false undefined,
// ); this.settings.profiler?.enablePrinting ?? false,
const profiler: Profiler | undefined = undefined; this.settings.profiler?.recordSize ?? false
);
}
const fsLocal = new FakeFsLocal( const fsLocal = new FakeFsLocal(
this.app.vault, this.app.vault,
this.settings.syncConfigDir ?? false, this.settings.syncConfigDir ?? false,
@@ -1471,6 +1471,9 @@ export default class RemotelySavePlugin extends Plugin {
if (this.settings.profiler === undefined) { if (this.settings.profiler === undefined) {
this.settings.profiler = DEFAULT_PROFILER_CONFIG; this.settings.profiler = DEFAULT_PROFILER_CONFIG;
} }
if (this.settings.profiler.enable === undefined) {
this.settings.profiler.enable = false;
}
if (this.settings.profiler.enablePrinting === undefined) { if (this.settings.profiler.enablePrinting === undefined) {
this.settings.profiler.enablePrinting = false; this.settings.profiler.enablePrinting = false;
} }
@@ -1555,6 +1558,7 @@ export default class RemotelySavePlugin extends Plugin {
this.settings.dropbox.refreshToken !== "" && this.settings.dropbox.refreshToken !== "" &&
current >= this.settings!.dropbox!.credentialsShouldBeDeletedAtTime! current >= this.settings!.dropbox!.credentialsShouldBeDeletedAtTime!
) { ) {
console.warn(`dropbox expired`);
dropboxExpired = true; dropboxExpired = true;
this.settings.dropbox = cloneDeep(DEFAULT_DROPBOX_CONFIG); this.settings.dropbox = cloneDeep(DEFAULT_DROPBOX_CONFIG);
needSave = true; needSave = true;
@@ -1565,6 +1569,7 @@ export default class RemotelySavePlugin extends Plugin {
this.settings.onedrive.refreshToken !== "" && this.settings.onedrive.refreshToken !== "" &&
current >= this.settings!.onedrive!.credentialsShouldBeDeletedAtTime! current >= this.settings!.onedrive!.credentialsShouldBeDeletedAtTime!
) { ) {
console.warn(`onedrive expired`);
onedriveExpired = true; onedriveExpired = true;
this.settings.onedrive = cloneDeep(DEFAULT_ONEDRIVE_CONFIG); this.settings.onedrive = cloneDeep(DEFAULT_ONEDRIVE_CONFIG);
needSave = true; needSave = true;
@@ -1575,6 +1580,7 @@ export default class RemotelySavePlugin extends Plugin {
this.settings.onedrivefull.refreshToken !== "" && this.settings.onedrivefull.refreshToken !== "" &&
current >= this.settings!.onedrivefull!.credentialsShouldBeDeletedAtTime! current >= this.settings!.onedrivefull!.credentialsShouldBeDeletedAtTime!
) { ) {
console.warn(`onedrive full expired`);
onedriveFullExpired = true; onedriveFullExpired = true;
this.settings.onedrivefull = cloneDeep(DEFAULT_ONEDRIVEFULL_CONFIG); this.settings.onedrivefull = cloneDeep(DEFAULT_ONEDRIVEFULL_CONFIG);
needSave = true; needSave = true;
@@ -1585,6 +1591,7 @@ export default class RemotelySavePlugin extends Plugin {
this.settings.googledrive.refreshToken !== "" && this.settings.googledrive.refreshToken !== "" &&
current >= this.settings!.googledrive!.credentialsShouldBeDeletedAtTimeMs! current >= this.settings!.googledrive!.credentialsShouldBeDeletedAtTimeMs!
) { ) {
console.warn(`google drive expired`);
googleDriveExpired = true; googleDriveExpired = true;
this.settings.googledrive = cloneDeep(DEFAULT_GOOGLEDRIVE_CONFIG); this.settings.googledrive = cloneDeep(DEFAULT_GOOGLEDRIVE_CONFIG);
needSave = true; needSave = true;
@@ -1595,6 +1602,7 @@ export default class RemotelySavePlugin extends Plugin {
this.settings.box.refreshToken !== "" && this.settings.box.refreshToken !== "" &&
current >= this.settings!.box!.credentialsShouldBeDeletedAtTimeMs! current >= this.settings!.box!.credentialsShouldBeDeletedAtTimeMs!
) { ) {
console.warn(`box expired`);
boxExpired = true; boxExpired = true;
this.settings.box = cloneDeep(DEFAULT_BOX_CONFIG); this.settings.box = cloneDeep(DEFAULT_BOX_CONFIG);
needSave = true; needSave = true;
@@ -1605,6 +1613,7 @@ export default class RemotelySavePlugin extends Plugin {
this.settings.pcloud.accessToken !== "" && this.settings.pcloud.accessToken !== "" &&
current >= this.settings!.pcloud!.credentialsShouldBeDeletedAtTimeMs! current >= this.settings!.pcloud!.credentialsShouldBeDeletedAtTimeMs!
) { ) {
console.warn(`pcloud expired`);
pCloudExpired = true; pCloudExpired = true;
this.settings.pcloud = cloneDeep(DEFAULT_PCLOUD_CONFIG); this.settings.pcloud = cloneDeep(DEFAULT_PCLOUD_CONFIG);
needSave = true; needSave = true;
@@ -1615,6 +1624,7 @@ export default class RemotelySavePlugin extends Plugin {
this.settings.yandexdisk.refreshToken !== "" && this.settings.yandexdisk.refreshToken !== "" &&
current >= this.settings!.yandexdisk!.credentialsShouldBeDeletedAtTimeMs! current >= this.settings!.yandexdisk!.credentialsShouldBeDeletedAtTimeMs!
) { ) {
console.warn(`yandex disk expired`);
yandexDiskExpired = true; yandexDiskExpired = true;
this.settings.yandexdisk = cloneDeep(DEFAULT_YANDEXDISK_CONFIG); this.settings.yandexdisk = cloneDeep(DEFAULT_YANDEXDISK_CONFIG);
needSave = true; needSave = true;
@@ -1625,6 +1635,7 @@ export default class RemotelySavePlugin extends Plugin {
this.settings.koofr.refreshToken !== "" && this.settings.koofr.refreshToken !== "" &&
current >= this.settings!.koofr!.credentialsShouldBeDeletedAtTimeMs! current >= this.settings!.koofr!.credentialsShouldBeDeletedAtTimeMs!
) { ) {
console.warn(`koofr expired`);
koofrExpired = true; koofrExpired = true;
this.settings.koofr = cloneDeep(DEFAULT_KOOFR_CONFIG); this.settings.koofr = cloneDeep(DEFAULT_KOOFR_CONFIG);
needSave = true; needSave = true;
+7 -4
View File
@@ -88,9 +88,12 @@ export const mkdirpInVault = async (thePath: string, vault: Vault) => {
* @returns ArrayBuffer * @returns ArrayBuffer
*/ */
export const bufferToArrayBuffer = ( export const bufferToArrayBuffer = (
b: Buffer | Uint8Array | ArrayBufferView b: Buffer | Uint8Array<ArrayBuffer> | ArrayBufferView
) => { ) => {
return b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength); return b.buffer.slice(
b.byteOffset,
b.byteOffset + b.byteLength
) as ArrayBuffer;
}; };
/** /**
@@ -279,7 +282,7 @@ export const reverseString = (x: string) => {
}; };
export interface SplitRange { export interface SplitRange {
partNum: number; // startting from 1 partNum: number; // starting from 1
start: number; start: number;
end: number; // exclusive end: number; // exclusive
} }
@@ -316,7 +319,7 @@ export const getTypeName = (obj: any) => {
}; };
/** /**
* Startting from 1 * starting from 1
* @param x * @param x
* @returns * @returns
*/ */
+1
View File
@@ -10,6 +10,7 @@ interface BreakPoint {
} }
export const DEFAULT_PROFILER_CONFIG: ProfilerConfig = { export const DEFAULT_PROFILER_CONFIG: ProfilerConfig = {
enable: false,
enablePrinting: false, enablePrinting: false,
recordSize: false, recordSize: false,
}; };
+19
View File
@@ -2940,6 +2940,25 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
}); });
}); });
new Setting(debugDiv)
.setName(t("settings_profiler_enableprofiler"))
.setDesc(t("settings_profiler_enableprofiler_desc"))
.addDropdown((dropdown) => {
dropdown.addOption("enable", t("enable"));
dropdown.addOption("disable", t("disable"));
dropdown
.setValue(
this.plugin.settings.profiler?.enable ? "enable" : "disable"
)
.onChange(async (val: string) => {
if (this.plugin.settings.profiler === undefined) {
this.plugin.settings.profiler = DEFAULT_PROFILER_CONFIG;
}
this.plugin.settings.profiler.enable = val === "enable";
await this.plugin.saveSettings();
});
});
new Setting(debugDiv) new Setting(debugDiv)
.setName(t("settings_profiler_enabledebugprint")) .setName(t("settings_profiler_enabledebugprint"))
.setDesc(t("settings_profiler_enabledebugprint_desc")) .setDesc(t("settings_profiler_enabledebugprint_desc"))
+2 -2
View File
@@ -8,13 +8,13 @@
"strict": true, "strict": true,
"allowJs": true, "allowJs": true,
"noImplicitAny": true, "noImplicitAny": true,
"moduleResolution": "node", "moduleResolution": "bundler",
// "allowSyntheticDefaultImports": true, // "allowSyntheticDefaultImports": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"esModuleInterop": true, "esModuleInterop": true,
"importHelpers": true, "importHelpers": true,
"isolatedModules": true, "isolatedModules": true,
"lib": ["dom", "es5", "scripthost", "es2015", "webworker"] "lib": ["dom", "es5", "scripthost", "es2015", "es2021", "webworker"]
}, },
"include": ["src/global.d.ts", "**/*.ts"] "include": ["src/global.d.ts", "**/*.ts"]
} }
+5
View File
@@ -65,6 +65,11 @@ module.exports = {
new webpack.ProvidePlugin({ new webpack.ProvidePlugin({
process: "process/browser", process: "process/browser",
}), }),
// Strip `node:` URI prefix so resolve.fallback (browserify shims) applies.
// Required because some deps (AWS smithy, glob, ...) use `node:url` etc.
new webpack.NormalModuleReplacementPlugin(/^node:/, (resource) => {
resource.request = resource.request.replace(/^node:/, "");
}),
], ],
module: { module: {
rules: [ rules: [