mirror of
https://github.com/naipi11/onedrive-bidirectional-sync.git
synced 2026-07-22 07:48:31 +00:00
Initial OneDrive bidirectional sync plugin
This commit is contained in:
commit
7dd449b34c
18 changed files with 1371 additions and 0 deletions
31
.github/workflows/release.yml
vendored
Normal file
31
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
name: Release Obsidian plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm build
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
main.js
|
||||
manifest.json
|
||||
versions.json
|
||||
23
.github/workflows/validate.yml
vendored
Normal file
23
.github/workflows/validate.yml
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
name: Validate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm build
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
.pnpm-store/
|
||||
main.js
|
||||
*.map
|
||||
25
COMMUNITY_SUBMISSION.md
Normal file
25
COMMUNITY_SUBMISSION.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Community plugin submission checklist
|
||||
|
||||
Repository target: `naipi11/onedrive-bidirectional-sync`
|
||||
|
||||
Before submitting:
|
||||
|
||||
- MIT license added.
|
||||
- Test sign-in, upload, download, deletion, and conflicts with a real OneDrive account.
|
||||
- Test on desktop and at least one mobile platform, including iPadOS.
|
||||
- Create a public GitHub repository and push this source tree.
|
||||
- Create the `0.1.0` tag. The release workflow will attach `main.js`, `manifest.json`, and `versions.json`.
|
||||
- Fork `obsidianmd/obsidian-releases`.
|
||||
- Add the following entry to `community-plugins.json` and open a pull request:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "onedrive-bidirectional-sync",
|
||||
"name": "OneDrive Bidirectional Sync",
|
||||
"author": "naipi11",
|
||||
"description": "Synchronize vault files across devices through a private OneDrive app folder.",
|
||||
"repo": "naipi11/onedrive-bidirectional-sync"
|
||||
}
|
||||
```
|
||||
|
||||
Do not submit until the test claims above are true.
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 naipi11
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
46
README.md
Normal file
46
README.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# OneDrive Bidirectional Sync
|
||||
|
||||
[MIT License](LICENSE)
|
||||
|
||||
一个不依赖本地 OneDrive 客户端的 Obsidian 双向同步插件 MVP,可运行于 Windows、Linux、macOS、iOS 和 Android。
|
||||
|
||||
## 工作方式
|
||||
|
||||
- 通过 Microsoft Graph 直接访问 OneDrive。
|
||||
- 仅申请 `Files.ReadWrite.AppFolder` 权限,远端数据位于 `OneDrive/Apps/<应用名称>/vaults/<库 ID>/`。
|
||||
- 使用本机同步快照判断文件在本地或远端是否变化。
|
||||
- 两端同时修改同一文件时,保留一个带“本地冲突”后缀的副本,并将远端版本写入原路径。
|
||||
- 默认不同步 `.obsidian`,避免插件自身令牌和设备工作区配置被同步。
|
||||
|
||||
## 安装与构建
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
将 `manifest.json`、`main.js` 复制到库的 `.obsidian/plugins/onedrive-bidirectional-sync/`,然后在 Obsidian 中启用插件。
|
||||
|
||||
## Microsoft Entra 应用注册
|
||||
|
||||
1. 在 Microsoft Entra 管理中心创建应用注册。
|
||||
2. “支持的账户类型”选择同时支持组织目录与个人 Microsoft 账户。
|
||||
3. 在“身份验证”中启用“允许公共客户端流”。
|
||||
4. 添加 Microsoft Graph 委托权限 `Files.ReadWrite.AppFolder`;`offline_access` 会在登录时请求。
|
||||
5. 将 Application (client) ID 填入插件设置。
|
||||
|
||||
所有设备必须填写相同的客户端 ID、登录同一个 Microsoft 账户,并填写相同的库 ID。
|
||||
|
||||
## 当前限制
|
||||
|
||||
- 单文件上传使用 Graph 简单上传接口,最大支持 250 MB。
|
||||
- 初始同步按修改时间决定同名文件方向;建议先在主设备上传,再连接其他设备。
|
||||
- iOS 和 Android 会限制后台运行,打开 Obsidian 后或手动执行同步更可靠。
|
||||
- 令牌保存在 Obsidian 插件数据文件中。不要同步或分享插件自身的 `data.json`。
|
||||
- 这是 MVP,尚未实现 Graph delta 增量扫描、分块上传、端到端加密和自动化测试。
|
||||
|
||||
## 官方接口文档
|
||||
|
||||
- [Microsoft Graph OneDrive app folder](https://learn.microsoft.com/graph/onedrive-sharepoint-appfolder)
|
||||
- [Microsoft identity platform device authorization grant](https://learn.microsoft.com/entra/identity-platform/v2-oauth2-device-code)
|
||||
- [Microsoft Graph upload or replace file contents](https://learn.microsoft.com/graph/api/driveitem-put-content)
|
||||
25
esbuild.config.mjs
Normal file
25
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
|
||||
const production = process.argv[2] === "production";
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: { js: "/* OneDrive Bidirectional Sync */" },
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
external: ["obsidian", "electron", ...builtins],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
sourcemap: production ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js"
|
||||
});
|
||||
|
||||
if (production) {
|
||||
await context.rebuild();
|
||||
await context.dispose();
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
10
manifest.json
Normal file
10
manifest.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "onedrive-bidirectional-sync",
|
||||
"name": "OneDrive Bidirectional Sync",
|
||||
"version": "0.1.0",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Synchronize vault files across devices through a private OneDrive app folder.",
|
||||
"author": "naipi11",
|
||||
"authorUrl": "https://github.com/naipi11",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
17
package.json
Normal file
17
package.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "obsidian-onedrive-bidirectional-sync",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "node esbuild.config.mjs production",
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"builtin-modules": "^4.0.0",
|
||||
"esbuild": "^0.24.2",
|
||||
"obsidian": "^1.7.7",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
386
pnpm-lock.yaml
Normal file
386
pnpm-lock.yaml
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.10.0
|
||||
version: 22.19.21
|
||||
builtin-modules:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
esbuild:
|
||||
specifier: ^0.24.2
|
||||
version: 0.24.2
|
||||
obsidian:
|
||||
specifier: ^1.7.7
|
||||
version: 1.13.1(@codemirror/state@6.5.0)(@codemirror/view@6.38.6)
|
||||
typescript:
|
||||
specifier: ^5.7.2
|
||||
version: 5.9.3
|
||||
|
||||
packages:
|
||||
|
||||
'@codemirror/state@6.5.0':
|
||||
resolution: {integrity: sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==}
|
||||
|
||||
'@codemirror/view@6.38.6':
|
||||
resolution: {integrity: sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==}
|
||||
|
||||
'@esbuild/aix-ppc64@0.24.2':
|
||||
resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.24.2':
|
||||
resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.24.2':
|
||||
resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.24.2':
|
||||
resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.24.2':
|
||||
resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.24.2':
|
||||
resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.24.2':
|
||||
resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.24.2':
|
||||
resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.24.2':
|
||||
resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.24.2':
|
||||
resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.24.2':
|
||||
resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.24.2':
|
||||
resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.24.2':
|
||||
resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.24.2':
|
||||
resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.24.2':
|
||||
resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.24.2':
|
||||
resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.24.2':
|
||||
resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.24.2':
|
||||
resolution: {integrity: sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.24.2':
|
||||
resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.24.2':
|
||||
resolution: {integrity: sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.24.2':
|
||||
resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/sunos-x64@0.24.2':
|
||||
resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.24.2':
|
||||
resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.24.2':
|
||||
resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.24.2':
|
||||
resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@marijn/find-cluster-break@1.0.2':
|
||||
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
|
||||
|
||||
'@types/codemirror@5.60.8':
|
||||
resolution: {integrity: sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==}
|
||||
|
||||
'@types/estree@1.0.9':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
'@types/node@22.19.21':
|
||||
resolution: {integrity: sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==}
|
||||
|
||||
'@types/tern@0.23.9':
|
||||
resolution: {integrity: sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==}
|
||||
|
||||
builtin-modules@4.0.0:
|
||||
resolution: {integrity: sha512-p1n8zyCkt1BVrKNFymOHjcDSAl7oq/gUvfgULv2EblgpPVQlQr9yHnWjg9IJ2MhfwPqiYqMMrr01OY7yQoK2yA==}
|
||||
engines: {node: '>=18.20'}
|
||||
|
||||
crelt@1.0.6:
|
||||
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||
|
||||
esbuild@0.24.2:
|
||||
resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
moment@2.29.4:
|
||||
resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==}
|
||||
|
||||
obsidian@1.13.1:
|
||||
resolution: {integrity: sha512-qtTEA2pmhJzhuhJqzbBFRYhpIOqvW+krDYjtFynv66KbxBbumHBlsJfWw3I4jtnK/6fZwbQhCrmmDdRwXmX56w==}
|
||||
peerDependencies:
|
||||
'@codemirror/state': 6.5.0
|
||||
'@codemirror/view': 6.38.6
|
||||
|
||||
style-mod@4.1.3:
|
||||
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
w3c-keyname@2.2.8:
|
||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@codemirror/state@6.5.0':
|
||||
dependencies:
|
||||
'@marijn/find-cluster-break': 1.0.2
|
||||
|
||||
'@codemirror/view@6.38.6':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.0
|
||||
crelt: 1.0.6
|
||||
style-mod: 4.1.3
|
||||
w3c-keyname: 2.2.8
|
||||
|
||||
'@esbuild/aix-ppc64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.24.2':
|
||||
optional: true
|
||||
|
||||
'@marijn/find-cluster-break@1.0.2': {}
|
||||
|
||||
'@types/codemirror@5.60.8':
|
||||
dependencies:
|
||||
'@types/tern': 0.23.9
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/node@22.19.21':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/tern@0.23.9':
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
|
||||
builtin-modules@4.0.0: {}
|
||||
|
||||
crelt@1.0.6: {}
|
||||
|
||||
esbuild@0.24.2:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.24.2
|
||||
'@esbuild/android-arm': 0.24.2
|
||||
'@esbuild/android-arm64': 0.24.2
|
||||
'@esbuild/android-x64': 0.24.2
|
||||
'@esbuild/darwin-arm64': 0.24.2
|
||||
'@esbuild/darwin-x64': 0.24.2
|
||||
'@esbuild/freebsd-arm64': 0.24.2
|
||||
'@esbuild/freebsd-x64': 0.24.2
|
||||
'@esbuild/linux-arm': 0.24.2
|
||||
'@esbuild/linux-arm64': 0.24.2
|
||||
'@esbuild/linux-ia32': 0.24.2
|
||||
'@esbuild/linux-loong64': 0.24.2
|
||||
'@esbuild/linux-mips64el': 0.24.2
|
||||
'@esbuild/linux-ppc64': 0.24.2
|
||||
'@esbuild/linux-riscv64': 0.24.2
|
||||
'@esbuild/linux-s390x': 0.24.2
|
||||
'@esbuild/linux-x64': 0.24.2
|
||||
'@esbuild/netbsd-arm64': 0.24.2
|
||||
'@esbuild/netbsd-x64': 0.24.2
|
||||
'@esbuild/openbsd-arm64': 0.24.2
|
||||
'@esbuild/openbsd-x64': 0.24.2
|
||||
'@esbuild/sunos-x64': 0.24.2
|
||||
'@esbuild/win32-arm64': 0.24.2
|
||||
'@esbuild/win32-ia32': 0.24.2
|
||||
'@esbuild/win32-x64': 0.24.2
|
||||
|
||||
moment@2.29.4: {}
|
||||
|
||||
obsidian@1.13.1(@codemirror/state@6.5.0)(@codemirror/view@6.38.6):
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.0
|
||||
'@codemirror/view': 6.38.6
|
||||
'@types/codemirror': 5.60.8
|
||||
moment: 2.29.4
|
||||
|
||||
style-mod@4.1.3: {}
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
allowBuilds:
|
||||
esbuild: true
|
||||
132
src/auth.ts
Normal file
132
src/auth.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { requestUrl } from "obsidian";
|
||||
import type { TokenState } from "./types";
|
||||
|
||||
const SCOPE = "offline_access Files.ReadWrite.AppFolder";
|
||||
|
||||
interface DeviceCodeResponse {
|
||||
device_code: string;
|
||||
user_code: string;
|
||||
verification_uri: string;
|
||||
expires_in: number;
|
||||
interval: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
interface OAuthError {
|
||||
error: string;
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
export class MicrosoftAuth {
|
||||
constructor(
|
||||
private readonly clientId: string,
|
||||
private readonly tenant: string,
|
||||
private token: TokenState | null,
|
||||
private readonly onToken: (token: TokenState) => Promise<void>
|
||||
) {}
|
||||
|
||||
get signedIn(): boolean {
|
||||
return this.token !== null;
|
||||
}
|
||||
|
||||
async beginDeviceCode(): Promise<DeviceCodeResponse> {
|
||||
const response = await requestUrl({
|
||||
url: `${this.baseUrl}/devicecode`,
|
||||
method: "POST",
|
||||
contentType: "application/x-www-form-urlencoded",
|
||||
body: form({ client_id: this.clientId, scope: SCOPE }),
|
||||
throw: false
|
||||
});
|
||||
if (response.status !== 200) throw new Error(readOAuthError(response.json));
|
||||
return response.json as DeviceCodeResponse;
|
||||
}
|
||||
|
||||
async finishDeviceCode(code: DeviceCodeResponse): Promise<void> {
|
||||
const deadline = Date.now() + code.expires_in * 1000;
|
||||
let interval = Math.max(code.interval, 5) * 1000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(interval);
|
||||
const response = await requestUrl({
|
||||
url: `${this.baseUrl}/token`,
|
||||
method: "POST",
|
||||
contentType: "application/x-www-form-urlencoded",
|
||||
body: form({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
client_id: this.clientId,
|
||||
device_code: code.device_code
|
||||
}),
|
||||
throw: false
|
||||
});
|
||||
if (response.status === 200) {
|
||||
await this.acceptToken(response.json as TokenResponse);
|
||||
return;
|
||||
}
|
||||
const error = response.json as OAuthError;
|
||||
if (error.error === "authorization_pending") continue;
|
||||
if (error.error === "slow_down") {
|
||||
interval += 5000;
|
||||
continue;
|
||||
}
|
||||
throw new Error(readOAuthError(error));
|
||||
}
|
||||
throw new Error("登录代码已过期,请重试");
|
||||
}
|
||||
|
||||
async accessToken(): Promise<string> {
|
||||
if (!this.token) throw new Error("尚未登录 Microsoft 账户");
|
||||
if (this.token.expiresAt > Date.now() + 60_000) return this.token.accessToken;
|
||||
|
||||
const response = await requestUrl({
|
||||
url: `${this.baseUrl}/token`,
|
||||
method: "POST",
|
||||
contentType: "application/x-www-form-urlencoded",
|
||||
body: form({
|
||||
client_id: this.clientId,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: this.token.refreshToken,
|
||||
scope: SCOPE
|
||||
}),
|
||||
throw: false
|
||||
});
|
||||
if (response.status !== 200) throw new Error(readOAuthError(response.json));
|
||||
await this.acceptToken(response.json as TokenResponse);
|
||||
return this.token!.accessToken;
|
||||
}
|
||||
|
||||
private get baseUrl(): string {
|
||||
return `https://login.microsoftonline.com/${encodeURIComponent(this.tenant || "common")}/oauth2/v2.0`;
|
||||
}
|
||||
|
||||
private async acceptToken(response: TokenResponse): Promise<void> {
|
||||
const refreshToken = response.refresh_token ?? this.token?.refreshToken;
|
||||
if (!refreshToken) throw new Error("Microsoft 未返回刷新令牌");
|
||||
this.token = {
|
||||
accessToken: response.access_token,
|
||||
refreshToken,
|
||||
expiresAt: Date.now() + response.expires_in * 1000
|
||||
};
|
||||
await this.onToken(this.token);
|
||||
}
|
||||
}
|
||||
|
||||
function form(values: Record<string, string>): string {
|
||||
return Object.entries(values)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join("&");
|
||||
}
|
||||
|
||||
function readOAuthError(value: unknown): string {
|
||||
const error = value as OAuthError | undefined;
|
||||
return error?.error_description ?? error?.error ?? "Microsoft 登录请求失败";
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
138
src/graph.ts
Normal file
138
src/graph.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { requestUrl } from "obsidian";
|
||||
import type { RemoteItem } from "./types";
|
||||
import { MicrosoftAuth } from "./auth";
|
||||
|
||||
const GRAPH = "https://graph.microsoft.com/v1.0";
|
||||
|
||||
interface DriveItemJson {
|
||||
id: string;
|
||||
name: string;
|
||||
eTag?: string;
|
||||
size?: number;
|
||||
lastModifiedDateTime?: string;
|
||||
folder?: { childCount: number };
|
||||
}
|
||||
|
||||
interface ChildrenResponse {
|
||||
value: DriveItemJson[];
|
||||
"@odata.nextLink"?: string;
|
||||
}
|
||||
|
||||
export class GraphClient {
|
||||
constructor(private readonly auth: MicrosoftAuth) {}
|
||||
|
||||
async ensureVaultFolder(vaultId: string): Promise<DriveItemJson> {
|
||||
const appRoot = await this.json<DriveItemJson>("GET", "/me/drive/special/approot");
|
||||
const vaults = await this.ensureChildFolder(appRoot.id, "vaults");
|
||||
return this.ensureChildFolder(vaults.id, vaultId);
|
||||
}
|
||||
|
||||
async listTree(root: DriveItemJson): Promise<Map<string, RemoteItem>> {
|
||||
const result = new Map<string, RemoteItem>();
|
||||
await this.walk(root.id, "", result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async download(itemId: string): Promise<ArrayBuffer> {
|
||||
const response = await this.request("GET", `/me/drive/items/${itemId}/content`);
|
||||
if (response.status < 200 || response.status >= 300) throw graphError(response.status, response.json);
|
||||
return response.arrayBuffer;
|
||||
}
|
||||
|
||||
async upload(rootId: string, path: string, data: ArrayBuffer): Promise<RemoteItem> {
|
||||
const parts = splitPath(path);
|
||||
const name = parts.pop();
|
||||
if (!name) throw new Error(`无效路径: ${path}`);
|
||||
const parentId = await this.ensureFolderPath(rootId, parts);
|
||||
const item = await this.json<DriveItemJson>(
|
||||
"PUT",
|
||||
`/me/drive/items/${parentId}:/${encodeURIComponent(name)}:/content`,
|
||||
data,
|
||||
"application/octet-stream"
|
||||
);
|
||||
return toRemote(item, path);
|
||||
}
|
||||
|
||||
async delete(itemId: string): Promise<void> {
|
||||
const response = await this.request("DELETE", `/me/drive/items/${itemId}`);
|
||||
if (response.status < 200 || response.status >= 300) throw graphError(response.status, response.json);
|
||||
}
|
||||
|
||||
private async walk(parentId: string, parentPath: string, result: Map<string, RemoteItem>): Promise<void> {
|
||||
let url: string | undefined = `/me/drive/items/${parentId}/children?$top=200`;
|
||||
while (url) {
|
||||
const page: ChildrenResponse = await this.json<ChildrenResponse>("GET", url);
|
||||
for (const item of page.value) {
|
||||
const path = parentPath ? `${parentPath}/${item.name}` : item.name;
|
||||
if (item.folder) await this.walk(item.id, path, result);
|
||||
else result.set(path, toRemote(item, path));
|
||||
}
|
||||
url = page["@odata.nextLink"];
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureFolderPath(rootId: string, parts: string[]): Promise<string> {
|
||||
let currentId = rootId;
|
||||
for (const part of parts) currentId = (await this.ensureChildFolder(currentId, part)).id;
|
||||
return currentId;
|
||||
}
|
||||
|
||||
private async ensureChildFolder(parentId: string, name: string): Promise<DriveItemJson> {
|
||||
const response = await this.request(
|
||||
"POST",
|
||||
`/me/drive/items/${parentId}/children`,
|
||||
JSON.stringify({ name, folder: {}, "@microsoft.graph.conflictBehavior": "fail" }),
|
||||
"application/json"
|
||||
);
|
||||
if (response.status >= 200 && response.status < 300) return response.json as DriveItemJson;
|
||||
if (response.status !== 409) throw graphError(response.status, response.json);
|
||||
|
||||
let url: string | undefined = `/me/drive/items/${parentId}/children?$select=id,name,folder&$top=200`;
|
||||
while (url) {
|
||||
const page: ChildrenResponse = await this.json<ChildrenResponse>("GET", url);
|
||||
const found = page.value.find((item) => item.name === name && item.folder);
|
||||
if (found) return found;
|
||||
url = page["@odata.nextLink"];
|
||||
}
|
||||
throw new Error(`无法找到远端目录: ${name}`);
|
||||
}
|
||||
|
||||
private async json<T>(method: string, pathOrUrl: string, body?: string | ArrayBuffer, contentType?: string): Promise<T> {
|
||||
const response = await this.request(method, pathOrUrl, body, contentType);
|
||||
if (response.status < 200 || response.status >= 300) throw graphError(response.status, response.json);
|
||||
return response.json as T;
|
||||
}
|
||||
|
||||
private async request(method: string, pathOrUrl: string, body?: string | ArrayBuffer, contentType?: string) {
|
||||
const token = await this.auth.accessToken();
|
||||
return requestUrl({
|
||||
url: pathOrUrl.startsWith("http") ? pathOrUrl : `${GRAPH}${pathOrUrl}`,
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
contentType,
|
||||
body,
|
||||
throw: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toRemote(item: DriveItemJson, path: string): RemoteItem {
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
path,
|
||||
eTag: item.eTag ?? "",
|
||||
mtime: Date.parse(item.lastModifiedDateTime ?? "") || 0,
|
||||
size: item.size ?? 0,
|
||||
folder: Boolean(item.folder)
|
||||
};
|
||||
}
|
||||
|
||||
function splitPath(path: string): string[] {
|
||||
return path.split("/").filter(Boolean);
|
||||
}
|
||||
|
||||
function graphError(status: number, value: unknown): Error {
|
||||
const graph = value as { error?: { message?: string; code?: string } } | undefined;
|
||||
return new Error(`OneDrive 请求失败 (${status}): ${graph?.error?.message ?? graph?.error?.code ?? "未知错误"}`);
|
||||
}
|
||||
223
src/main.ts
Normal file
223
src/main.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import { App, Modal, Notice, Plugin, PluginSettingTab, Setting } from "obsidian";
|
||||
import { MicrosoftAuth } from "./auth";
|
||||
import { GraphClient } from "./graph";
|
||||
import { SyncEngine } from "./sync";
|
||||
import type { SyncSettings, TokenState } from "./types";
|
||||
|
||||
const DEFAULT_SETTINGS: SyncSettings = {
|
||||
clientId: "",
|
||||
tenant: "common",
|
||||
vaultId: "",
|
||||
intervalMinutes: 10,
|
||||
syncOnStartup: true,
|
||||
syncConfigDir: false,
|
||||
excludedPatterns: ".trash/**\n.trash/",
|
||||
token: null,
|
||||
entries: {},
|
||||
lastSyncAt: 0
|
||||
};
|
||||
|
||||
export default class OneDriveSyncPlugin extends Plugin {
|
||||
declare settings: SyncSettings;
|
||||
private intervalId: number | null = null;
|
||||
private syncInProgress = false;
|
||||
|
||||
async onload(): Promise<void> {
|
||||
await this.loadSettings();
|
||||
this.addSettingTab(new OneDriveSyncSettingTab(this.app, this));
|
||||
this.addCommand({ id: "sync-now", name: "立即同步", callback: () => void this.syncNow() });
|
||||
this.addRibbonIcon("refresh-cw", "OneDrive 双向同步", () => void this.syncNow());
|
||||
this.configureTimer();
|
||||
|
||||
if (this.settings.syncOnStartup && this.settings.token && this.settings.clientId) {
|
||||
this.app.workspace.onLayoutReady(() => window.setTimeout(() => void this.syncNow(), 3000));
|
||||
}
|
||||
}
|
||||
|
||||
onunload(): void {
|
||||
if (this.intervalId !== null) window.clearInterval(this.intervalId);
|
||||
}
|
||||
|
||||
async login(): Promise<void> {
|
||||
if (!this.settings.clientId.trim()) {
|
||||
new Notice("请先填写 Microsoft Entra 应用客户端 ID");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const auth = this.createAuth();
|
||||
const code = await auth.beginDeviceCode();
|
||||
new DeviceCodeModal(this.app, code.message, code.user_code, code.verification_uri).open();
|
||||
await auth.finishDeviceCode(code);
|
||||
new Notice("Microsoft 账户登录成功");
|
||||
} catch (error) {
|
||||
new Notice(errorMessage(error), 10000);
|
||||
}
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
this.settings.token = null;
|
||||
this.settings.entries = {};
|
||||
await this.saveSettings();
|
||||
new Notice("已清除本机 Microsoft 登录信息和同步快照");
|
||||
}
|
||||
|
||||
async syncNow(): Promise<void> {
|
||||
if (!this.settings.clientId || !this.settings.token) {
|
||||
new Notice("请先在设置中登录 Microsoft 账户");
|
||||
return;
|
||||
}
|
||||
if (!this.settings.vaultId.trim()) {
|
||||
new Notice("库 ID 不能为空");
|
||||
return;
|
||||
}
|
||||
if (this.syncInProgress) {
|
||||
new Notice("OneDrive 同步已在进行中");
|
||||
return;
|
||||
}
|
||||
this.syncInProgress = true;
|
||||
new Notice("OneDrive 同步开始");
|
||||
try {
|
||||
const engine = new SyncEngine(this.app, new GraphClient(this.createAuth()), this.settings, () => this.saveSettings());
|
||||
const result = await engine.sync();
|
||||
new Notice(
|
||||
`同步完成:上传 ${result.uploaded},下载 ${result.downloaded},冲突 ${result.conflicts},删除 ${result.deletedLocal + result.deletedRemote}`,
|
||||
8000
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("OneDrive sync failed", error);
|
||||
new Notice(`同步失败:${errorMessage(error)}`, 12000);
|
||||
} finally {
|
||||
this.syncInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
|
||||
configureTimer(): void {
|
||||
if (this.intervalId !== null) window.clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
if (this.settings.intervalMinutes > 0) {
|
||||
this.intervalId = window.setInterval(() => void this.syncNow(), this.settings.intervalMinutes * 60_000);
|
||||
this.registerInterval(this.intervalId);
|
||||
}
|
||||
}
|
||||
|
||||
private createAuth(): MicrosoftAuth {
|
||||
return new MicrosoftAuth(this.settings.clientId.trim(), this.settings.tenant.trim() || "common", this.settings.token, async (token: TokenState) => {
|
||||
this.settings.token = token;
|
||||
await this.saveSettings();
|
||||
});
|
||||
}
|
||||
|
||||
private async loadSettings(): Promise<void> {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
if (!this.settings.vaultId) {
|
||||
this.settings.vaultId = crypto.randomUUID();
|
||||
await this.saveSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DeviceCodeModal extends Modal {
|
||||
constructor(app: App, private readonly message: string, private readonly code: string, private readonly uri: string) {
|
||||
super(app);
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
this.titleEl.setText("登录 Microsoft 账户");
|
||||
this.contentEl.createEl("p", { text: this.message });
|
||||
this.contentEl.createEl("p", { text: `代码:${this.code}` });
|
||||
new Setting(this.contentEl)
|
||||
.addButton((button) => button.setButtonText("复制代码").onClick(() => void navigator.clipboard.writeText(this.code)))
|
||||
.addButton((button) => button.setButtonText("打开登录页面").setCta().onClick(() => window.open(this.uri)));
|
||||
this.contentEl.createEl("p", { text: "完成授权后,此窗口可关闭;插件会继续等待登录结果。" });
|
||||
}
|
||||
}
|
||||
|
||||
class OneDriveSyncSettingTab extends PluginSettingTab {
|
||||
constructor(app: App, private readonly plugin: OneDriveSyncPlugin) {
|
||||
super(app, plugin);
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
containerEl.createEl("h2", { text: "OneDrive 双向同步" });
|
||||
containerEl.createEl("p", {
|
||||
text: "所有设备必须使用相同的客户端 ID 和库 ID。插件只访问 OneDrive 的应用专属目录。"
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Microsoft Entra 客户端 ID")
|
||||
.setDesc("应用注册的 Application (client) ID;需启用公共客户端流并授予 Files.ReadWrite.AppFolder。")
|
||||
.addText((text) => text.setPlaceholder("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx").setValue(this.plugin.settings.clientId).onChange(async (value) => {
|
||||
this.plugin.settings.clientId = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("租户")
|
||||
.setDesc("common 支持个人账户和组织账户;也可填写租户 ID。")
|
||||
.addText((text) => text.setValue(this.plugin.settings.tenant).onChange(async (value) => {
|
||||
this.plugin.settings.tenant = value.trim() || "common";
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("库 ID")
|
||||
.setDesc("同一个库在所有设备上必须一致。首次安装后,将此值复制到其他设备。")
|
||||
.addText((text) => text.setValue(this.plugin.settings.vaultId).onChange(async (value) => {
|
||||
this.plugin.settings.vaultId = value.trim();
|
||||
this.plugin.settings.entries = {};
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(this.plugin.settings.token ? "已登录 Microsoft 账户" : "登录 Microsoft 账户")
|
||||
.addButton((button) => button.setButtonText("登录").setCta().onClick(() => void this.plugin.login()))
|
||||
.addButton((button) => button.setButtonText("退出").onClick(() => void this.plugin.logout()));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("自动同步间隔(分钟)")
|
||||
.setDesc("设为 0 可关闭定时同步。移动系统可能暂停后台计时器。")
|
||||
.addText((text) => text.setValue(String(this.plugin.settings.intervalMinutes)).onChange(async (value) => {
|
||||
this.plugin.settings.intervalMinutes = Math.max(0, Number.parseInt(value, 10) || 0);
|
||||
await this.plugin.saveSettings();
|
||||
this.plugin.configureTimer();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("启动后同步")
|
||||
.addToggle((toggle) => toggle.setValue(this.plugin.settings.syncOnStartup).onChange(async (value) => {
|
||||
this.plugin.settings.syncOnStartup = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("同步 .obsidian 配置目录")
|
||||
.setDesc("默认关闭。开启后插件、主题与工作区配置也会同步,发生冲突的风险更高。")
|
||||
.addToggle((toggle) => toggle.setValue(this.plugin.settings.syncConfigDir).onChange(async (value) => {
|
||||
this.plugin.settings.syncConfigDir = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("排除规则")
|
||||
.setDesc("每行一个 glob,例如 attachments/cache/**。")
|
||||
.addTextArea((text) => text.setValue(this.plugin.settings.excludedPatterns).onChange(async (value) => {
|
||||
this.plugin.settings.excludedPatterns = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("立即同步")
|
||||
.setDesc(this.plugin.settings.lastSyncAt ? `上次完成:${new Date(this.plugin.settings.lastSyncAt).toLocaleString()}` : "尚未同步")
|
||||
.addButton((button) => button.setButtonText("同步").setCta().onClick(() => void this.plugin.syncNow()));
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
212
src/sync.ts
Normal file
212
src/sync.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import { App, normalizePath } from "obsidian";
|
||||
import { GraphClient } from "./graph";
|
||||
import type { LocalItem, RemoteItem, SyncEntry, SyncSettings, SyncSummary } from "./types";
|
||||
|
||||
export class SyncEngine {
|
||||
private running = false;
|
||||
|
||||
constructor(
|
||||
private readonly app: App,
|
||||
private readonly graph: GraphClient,
|
||||
private readonly settings: SyncSettings,
|
||||
private readonly save: () => Promise<void>
|
||||
) {}
|
||||
|
||||
async sync(): Promise<SyncSummary> {
|
||||
if (this.running) throw new Error("同步已在进行中");
|
||||
this.running = true;
|
||||
const summary: SyncSummary = {
|
||||
uploaded: 0,
|
||||
downloaded: 0,
|
||||
deletedLocal: 0,
|
||||
deletedRemote: 0,
|
||||
conflicts: 0,
|
||||
skipped: 0
|
||||
};
|
||||
|
||||
try {
|
||||
const root = await this.graph.ensureVaultFolder(this.settings.vaultId);
|
||||
const [local, remote] = await Promise.all([this.scanLocal(), this.graph.listTree(root)]);
|
||||
const paths = new Set([...local.keys(), ...remote.keys(), ...Object.keys(this.settings.entries)]);
|
||||
|
||||
for (const path of [...paths].sort()) {
|
||||
if (this.excluded(path)) {
|
||||
summary.skipped++;
|
||||
continue;
|
||||
}
|
||||
await this.reconcile(path, root.id, local.get(path), remote.get(path), summary);
|
||||
}
|
||||
|
||||
this.settings.lastSyncAt = Date.now();
|
||||
await this.save();
|
||||
return summary;
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async reconcile(
|
||||
path: string,
|
||||
rootId: string,
|
||||
local: LocalItem | undefined,
|
||||
remote: RemoteItem | undefined,
|
||||
summary: SyncSummary
|
||||
): Promise<void> {
|
||||
const previous = this.settings.entries[path];
|
||||
|
||||
if (local && remote) {
|
||||
if (!previous) {
|
||||
if (local.size === remote.size && Math.abs(local.mtime - remote.mtime) < 2000) {
|
||||
this.record(path, local, remote);
|
||||
} else if (local.mtime > remote.mtime) {
|
||||
const uploaded = await this.upload(rootId, local);
|
||||
this.record(path, local, uploaded);
|
||||
summary.uploaded++;
|
||||
} else {
|
||||
const downloaded = await this.download(remote);
|
||||
this.record(path, downloaded, remote);
|
||||
summary.downloaded++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const localChanged = changedLocal(local, previous);
|
||||
const remoteChanged = remote.eTag !== previous.remoteETag;
|
||||
if (localChanged && remoteChanged) {
|
||||
await this.createConflictCopy(path);
|
||||
const downloaded = await this.download(remote);
|
||||
this.record(path, downloaded, remote);
|
||||
summary.conflicts++;
|
||||
} else if (localChanged) {
|
||||
const uploaded = await this.upload(rootId, local);
|
||||
this.record(path, local, uploaded);
|
||||
summary.uploaded++;
|
||||
} else if (remoteChanged) {
|
||||
const downloaded = await this.download(remote);
|
||||
this.record(path, downloaded, remote);
|
||||
summary.downloaded++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (local) {
|
||||
if (previous && !changedLocal(local, previous)) {
|
||||
await this.removeLocal(path);
|
||||
delete this.settings.entries[path];
|
||||
summary.deletedLocal++;
|
||||
} else {
|
||||
const uploaded = await this.upload(rootId, local);
|
||||
this.record(path, local, uploaded);
|
||||
summary.uploaded++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (remote) {
|
||||
if (previous && remote.eTag === previous.remoteETag) {
|
||||
await this.graph.delete(remote.id);
|
||||
delete this.settings.entries[path];
|
||||
summary.deletedRemote++;
|
||||
} else {
|
||||
const downloaded = await this.download(remote);
|
||||
this.record(path, downloaded, remote);
|
||||
summary.downloaded++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
delete this.settings.entries[path];
|
||||
}
|
||||
|
||||
private async scanLocal(): Promise<Map<string, LocalItem>> {
|
||||
const result = new Map<string, LocalItem>();
|
||||
const walk = async (folder: string): Promise<void> => {
|
||||
const listing = await this.app.vault.adapter.list(folder);
|
||||
for (const file of listing.files) {
|
||||
const path = normalizePath(file);
|
||||
if (this.excluded(path)) continue;
|
||||
const stat = await this.app.vault.adapter.stat(path);
|
||||
if (stat) result.set(path, { path, mtime: stat.mtime, size: stat.size });
|
||||
}
|
||||
for (const child of listing.folders) {
|
||||
const path = normalizePath(child);
|
||||
if (!this.excluded(`${path}/`)) await walk(path);
|
||||
}
|
||||
};
|
||||
await walk("");
|
||||
return result;
|
||||
}
|
||||
|
||||
private async upload(rootId: string, local: LocalItem): Promise<RemoteItem> {
|
||||
const data = await this.app.vault.adapter.readBinary(local.path);
|
||||
return this.graph.upload(rootId, local.path, data);
|
||||
}
|
||||
|
||||
private async download(remote: RemoteItem): Promise<LocalItem> {
|
||||
const parent = remote.path.includes("/") ? remote.path.slice(0, remote.path.lastIndexOf("/")) : "";
|
||||
if (parent) await this.ensureLocalFolder(parent);
|
||||
await this.app.vault.adapter.writeBinary(remote.path, await this.graph.download(remote.id));
|
||||
const stat = await this.app.vault.adapter.stat(remote.path);
|
||||
if (!stat) throw new Error(`写入本地文件失败: ${remote.path}`);
|
||||
return { path: remote.path, mtime: stat.mtime, size: stat.size };
|
||||
}
|
||||
|
||||
private async removeLocal(path: string): Promise<void> {
|
||||
if (await this.app.vault.adapter.exists(path)) await this.app.vault.adapter.remove(path);
|
||||
}
|
||||
|
||||
private async createConflictCopy(path: string): Promise<void> {
|
||||
const dot = path.lastIndexOf(".");
|
||||
const suffix = ` (本地冲突 ${deviceLabel()} ${timestamp()})`;
|
||||
const conflict = dot > path.lastIndexOf("/") ? `${path.slice(0, dot)}${suffix}${path.slice(dot)}` : `${path}${suffix}`;
|
||||
const parent = conflict.includes("/") ? conflict.slice(0, conflict.lastIndexOf("/")) : "";
|
||||
if (parent) await this.ensureLocalFolder(parent);
|
||||
await this.app.vault.adapter.writeBinary(conflict, await this.app.vault.adapter.readBinary(path));
|
||||
}
|
||||
|
||||
private async ensureLocalFolder(path: string): Promise<void> {
|
||||
let current = "";
|
||||
for (const part of path.split("/")) {
|
||||
current = current ? `${current}/${part}` : part;
|
||||
if (!(await this.app.vault.adapter.exists(current))) await this.app.vault.adapter.mkdir(current);
|
||||
}
|
||||
}
|
||||
|
||||
private record(path: string, local: LocalItem, remote: RemoteItem): void {
|
||||
this.settings.entries[path] = {
|
||||
localMtime: local.mtime,
|
||||
localSize: local.size,
|
||||
remoteETag: remote.eTag,
|
||||
remoteMtime: remote.mtime,
|
||||
remoteSize: remote.size
|
||||
};
|
||||
}
|
||||
|
||||
private excluded(path: string): boolean {
|
||||
if (path === ".obsidian/plugins/onedrive-bidirectional-sync/data.json") return true;
|
||||
if (!this.settings.syncConfigDir && (path === ".obsidian" || path.startsWith(".obsidian/"))) return true;
|
||||
const patterns = this.settings.excludedPatterns.split(/\r?\n/).map((p) => p.trim()).filter(Boolean);
|
||||
return patterns.some((pattern) => wildcard(pattern, path));
|
||||
}
|
||||
}
|
||||
|
||||
function changedLocal(local: LocalItem, previous: SyncEntry): boolean {
|
||||
return local.size !== previous.localSize || Math.abs(local.mtime - previous.localMtime) >= 1000;
|
||||
}
|
||||
|
||||
function wildcard(pattern: string, value: string): boolean {
|
||||
const regex = pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*\*/g, "\u0000")
|
||||
.replace(/\*/g, "[^/]*")
|
||||
.replace(/\u0000/g, ".*");
|
||||
return new RegExp(`^${regex}$`).test(value);
|
||||
}
|
||||
|
||||
function timestamp(): string {
|
||||
return new Date().toISOString().replace(/[:.]/g, "-");
|
||||
}
|
||||
|
||||
function deviceLabel(): string {
|
||||
return navigator.platform?.replace(/\s+/g, "-") || "device";
|
||||
}
|
||||
51
src/types.ts
Normal file
51
src/types.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
export interface TokenState {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface SyncEntry {
|
||||
localMtime: number;
|
||||
localSize: number;
|
||||
remoteETag: string;
|
||||
remoteMtime: number;
|
||||
remoteSize: number;
|
||||
}
|
||||
|
||||
export interface SyncSettings {
|
||||
clientId: string;
|
||||
tenant: string;
|
||||
vaultId: string;
|
||||
intervalMinutes: number;
|
||||
syncOnStartup: boolean;
|
||||
syncConfigDir: boolean;
|
||||
excludedPatterns: string;
|
||||
token: TokenState | null;
|
||||
entries: Record<string, SyncEntry>;
|
||||
lastSyncAt: number;
|
||||
}
|
||||
|
||||
export interface RemoteItem {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
eTag: string;
|
||||
mtime: number;
|
||||
size: number;
|
||||
folder: boolean;
|
||||
}
|
||||
|
||||
export interface LocalItem {
|
||||
path: string;
|
||||
mtime: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface SyncSummary {
|
||||
uploaded: number;
|
||||
downloaded: number;
|
||||
deletedLocal: number;
|
||||
deletedRemote: number;
|
||||
conflicts: number;
|
||||
skipped: number;
|
||||
}
|
||||
22
tsconfig.json
Normal file
22
tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES2022",
|
||||
"allowJs": false,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["DOM", "ES2022"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
3
versions.json
Normal file
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"0.1.0": "1.5.0"
|
||||
}
|
||||
Loading…
Reference in a new issue