Initial OneDrive bidirectional sync plugin

This commit is contained in:
naipi11 2026-06-16 00:17:43 +08:00
commit 7dd449b34c
18 changed files with 1371 additions and 0 deletions

31
.github/workflows/release.yml vendored Normal file
View 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
View 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
View file

@ -0,0 +1,4 @@
node_modules/
.pnpm-store/
main.js
*.map

25
COMMUNITY_SUBMISSION.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,2 @@
allowBuilds:
esbuild: true

132
src/auth.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
{
"0.1.0": "1.5.0"
}