init: first revision

This commit is contained in:
Marsway 2025-06-08 17:21:26 +08:00
parent 1b06abdbbf
commit 62c30367b2
17 changed files with 3435 additions and 170 deletions

2
.gitignore vendored
View File

@ -20,3 +20,5 @@ data.json
# Exclude macOS Finder (System Explorer) View States # Exclude macOS Finder (System Explorer) View States
.DS_Store .DS_Store
pnpm-lock.yaml

284
README.md
View File

@ -1,94 +1,242 @@
# Obsidian Sample Plugin # Obsidian Wolai 同步插件
This is a sample plugin for Obsidian (https://obsidian.md). 一个功能强大的 Obsidian 插件,用于在 Obsidian 笔记和 Wolai 我来数据库之间进行双向同步。支持富文本格式、多种块类型,并提供智能的同步状态管理。
This project uses TypeScript to provide type checking and documentation. ## ✨ 功能特性
The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definition format, which contains TSDoc comments describing what it does.
This sample plugin demonstrates some of the basic functionality the plugin API can do. - **🔄 双向同步**: 支持 Obsidian → Wolai 和 Wolai → Obsidian 的双向内容同步
- Adds a ribbon icon, which shows a Notice when clicked. - **📝 富文本支持**: 完整支持粗体、斜体、行内代码、删除线、链接等 Markdown 格式
- Adds a command "Open Sample Modal" which opens a Modal. - **🧱 多种块类型**: 支持标题、段落、列表、代码块、引用、分割线等常见 Markdown 元素
- Adds a plugin setting tab to the settings page. - **📊 状态管理**: 基于 FrontMatter 的智能同步状态跟踪,避免重复同步
- Registers a global click event and output 'click' to the console. - **📈 API 统计**: 实时监控 API 调用次数,防止超出 Wolai API 限制
- Registers a global interval which logs 'setInterval' to the console. - **⚡ 手动触发**: 精确控制同步时机,避免不必要的 API 调用
- **👀 文件监听**: 可选的文件变化监听功能(默认关闭)
- **🔧 灵活配置**: 丰富的配置选项,适应不同使用场景
## First time developing plugins? ## 📋 安装方法
Quick starting guide for new plugin devs: ### 方法一:手动安装(推荐)
1. 下载插件文件到本地
2. 将整个插件文件夹复制到你的 Obsidian 库目录下的 `.obsidian/plugins/obsidian-wolai-sync/`
3. 重启 Obsidian
4. 在 Obsidian 设置 → 社区插件中启用 "Wolai Sync" 插件
- Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with. ### 方法二:开发者安装
- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it). 1. 克隆或下载此仓库
- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder. 2. 在项目目录中运行 `npm install` 安装依赖
- Install NodeJS, then run `npm i` in the command line under your repo folder. 3. 运行 `npm run build` 构建插件
- Run `npm run dev` to compile your plugin from `main.ts` to `main.js`. 4. 将生成的 `main.js`、`manifest.json`、`styles.css` 复制到 `.obsidian/plugins/obsidian-wolai-sync/`
- Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`.
- Reload Obsidian to load the new version of your plugin.
- Enable plugin in settings window.
- For updates to the Obsidian API run `npm update` in the command line under your repo folder.
## Releasing new releases ## 🎯 适用场景
- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release. ### 理想使用场景
- Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible. - **知识管理系统**: 将 Obsidian 中的笔记同步到 Wolai 进行团队协作
- Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases - **内容发布工作流**: 在 Obsidian 中编写文档,自动同步到 Wolai 进行发布
- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release. - **双向数据备份**: 确保重要内容在两个平台上都有备份
- Publish the release. - **团队协作**: Obsidian 中个人编辑Wolai 中团队共享和讨论
> You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`. ### Wolai 数据库要求
> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json`
## Adding your plugin to the community plugin list 你的 Wolai 数据库**必须**包含以下字段:
- Check the [plugin guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines). | 字段名 | 类型 | 必需 | 说明 |
- Publish an initial version. |--------|------|------|------|
- Make sure you have a `README.md` file in the root of your repo. | 标题 | 文本 | ✅ | 对应 Obsidian 文件的标题 |
- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin. | 同步状态 | 单选 | ✅ | 值包括Synced, Pending |
## How to use **同步状态字段的选项设置**:
- `Synced`: 已成功同步
- `Pending`: 等待同步
- Clone this repo. ## ⚙️ 配置说明
- Make sure your NodeJS is at least v16 (`node --version`).
- `npm i` or `yarn` to install dependencies.
- `npm run dev` to start compilation in watch mode.
## Manually installing the plugin ### 1. Wolai API 设置
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`. 首先需要在 [Wolai 开发者中心](https://www.wolai.com/developers) 创建应用:
## Improve code quality with eslint (optional) 1. 访问 Wolai 开发者中心
- [ESLint](https://eslint.org/) is a tool that analyzes your code to quickly find problems. You can run ESLint against your plugin to find common bugs and ways to improve your code. 2. 创建新应用
- To use eslint with this project, make sure to install eslint from terminal: 3. 获取 **App ID** 和 **App Secret**
- `npm install -g eslint` 4. 将应用连接到你的 Wolai 工作区
- To use eslint to analyze this project use this command:
- `eslint main.ts`
- eslint will then create a report with suggestions for code improvement by file and line number.
- If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder:
- `eslint .\src\`
## Funding URL 在插件设置中填入:
- **数据库 ID**: 从 Wolai 数据库 URL 中获取(如 `https://www.wolai.com/your-workspace/database-id` 中的 `database-id`
- **App ID**: 从开发者中心获取的应用 ID
- **App Secret**: 从开发者中心获取的应用密钥
You can include funding URLs where people who use your plugin can financially support it. ### 2. Obsidian 设置
The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file: - **同步文件夹**: 指定要同步的 Obsidian 文件夹路径(例如:`Notes/Wolai`
```json ### 3. 同步设置
{
"fundingUrl": "https://buymeacoffee.com" - **启用文件监听器**: 默认关闭,开启后会自动监听文件变化
} - **自动同步**: 定时自动同步功能
- **同步间隔**: 自动同步的时间间隔5-120分钟
## 🚀 使用方法
### Obsidian → Wolai 同步
#### 方法一:手动同步(推荐)
1. 在同步文件夹中创建或编辑 Markdown 文件
2. 使用命令面板Ctrl/Cmd + P搜索 "Wolai"
3. 选择以下命令之一:
- **"同步当前文件到 Wolai"**: 仅同步当前打开的文件
- **"同步所有文件到 Wolai"**: 同步所有待同步的文件
4. 插件会自动为文件添加 FrontMatter 并设置同步状态
#### 方法二:文件监听(可选)
1. 在插件设置中启用 "文件监听器"
2. 文件修改保存后会自动标记为需要同步
3. 定期手动触发同步或启用自动同步
### Wolai → Obsidian 同步
1. 在 Wolai 数据库中找到要同步的行
2. 将该行的 "同步状态" 字段设置为 **"Wait For Syncing"**
3. 在 Obsidian 中使用命令 "从 Wolai 同步到 Obsidian"
4. 插件会自动创建或更新对应的 Obsidian 文件
### 同步状态说明
插件通过文件的 FrontMatter 管理同步状态:
```yaml
---
sync_status: Synced
wolai_id: "page_id_from_wolai"
last_sync: "2024-01-15T10:30:00.000Z"
---
``` ```
If you have multiple URLs, you can also do: 状态含义:
- `Pending`: 新文件,待首次同步到 Wolai
- `Modified`: 文件已修改,需要重新同步到 Wolai
- `Synced`: 已成功同步,无需重复操作
- `Wait For Syncing`: Wolai 中标记需要同步到 Obsidian
```json ## 🔧 为什么设计为手动同步?
{
"fundingUrl": { ### Wolai API 调用限制
"Buy Me a Coffee": "https://buymeacoffee.com",
"GitHub Sponsor": "https://github.com/sponsors", 基于 Wolai OpenAPI 的以下限制,我们采用了手动同步的设计:
"Patreon": "https://www.patreon.com/"
} #### 1. 频率限制
} - Wolai API 对调用频率有严格限制
- 过于频繁的自动同步可能导致 API 调用被限制
- 手动触发可以精确控制同步时机
#### 2. 批量限制
- **创建块**: 单次最多创建 20 个块
- **插入数据**: 单次最多插入 20 行数据
- **分页查询**: 单次最多返回 200 条记录
#### 3. Token 管理
- API Token 需要定期刷新
- 不当的频繁调用可能导致认证失败
- 手动同步降低了 Token 管理的复杂性
#### 4. 成本考虑
- API 调用可能产生费用(根据 Wolai 的定价策略)
- 避免不必要的自动同步可以控制成本
- 用户可以选择性地同步重要内容
### 设计优势
1. **精确控制**: 用户完全掌控何时同步什么内容
2. **避免冲突**: 减少同时编辑导致的同步冲突
3. **节省配额**: 避免浪费 API 调用次数
4. **稳定可靠**: 减少因网络问题导致的同步失败
5. **调试友好**: 便于排查同步问题和错误
## 📊 API 使用统计
插件内置 API 调用统计功能:
- **今日调用**: 显示当天的 API 调用次数
- **总调用数**: 显示累计 API 调用次数
- **重置功能**: 可以手动重置统计数据
- **实时更新**: 每次 API 调用后自动更新计数
建议将每日 API 调用控制在合理范围内,避免超出 Wolai 的限制。
## 🛠️ 支持的 Markdown 语法
### 文本格式
- **粗体文本**: `**粗体**``__粗体__`
- *斜体文本*: `*斜体*``_斜体_`
- `行内代码`: `` `代码` ``
- ~~删除线~~: `~~删除线~~`
- [链接](https://example.com): `[链接文本](URL)`
### 块级元素
- 标题: `#``######`
- 无序列表: `- 项目``* 项目`
- 有序列表: `1. 项目`
- 代码块: ````代码块````
- 引用: `> 引用内容`
- 分割线: `---``***`
### 富文本混合
支持在同一段落中混合多种格式:
```markdown
这是一个包含 **粗体**、*斜体*、`代码` 和 [链接](https://example.com) 的段落。
``` ```
## API Documentation ## ❗ 注意事项
See https://github.com/obsidianmd/obsidian-api 1. **数据备份**: 同步前请确保重要数据已备份
2. **网络连接**: 确保网络连接稳定,避免同步中断
3. **配置检查**: 首次使用前请使用 "测试连接" 功能验证配置
4. **文件格式**: 确保文件是标准的 Markdown 格式
5. **权限设置**: 确保 Wolai 应用有访问目标数据库的权限
6. **并发编辑**: 避免在同步过程中同时编辑文件
7. **特殊字符**: 某些特殊字符可能需要转义处理
## 🐛 故障排除
### 常见问题
#### 1. 连接失败
- 检查 App ID 和 App Secret 是否正确
- 确认应用已连接到 Wolai 工作区
- 验证网络连接是否正常
#### 2. 同步失败
- 查看 Obsidian 开发者控制台Ctrl+Shift+I的错误信息
- 检查 Wolai 数据库字段是否完整
- 确认文件的 FrontMatter 格式正确
#### 3. 重复同步
- 检查文件的 `sync_status` 字段值
- 确认 Wolai 数据库中的同步状态设置
- 重置 API 统计后重新尝试
#### 4. 格式问题
- 确保 Markdown 文件格式标准
- 检查特殊字符是否正确转义
- 验证文件编码为 UTF-8
### 获取帮助
如果遇到问题:
1. 查看 Obsidian 控制台的详细错误信息
2. 检查插件设置是否正确配置
3. 尝试使用 "测试连接" 功能
4. 查看 [Wolai API 文档](https://www.wolai.com/developers) 获取最新信息
## 📄 许可证
本项目使用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。
## 🤝 贡献
欢迎提交 Issue 和 Pull Request 来改进这个插件!
---
**开发者**: Li Wei
**主页**: https://marsway.red
**版本**: 1.0.0
**兼容性**: Obsidian 0.15.0+

393
docs/WolaiAPI.md Normal file
View File

@ -0,0 +1,393 @@
# Wolai API
通过wolai开放API可以访问wolai 的块、页面、数据库等未来开放在wolai开发者中心创建一个应用将应用连接到wolai实现更多自有场景的数据连接。wolai开放API采用RESTful规范通过 GETPOST...请求来获取数据。发送所有请求的 base URL 是 `https://openapi.wolai.com/v1`。(目前仅提供 API暂不支持SDK
您可以用wolai API 打通三方应用同步滴答清单微信钉钉飞书到wolai或者自建应用。支持PDF导入生成自动汇总的周报基于数据表格的图表生成等
# 创建 Token POST /token
### 接口描述
创建用于访问后续API的Token
### 请求地址
**POST **`/token`
### 请求参数 *[Request Body]*
||||
|-|-|-|
|App**Id** *string*|应用ID|`必填`|
|**AppSecret** *string*|应用秘钥|`必填`|
### 返回参数说明
返回 Token 信息
### 请求示例
```JSON
{
"appId":"qGPon7raLEu4nJTXCBkeDB",
"appSecret":"e8501b4c8ed17f97961db65d438c61ba0c4d58ad894ac23c924c1a098b03b1f7"
}
```
### 返回示例
```JSON
//成功
{
"data": {
"app_token": "63b826cad9b3154670ff4242641c1f175320bf6b1b501bc860c7bb4772c9ce74",
"app_id": "qGPon7raLEu4nJTXCBkeDA",
"create_time": 1671523112626,
"expire_time": -1,
"update_time": 1671523112626
}
}
//失败
{
"data": null
}
```
# 创建块 POST /blocks
### 接口描述
创建一个块或多个块并插入到 parent_id 对应的块下
创建限制单次批量不能超过20个块
### 请求地址
**POST** ** **`/block`
### *请求参数 [Request Body] *
#### 基础参数
在 Body json 内填入 blocks 及parent_id属性 。blocsk 可以为单个block 或 block 数组block 对象 内 type 为必传字段类型为支持的块类型详见BlockTypes),其余字段查看下方。 **↳ **符号表示为 blocks 包含的子字段, 详见右侧请求示例。
|属性名|类型|必填/可选|描述|
|-|-|-|-|
|parent_id|string|`必填`|父块 ID|
|blocks|单个Block对象或Block数组 注意下方为块可选的公共属性|`必填`|包含以下公共属性,及特殊属性的顶层字段|
|**↳ **type|BlockTypes|`必填`|块类型|
|**↳ **block_front_color|BlockFrontColors||前景色|
|**↳ **block_back_color|BlockBackColors||背景色|
|**↳ **text_alignment|TextAlign||文本对齐|
|**↳ **block_alignment|BlockAlign||块对齐|
|**↳ **其他属性|参考Block|||
什么是父块
wolai 中父子关系有三种,一种是页面里的内容,一种是所有缩进的内容都是子块,另外一种是容器块,如分栏和模板按钮内的内容是该容器块的子块。
如:
1. 当前页面包含了文本,简单表格,着重块,代码块等一系列块。注意包含的块并不包括页面本身。
2. 缩进子块
父块
子块
3. 容器块
包含一个待办列表子块
- [ ]
parent_id 为父块id, 可以从块菜单内的“复制块 ID” 来获取。页面块可以从
该页面的访问链接内最后的路径来获取[//]: # (如这个页面的链接为 [https://www.wolai.com/wolai/iSCCMiztmGHpoNhe4JrSsk](https://www.wolai.com/wolai/iSCCMiztmGHpoNhe4JrSsk)则该页面ID 为iSCCMiztmGHpoNhe4JrSsk)也可以从父页面或父块内该块的块菜单内的“复制页面ID”来获取。
![](https://secure2.wostatic.cn/static/ktT6UgZzBe5ZCmfZwh2jtC/image.png?auth_key=1748942946-exth5iYLWT8w5uaCz5cBPQ-0-c723d3d045c7dbdb2c2cfca99be8d8bf)
![](https://secure2.wostatic.cn/static/iTxsrNA6ETPGcffhUdNFUT/image.png?auth_key=1748942946-f3ze7FTwXq9s4KRJEEBHeT-0-b26d5eb066a617c369607bcf01d463b4)
### 返回参数
可访问的块链接
### 请求示例* *
```JSON
{
"parent_id": "父块ID",
"blocks": [
{
"type": "text",
"content": "Hello ",
"text_alignment": "center"
},
{
"type": "heading",
"level": 1,
"content": {
"title": "World!",
"front_color": "red"
},
"text_alignment": "center"
}
]
}
```
### 返回示例
```JSON
//成功
{
"data":"https://www.wolai.com/o4icCBRxnvHS99j1RRAuAp#iTZum4zcxGNRpdeVcVvzWw"
}
//失败
{
"message": "Token 未填写, 请检查 Header 中 Authorization 字段内是否填写 Token, Token 相关说明请参考https://www.wolai.com/eLKwqTsGHaXV6SvjedEt43",
"error_code": 17003,
"status_code": 401
}
```
# 插入数据 POST /databases/{id}/rows
### 接口描述
插入数据表格行数据
如果插入多选/单选列中包含目前数据表格该列中不存在的选项,会对该多选/单选列自动增加选项。
### 请求地址
POST ** **`/databases/{id}/rows`
### 获取数据表格 ID
**数据表格嵌入块**
- 从数据表格菜单中获取,选择 下的复制访问链接,并取出链接最后的 ID。
![](https://secure2.wostatic.cn/static/fGYrKkYMQ6miSPxXYgvqmC/image.png?auth_key=1748942760-r6L4Qq7FZ2PVadbCPZBuay-0-80b1abe7088c16b0f2b2680fc766ed5f)
- 选择块菜单旁边的 **复制引用视图链接**/后 ?前面的中间部分为数据表格块 ID示例
[https://www.wolai.com/5FSqWmTrdAXBXo3EQhDCq4?viewId=s9k2EPzfqUaVSRXPnKGb](https://www.wolai.com/5FSqWmTrdAXBXo3EQhDCq4?viewId=s9k2EPzfqUaVSRXPnKGb)
其中 `5FSqWmTrdAXBXo3EQhDCq4` 为数据表格块 ID。
**数据表格页面**
- 从页面域名中获取,示例:[https://www.wolai.com/wolaiteam/4prhMAfrPzoipvkXhPKD34](https://www.wolai.com/wolaiteam/4prhMAfrPzoipvkXhPKD34) 其中`4prhMAfrPzoipvkXhPKD34`为数据表格块 ID。
- 从数据表格菜单中获取,方法同上。
### *请求参数 [Request Path]*
||||
|-|-|-|
|**Id** *string*|数据表格块 ID 可以在页面域名内或者数据表格全局菜单获取|`必填`|
### *请求参数 [Request Body]*
||||
|-|-|-|
|**rows** CreateDatabaseRow[]|要插入的多行数据数组,最多支持单次插入 20行。|`必填`|
### *返回参数 *
|||
|-|-|
|**data** *string*[]|数据行页面链接列表|
### *请求示例 *
```JSON
POST https://openapi.wolai.com/v1/databases/c1YSDeeFUKXddmFtV1wTu9/rows
{
"rows": [{
"标题": "标题",
"多选列": ["1", "2"],
"数字": 12,
"CheckBox": false
}]
}
```
### *返回示例 *
```JSON
//成功
{
"data": ["https://www.wolai.com/c1YSDeeFUKXddmFtV1wTu9"]
}
}
//失败
{
"message": "缺少请求体 Body",
"error_code": 17001,
"status_code": 400
}
```
# 获取表格内容 GET /databases/{id}
### 接口描述
获取数据表格内容
### 请求地址
**GET** ** **`/databases/{id}`
### 获取数据表格 ID
**数据表格嵌入块**
- 从数据表格菜单中获取,选择 下的复制访问链接,并取出链接最后的 ID。
![](https://secure2.wostatic.cn/static/fGYrKkYMQ6miSPxXYgvqmC/image.png?auth_key=1748959068-wAhNL72dNEU7j96Rmugo2w-0-11082dd25b5924babcfd38a212645c36)
- 选择块菜单旁边的 **复制引用视图链接**/后 ?前面的中间部分为数据表格块 ID示例
[https://www.wolai.com/5FSqWmTrdAXBXo3EQhDCq4?viewId=s9k2EPzfqUaVSRXPnKGb](https://www.wolai.com/5FSqWmTrdAXBXo3EQhDCq4?viewId=s9k2EPzfqUaVSRXPnKGb)
其中 `5FSqWmTrdAXBXo3EQhDCq4` 为数据表格块 ID。
**数据表格页面**
- 从页面域名中获取,示例:[https://www.wolai.com/wolaiteam/4prhMAfrPzoipvkXhPKD34](https://www.wolai.com/wolaiteam/4prhMAfrPzoipvkXhPKD34) 其中`4prhMAfrPzoipvkXhPKD34`为数据表格块 ID。
- 从数据表格菜单中获取,方法同上。
### *请求参数 [Request Path]*
||||
|-|-|-|
|**blockId** *string*|数据表格块 ID 可以在页面域名内或者数据表格全局菜单获取|`必填`|
### *返回参数 *
|||
|-|-|
|**ColumnOrder** *string[]*|列顺序,以列名排序|
|**Rows** DatabaseRowData*[]*|数据表格内容,数据表格行列表,每行包含每列对应的数据|
### *请求示例 *
```JSON
GET https://openapi.wolai.com/v1/databases/c1YSDeeFUKXddmFtV1wTu9
```
### *返回示例 *
```JSON
//成功
{
"data": {
"column_order": [
"标题",
"标签"
],
"rows": [
{
"page_id": "4YRtvKiYMQBXGvjz7Y52hC", //行对应的页面 ID
"data": {
"标题": {
"type": "primary",
"value": "测试"
},
"标签": {
"type": "select",
"value": "待完成"
}
}
}
]
}
}
//失败
{
"message": "Token 未填写, 请检查 Header 中 Authorization 字段内是否填写 Token, Token 相关说明请参考https://www.wolai.com/wolai/a3qaYWF3P3SWUGxWPvxTjP",
"error_code": 17003,
"status_code": 401
}
```
# 分页参数
### 请求
资源分页都接受以下请求参数(query paramters)
如: `/block/{id}/children?page_size=10&start_cursor=cursor_id`
|**参数**|**类型**|**描述**|
|-|-|-|
|`start_cursor`|`string`(可选的)|从上一个响应中返回的`cursor`,用于请求下一页的结果。 默认值: `undefined`,表示从列表的开始返回结果。|
|`page_size`|`number`(可选的)|响应中需要的完整列表中的项目数量。 默认值:`200 `最多:`200 `响应可能包含少于这个数量的结果。|
### 响应
资源的批量请求会返回以下参数:
|**字段**|**类型**|**描述**|
|-|-|-|
|`has_more`|`boolean`|当响应包括列表的结尾时,为`false`。否则,为`true`。|
|`next_cursor`|`string`|只有当`has_more`为 `true`时才可用。 用来检索下一页的结果,方法是将该值作为`start_cursor`参数传递给同一个端点。|
# 块类型
[Block](https://www.wolai.com/sBh7HkJUCtEMVcDF8xo9Gz)
[BlockTypes](https://www.wolai.com/2RsvgCzmLo5fvQrDxfSXyW)
[BlockBackColors](https://www.wolai.com/bCwb12wh1ke4hYb2GiNHcS)
[BlockFrontColors](https://www.wolai.com/4wCxFNa2kScj9tRUMRhPzG)
[InlineTitleType](https://www.wolai.com/2hVfKgFPjZVN8vd7FQpACX)
[ RichText](https://www.wolai.com/mYn9ePcFv7UzHezoLr3CXE)
[BlockAlign](https://www.wolai.com/quouz2gwGy7dqnwHWkfZJu)
[TextAlign](https://www.wolai.com/bwvNHsJmknpnsgdNHKyTAE)
[CreateRichText](https://www.wolai.com/sCbv85cCmDNvH6mkvANvwj)
[HeadingLevel](https://www.wolai.com/1hsY7VqXPHov43Qz4booyT)
[EmojiIcon](https://www.wolai.com/wjXib8ykTN19MNd6LmpTKW)
[LinkIcon](https://www.wolai.com/faU5M5NK2KYdfNurfu6yfX)
[CodeSetting](https://www.wolai.com/cKn5FuFjnmHxCxzcHmpn7a)
[CodeLanguage](https://www.wolai.com/3Uimn9YiAtFQMgFQGxn5BU)
[LinkCover](https://www.wolai.com/3Bbc25FEey8JULGkdc4BSR)
[TodoListProStatus](https://www.wolai.com/qJRTMukqYLKdLzjEY2dz11)
[PageSetting](https://www.wolai.com/79S6Lw12HDXq2GE1EmuUKE)
[鼠标悬停](https://www.wolai.com/mvpw6R5PxsjqyvFYgqGetQ)
[分页参数](https://www.wolai.com/83f7rDQPRFCcLwwnjhPMkP)

230
docs/WolaiBlockType.md Normal file
View File

@ -0,0 +1,230 @@
每个块创建的时候必须填写对应的 type 字段,如文本块需要填写 type 字段为 "text"具体类型请参考 BlockTypes。
每个块有单独的属性可以设置,具体的字段参考下方
目前 file, database, meeting, reference, simple_table, template_button, row, column无法使用在创建块接口中使用。
## 文本块
|属性|类型|描述|可选|
|-|-|-|-|
|type|`"text"`|文本块类型|`必填`|
|content|CreateRichText|文本内容。具体参考CreateRichText|`可选`|
## 标题块
|属性|类型|描述|可选|
|-|-|-|-|
|type|`"heading"`|标题块类型|`必填`|
|level|HeadingLevel|标题级别,支持数字 1-4|`必填`|
|toggle|`boolean`|是否折叠|`可选`|
|content|CreateRichText|文本内容。具体参考CreateRichText|`可选`|
## 页面块
|属性|类型|描述|可选|
|-|-|-|-|
|type|`"page"`|页面块类型|`必填`|
|icon|LinkIcon 或 EmojiIcon|图标|`可选`|
|page_cover|LinkCover|封面|`可选`|
|page_setting|PageSetting|页面设置|`可选`|
|content|CreateRichText|页面标题|`可选`|
## 代码块
|属性|类型|描述|可选|
|-|-|-|-|
|type|`"code"`|代码块类型|`必填`|
|language|CodeLanguage|代码语言 (例如 "python", "html" 等)|`必填`|
|code_setting|CodeSetting|代码设置|`可选`|
|caption|`string`|代码块说明|`可选`|
|content|CreateRichText|代码内容|`可选`|
## 引用块
|属性|类型|描述|可选|
|-|-|-|-|
|type|`"quote"`|引用块类型|`必填`|
|content|CreateRichText|文本内容。具体参考CreateRichText|`可选`|
## 着重文字块
|属性|类型|描述|可选|
|-|-|-|-|
|type|`"callout"`|着重文字块类型|`必填`|
|icon|LinkIcon 或 EmojiIcon|图标|`可选`|
|marquee_mode|`boolean`|跑马灯模式|`可选`|
|content|CreateRichText|页面标题|`可选`|
## 媒体块
|属性|类型|描述|可选|
|-|-|-|-|
|type|`string`|媒体块类型,可能的值有 `"image"`,`"video"`,`"audio"`|`必填`|
|link|`string`|图片/视频/音频链接|`必填`|
|caption|`string`|说明文字|`可选`|
## 分隔符块
|属性|类型|描述|可选|
|-|-|-|-|
|type|`"divider"`|分割线类型|`必填`|
## 进度条块
|属性|类型|描述|可选|
|-|-|-|-|
|type|`"progress_bar"`|进度条类型|`必填`|
|progress|`number`|可填入 0 - 100 的数字|`必填`|
|auto_mode|`boolean`|自动模式,开启后进度条左边会出现勾选图标,并且不再允许手动调整进度|`可选`|
|hide_number|`boolean`|隐藏进度条数字|`可选`|
## 书签块
|属性|类型|描述|可选|
|-|-|-|-|
|type|`"bookmark"`|书签类型|`必填`|
|link|`string`|书签链接|`必填`|
## 有序列表块
|属性|类型|描述|可选|
|-|-|-|-|
|type|`"enum_list"`|有序列表类型|`必填`|
|content|CreateRichText|文本内容。具体参考CreateRichText|`可选`|
## 任务列表块
|属性|类型|描述|可选|
|-|-|-|-|
|type|`"todo_list"`|任务列表类型|`必填`|
|content|CreateRichText|文本内容。具体参考CreateRichText|`可选`|
|checked|`boolean`|任务完成状态[//]: # (不填,默认为未完成状态)|`可选`|
## 高级任务列表块
|属性|类型|描述|可选|
|-|-|-|-|
|type|`"todo_list_pro"`|高级任务列表类型|`必填`|
|task_status|TodoListProStatus|任务完成状态,可能的有`"todo"`,`"doing"`,`"done"`,`"cancel"`[//]: # (默认为 "todo" 状态)|`可选`|
|content|CreateRichText|文本内容。具体参考CreateRichText|`可选`|
## 无序列表块
|属性|类型|描述|可选|
|-|-|-|-|
|type|`"bull_list"`|无序列表类型|`必填`|
|content|CreateRichText|文本内容。具体参考CreateRichText|`可选`|
## 折叠列表块
|属性|类型|描述|可选|
|-|-|-|-|
|type|`"toggle_list"`|折叠列表类型|`必填`|
|content|CreateRichText|文本内容。具体参考CreateRichText|`可选`|
## 公式块
|属性|类型|描述|可选|
|-|-|-|-|
|type|`"block_equation"`|公式块类型|`必填`|
|content[//]: # (公式块的内容建议直接传递纯文本,如 content: "e^2")|CreateRichText|文本内容。具体参考CreateRichText|`可选`|
## 三方嵌入块
|属性|类型|描述|可选|
|-|-|-|-|
|type|`"embed"`|三方嵌入类型|`必填`|
|original_link|`string`|原始链接|`必填`|
|embed_link|`string`|嵌入链接|`可选`|
---
# CreateRichText
string 或 RichText或 RichText 或 string数组 或 不传递(可选字段)
注意:创建块的 RichText 的 type 暂时只支持 "text" 和 "equation"
**例子:**
```JSON
// 纯文本 string
"hello"
// 富文本 RichText
{
// "type": "text", // 注意纯文本时,这个字段可以不传递
"title": "hello",
"bold": true
}
// 数组 支持 RichText 和 string 混合
[
{
// "type": "text", // 注意纯文本时,这个字段可以不传递
"title": "hello",
"bold": true
},
"world!"
]
// 不传递, 则生成一个无内容的块
```
## RichText
|属性名|类型|描述|
|-|-|-|
|type|InlineTitleType|行内标题类型|
|title|`string`|标题|
|bold|`boolean`|是否加粗|
|italic|`boolean`|是否斜体|
|underline|`boolean`|是否下划线|
|highlight|`boolean`|是否高亮|
|strikethrough|`boolean`|是否删除线|
|inline_code|`boolean`|是否行内代码|
|front_color|BlockFrontColors|前景色|
|back_color|BlockBackColors|背景色|

380
main.ts
View File

@ -1,85 +1,97 @@
import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian'; import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting, TFile } from 'obsidian';
import { WolaiSyncSettings } from './src/types';
import { SyncManager } from './src/SyncManager';
import { FileWatcher } from './src/FileWatcher';
import { WolaiSyncSettingTab } from './src/SettingsTab';
// Remember to rename these classes and interfaces! // Remember to rename these classes and interfaces!
interface MyPluginSettings { const DEFAULT_SETTINGS: WolaiSyncSettings = {
mySetting: string; obsidianFolder: '',
wolaiDatabaseId: '',
wolaiAppId: '',
wolaiAppSecret: '',
syncInterval: 20,
autoSync: true,
enableFileWatcher: false,
lastSyncTime: 0
} }
const DEFAULT_SETTINGS: MyPluginSettings = { export default class WolaiSyncPlugin extends Plugin {
mySetting: 'default' settings: WolaiSyncSettings;
} syncManager: SyncManager;
fileWatcher: FileWatcher;
export default class MyPlugin extends Plugin { statusBarItemEl: HTMLElement;
settings: MyPluginSettings; syncIntervalId: number | null = null;
async onload() { async onload() {
console.log('Plugin: Obsidian Wolai Sync loaded');
new Notice('Wolai Sync plugin loaded');
await this.loadSettings(); await this.loadSettings();
// 初始化同步管理器
this.syncManager = new SyncManager(this.app.vault, this.settings);
// 初始化文件监听器
this.initializeFileWatcher();
// This creates an icon in the left ribbon. // This creates an icon in the left ribbon.
const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => { const ribbonIconEl = this.addRibbonIcon('refresh-ccw-dot', 'Wolai Sync', async (evt: MouseEvent) => {
// Called when the user clicks the icon. // 执行手动同步
new Notice('This is a notice!'); await this.performManualSync();
}); });
// Perform additional things with the ribbon // Perform additional things with the ribbon
ribbonIconEl.addClass('my-plugin-ribbon-class'); ribbonIconEl.addClass('my-plugin-ribbon-class');
// This adds a status bar item to the bottom of the app. Does not work on mobile apps. // This adds a status bar item to the bottom of the app. Does not work on mobile apps.
const statusBarItemEl = this.addStatusBarItem(); this.statusBarItemEl = this.addStatusBarItem();
statusBarItemEl.setText('Status Bar Text'); this.updateStatusBar('Ready');
// This adds a simple command that can be triggered anywhere
this.addCommand({
id: 'open-sample-modal-simple',
name: 'Open sample modal (simple)',
callback: () => {
new SampleModal(this.app).open();
}
});
// This adds an editor command that can perform some operation on the current editor instance
this.addCommand({
id: 'sample-editor-command',
name: 'Sample editor command',
editorCallback: (editor: Editor, view: MarkdownView) => {
console.log(editor.getSelection());
editor.replaceSelection('Sample Editor Command');
}
});
// This adds a complex command that can check whether the current state of the app allows execution of the command
this.addCommand({
id: 'open-sample-modal-complex',
name: 'Open sample modal (complex)',
checkCallback: (checking: boolean) => {
// Conditions to check
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (markdownView) {
// If checking is true, we're simply "checking" if the command can be run.
// If checking is false, then we want to actually perform the operation.
if (!checking) {
new SampleModal(this.app).open();
}
// This command will only show up in Command Palette when the check function returns true
return true;
}
}
});
// This adds a settings tab so the user can configure various aspects of the plugin // This adds a settings tab so the user can configure various aspects of the plugin
this.addSettingTab(new SampleSettingTab(this.app, this)); this.addSettingTab(new WolaiSyncSettingTab(this.app, this));
// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin) // 添加强制同步当前文件的Command
// Using this function will automatically remove the event listener when this plugin is disabled. this.addCommand({
this.registerDomEvent(document, 'click', (evt: MouseEvent) => { id: 'force-sync-current-file',
console.log('click', evt); name: '强制同步当前文件',
checkCallback: (checking: boolean) => {
// 检查是否有活动的Markdown文件
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (activeView && activeView.file) {
if (!checking) {
this.forceSyncCurrentFile();
}
return true;
}
return false;
}
}); });
// When registering intervals, this function will automatically clear the interval when the plugin is disabled. // // If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000)); // // Using this function will automatically remove the event listener when this plugin is disabled.
// this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
// console.log('click', evt);
// });
// 启动定时同步
this.setupScheduledSync();
console.log('Wolai Sync plugin initialization completed');
} }
onunload() { onunload() {
console.log('Plugin: Obsidian Wolai Sync unloaded. Bye bye~');
// 清理文件监听器
if (this.fileWatcher) {
this.fileWatcher.stopWatching();
}
// 清理定时同步
if (this.syncIntervalId) {
window.clearInterval(this.syncIntervalId);
this.syncIntervalId = null;
}
} }
async loadSettings() { async loadSettings() {
@ -88,6 +100,232 @@ export default class MyPlugin extends Plugin {
async saveSettings() { async saveSettings() {
await this.saveData(this.settings); await this.saveData(this.settings);
// 更新同步管理器设置
if (this.syncManager) {
this.syncManager.updateSettings(this.settings);
}
// 重新初始化文件监听器(根据新的设置)
if (this.fileWatcher) {
this.fileWatcher.stopWatching();
}
this.initializeFileWatcher();
// 重新设置定时同步
this.setupScheduledSync();
}
private initializeFileWatcher(): void {
this.fileWatcher = new FileWatcher(
this.app.vault,
this.settings.obsidianFolder,
{
onFileChange: async (filePath: string) => {
console.log(`File changed: ${filePath}`);
await this.syncSingleFile(filePath);
},
onFileCreate: async (filePath: string) => {
console.log(`File created: ${filePath}`);
await this.syncSingleFile(filePath);
},
onFileDelete: async (filePath: string) => {
console.log(`File deleted: ${filePath}`);
// 从同步记录中移除
await this.syncManager.removeSyncRecord(filePath);
}
}
);
// 只有在启用文件监听器且设置了文件夹时才开始监听
if (this.settings.enableFileWatcher && this.settings.obsidianFolder) {
this.fileWatcher.startWatching();
console.log('File watcher enabled and started');
} else {
console.log('File watcher disabled or no folder specified');
}
}
private async syncSingleFile(filePath: string): Promise<void> {
try {
// 检查文件是否需要同步
const file = this.app.vault.getAbstractFileByPath(filePath) as TFile;
if (!file || !(file instanceof TFile)) {
console.log(`File not found or not a markdown file: ${filePath}`);
return;
}
const content = await this.app.vault.read(file);
const parsedMarkdown = this.syncManager.markdownParser.parseMarkdown(content);
// 只对需要同步的文件执行同步
if (!this.syncManager.markdownParser.needsSync(parsedMarkdown.frontMatter)) {
console.log(`File ${filePath} doesn't need sync, skipping...`);
return;
}
this.updateStatusBar('Syncing...');
const success = await this.syncManager.syncFile(filePath);
if (success) {
this.updateStatusBar('Synced');
console.log(`Successfully synced: ${filePath}`);
} else {
this.updateStatusBar('Sync Failed');
console.error(`Failed to sync: ${filePath}`);
}
} catch (error) {
console.error(`Error syncing file ${filePath}:`, error);
this.updateStatusBar('Sync Error');
}
}
private async performManualSync(): Promise<void> {
try {
this.updateStatusBar('Manual Sync...');
new Notice('开始手动同步...');
const result = await this.syncManager.fullSync();
const totalSynced = result.obsidianToWolai + result.wolaiToObsidian;
this.updateStatusBar('Synced');
if (totalSynced > 0) {
new Notice(`手动同步完成: Obsidian→Wolai ${result.obsidianToWolai}个文件, Wolai→Obsidian ${result.wolaiToObsidian}个文件`);
} else {
new Notice('没有需要同步的文件');
}
// 更新最后同步时间
this.settings.lastSyncTime = Date.now();
await this.saveSettings();
} catch (error) {
console.error('Manual sync failed:', error);
this.updateStatusBar('Sync Failed');
new Notice('手动同步失败,请查看控制台日志');
}
}
private setupScheduledSync(): void {
// 清理现有的定时器
if (this.syncIntervalId) {
window.clearInterval(this.syncIntervalId);
this.syncIntervalId = null;
}
// 如果启用了自动同步,设置新的定时器
if (this.settings.autoSync && this.settings.syncInterval > 0) {
const intervalMs = this.settings.syncInterval * 60 * 1000; // 转换为毫秒
this.syncIntervalId = window.setInterval(async () => {
try {
console.log('Starting scheduled sync...');
this.updateStatusBar('Auto Sync...');
await this.syncManager.scheduledSync();
this.updateStatusBar('Synced');
console.log('Scheduled sync completed');
} catch (error) {
console.error('Scheduled sync failed:', error);
this.updateStatusBar('Sync Failed');
}
}, intervalMs);
console.log(`Scheduled sync enabled: every ${this.settings.syncInterval} minutes`);
}
}
private updateStatusBar(status: string): void {
if (this.statusBarItemEl) {
this.statusBarItemEl.setText(`Wolai: ${status}`);
}
}
// 公共方法供设置页面调用
async testConnection(): Promise<boolean> {
if (!this.syncManager) {
return false;
}
return await this.syncManager.validateSync();
}
async manualSyncFromSettings(): Promise<void> {
await this.performManualSync();
}
getSyncStats(): { total: number; synced: number; pending: number } {
if (!this.syncManager) {
return { total: 0, synced: 0, pending: 0 };
}
return this.syncManager.getSyncStats();
}
updateSyncManager(): void {
if (this.syncManager) {
this.syncManager.updateSettings(this.settings);
}
}
startScheduledSync(): void {
this.stopScheduledSync(); // 先停止现有的定时器
if (this.settings.autoSync && this.settings.syncInterval > 0) {
const intervalMs = this.settings.syncInterval * 60 * 1000; // 转换为毫秒
this.syncIntervalId = window.setInterval(async () => {
try {
console.log('Executing scheduled sync...');
await this.syncManager.scheduledSync();
} catch (error) {
console.error('Scheduled sync failed:', error);
}
}, intervalMs);
console.log(`Scheduled sync started with interval: ${this.settings.syncInterval} minutes`);
}
}
stopScheduledSync(): void {
if (this.syncIntervalId) {
window.clearInterval(this.syncIntervalId);
this.syncIntervalId = null;
console.log('Scheduled sync stopped');
}
}
async forceSyncCurrentFile(): Promise<void> {
try {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!activeView || !activeView.file) {
new Notice('没有打开的Markdown文件');
return;
}
const filePath = activeView.file.path;
console.log(`强制同步当前文件: ${filePath}`);
// 显示开始同步通知
new Notice(`开始强制同步: ${activeView.file.basename}`);
this.updateStatusBar('Force Syncing...');
// 使用强制同步方法,绕过所有检查
const success = await this.syncManager.forceSyncObsidianToWolai(filePath);
if (success) {
this.updateStatusBar('Force Synced');
new Notice(`✅ 强制同步成功: ${activeView.file.basename}`);
console.log(`Successfully force synced: ${filePath}`);
} else {
this.updateStatusBar('Force Sync Failed');
new Notice(`❌ 强制同步失败: ${activeView.file.basename}`);
console.error(`Failed to force sync: ${filePath}`);
}
} catch (error) {
console.error('Error force syncing current file:', error);
this.updateStatusBar('Force Sync Error');
new Notice(`❌ 强制同步出错,请查看控制台日志`);
}
} }
} }
@ -106,29 +344,3 @@ class SampleModal extends Modal {
contentEl.empty(); contentEl.empty();
} }
} }
class SampleSettingTab extends PluginSettingTab {
plugin: MyPlugin;
constructor(app: App, plugin: MyPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const {containerEl} = this;
containerEl.empty();
new Setting(containerEl)
.setName('Setting #1')
.setDesc('It\'s a secret')
.addText(text => text
.setPlaceholder('Enter your secret')
.setValue(this.plugin.settings.mySetting)
.onChange(async (value) => {
this.plugin.settings.mySetting = value;
await this.plugin.saveSettings();
}));
}
}

View File

@ -1,11 +1,11 @@
{ {
"id": "sample-plugin", "id": "wolai-sync",
"name": "Sample Plugin", "name": "Wolai Sync",
"version": "1.0.0", "version": "1.0.0",
"minAppVersion": "0.15.0", "minAppVersion": "0.15.0",
"description": "Demonstrates some of the capabilities of the Obsidian API.", "description": "Sync Wolai Databases and obsidian folders.",
"author": "Obsidian", "author": "Li Wei",
"authorUrl": "https://obsidian.md", "authorUrl": "https://marsway.red",
"fundingUrl": "https://obsidian.md/pricing", "fundingUrl": "https://obsidian.md/pricing",
"isDesktopOnly": false "isDesktopOnly": false
} }

View File

@ -1,7 +1,7 @@
{ {
"name": "obsidian-sample-plugin", "name": "obsidian-wolai-sync",
"version": "1.0.0", "version": "1.0.0",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)", "description": "An Obsidian plugin that sync wolai databases with Obsidian folders",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"dev": "node esbuild.config.mjs", "dev": "node esbuild.config.mjs",
@ -9,7 +9,7 @@
"version": "node version-bump.mjs && git add manifest.json versions.json" "version": "node version-bump.mjs && git add manifest.json versions.json"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "Marsway",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/node": "^16.11.6", "@types/node": "^16.11.6",
@ -17,8 +17,12 @@
"@typescript-eslint/parser": "5.29.0", "@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0", "builtin-modules": "3.3.0",
"esbuild": "0.17.3", "esbuild": "0.17.3",
"gray-matter": "^4.0.3",
"obsidian": "latest", "obsidian": "latest",
"remark": "^14.0.3",
"remark-parse": "^10.0.2",
"tslib": "2.4.0", "tslib": "2.4.0",
"typescript": "4.7.4" "typescript": "4.7.4",
"unified": "^11.0.5"
} }
} }

147
src/FileWatcher.ts Normal file
View File

@ -0,0 +1,147 @@
import { TFile, Vault, EventRef } from 'obsidian';
export class FileWatcher {
private vault: Vault;
private watchedFolder: string;
private eventRefs: EventRef[] = [];
private pendingFiles: Set<string> = new Set();
private debounceTimer: NodeJS.Timeout | null = null;
private debounceDelay: number = 1000; // 1秒防抖
private onFileChangeCallback: (filePath: string) => void;
private onFileCreateCallback: (filePath: string) => void;
private onFileDeleteCallback: (filePath: string) => void;
constructor(
vault: Vault,
watchedFolder: string,
callbacks: {
onFileChange: (filePath: string) => void;
onFileCreate: (filePath: string) => void;
onFileDelete: (filePath: string) => void;
}
) {
this.vault = vault;
this.watchedFolder = watchedFolder;
this.onFileChangeCallback = callbacks.onFileChange;
this.onFileCreateCallback = callbacks.onFileCreate;
this.onFileDeleteCallback = callbacks.onFileDelete;
}
startWatching(): void {
// 监听文件创建事件
const createRef = this.vault.on('create', (file: TFile) => {
if (this.shouldWatchFile(file)) {
console.log(`File created: ${file.path}`);
this.onFileCreateCallback(file.path);
}
});
// 监听文件修改事件
const modifyRef = this.vault.on('modify', (file: TFile) => {
if (this.shouldWatchFile(file)) {
console.log(`File modified: ${file.path}`);
this.addToPendingFiles(file.path);
}
});
// 监听文件删除事件
const deleteRef = this.vault.on('delete', (file: TFile) => {
if (this.shouldWatchFile(file)) {
console.log(`File deleted: ${file.path}`);
this.onFileDeleteCallback(file.path);
}
});
// 监听文件重命名事件
const renameRef = this.vault.on('rename', (file: TFile, oldPath: string) => {
if (this.shouldWatchFile(file) || this.isInWatchedFolder(oldPath)) {
console.log(`File renamed: ${oldPath} -> ${file.path}`);
// 处理重命名:删除旧文件,创建新文件
if (this.isInWatchedFolder(oldPath)) {
this.onFileDeleteCallback(oldPath);
}
if (this.shouldWatchFile(file)) {
this.onFileCreateCallback(file.path);
}
}
});
this.eventRefs = [createRef, modifyRef, deleteRef, renameRef];
console.log(`Started watching folder: ${this.watchedFolder}`);
}
stopWatching(): void {
// 清理所有事件监听器
this.eventRefs.forEach(ref => {
this.vault.offref(ref);
});
this.eventRefs = [];
// 清理防抖定时器
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
// 清理待处理文件列表
this.pendingFiles.clear();
console.log(`Stopped watching folder: ${this.watchedFolder}`);
}
updateWatchedFolder(newFolder: string): void {
this.watchedFolder = newFolder;
console.log(`Updated watched folder to: ${newFolder}`);
}
private shouldWatchFile(file: TFile): boolean {
return this.isInWatchedFolder(file.path) && file.extension === 'md';
}
private isInWatchedFolder(filePath: string): boolean {
if (!this.watchedFolder) {
return false;
}
// 标准化路径,确保正确比较
const normalizedWatchedFolder = this.watchedFolder.replace(/\/$/, '');
const normalizedFilePath = filePath.replace(/^\//, '');
return normalizedFilePath.startsWith(normalizedWatchedFolder + '/') ||
normalizedFilePath === normalizedWatchedFolder;
}
private addToPendingFiles(filePath: string): void {
this.pendingFiles.add(filePath);
// 重置防抖定时器
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
this.debounceTimer = setTimeout(() => {
this.processPendingFiles();
}, this.debounceDelay);
}
private processPendingFiles(): void {
const filesToProcess = Array.from(this.pendingFiles);
this.pendingFiles.clear();
console.log(`Processing ${filesToProcess.length} pending files`);
// 批量处理文件变化
filesToProcess.forEach(filePath => {
this.onFileChangeCallback(filePath);
});
}
setDebounceDelay(delay: number): void {
this.debounceDelay = delay;
}
getPendingFiles(): string[] {
return Array.from(this.pendingFiles);
}
}

558
src/MarkdownParser.ts Normal file
View File

@ -0,0 +1,558 @@
import matter from 'gray-matter';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import { ParsedMarkdown, WolaiBlock, MarkdownNode, SyncStatus, FileSyncInfo, CreateRichText, WolaiRichText, WolaiPageBlock } from './types';
export class MarkdownParser {
parseFrontMatter(content: string): ParsedMarkdown {
try {
const parsed = matter(content);
return {
frontMatter: parsed.data || {},
content: parsed.content || '',
blocks: [] // 将在后续步骤中填充
};
} catch (error) {
console.error('Error parsing front matter:', error);
return {
frontMatter: {},
content: content,
blocks: []
};
}
}
getSyncInfo(frontMatter: { [key: string]: any }): FileSyncInfo {
return {
sync_status: frontMatter.sync_status || 'Pending',
wolai_id: frontMatter.wolai_id,
last_sync: frontMatter.last_sync
};
}
setSyncInfo(frontMatter: { [key: string]: any }, syncInfo: FileSyncInfo): { [key: string]: any } {
return {
...frontMatter,
sync_status: syncInfo.sync_status,
wolai_id: syncInfo.wolai_id,
last_sync: syncInfo.last_sync
};
}
updateSyncStatus(content: string, syncStatus: SyncStatus, wolaiId?: string): string {
try {
const parsed = matter(content);
// 更新FrontMatter中的同步状态
const updatedFrontMatter: { [key: string]: any } = {
...parsed.data,
sync_status: syncStatus,
last_sync: new Date().toISOString()
};
if (wolaiId) {
updatedFrontMatter.wolai_id = wolaiId;
}
// 重新组装文件内容
return matter.stringify(parsed.content, updatedFrontMatter);
} catch (error) {
console.error('Error updating sync status:', error);
return content;
}
}
needsSync(frontMatter: { [key: string]: any }): boolean {
const syncInfo = this.getSyncInfo(frontMatter);
return syncInfo.sync_status === 'Pending' || syncInfo.sync_status === 'Modified';
}
isSynced(frontMatter: { [key: string]: any }): boolean {
const syncInfo = this.getSyncInfo(frontMatter);
return syncInfo.sync_status === 'Synced';
}
parseMarkdownToAST(content: string): MarkdownNode | null {
try {
const processor = unified().use(remarkParse as any);
const ast = processor.parse(content);
return ast as MarkdownNode;
} catch (error) {
console.error('Error parsing markdown to AST:', error);
return null;
}
}
parseMarkdown(content: string): ParsedMarkdown {
const frontMatterResult = this.parseFrontMatter(content);
const ast = this.parseMarkdownToAST(frontMatterResult.content);
if (!ast) {
return frontMatterResult;
}
const blocks = this.convertASTToWolaiBlocks(ast);
return {
...frontMatterResult,
blocks: blocks
};
}
private convertASTToWolaiBlocks(node: MarkdownNode): WolaiBlock[] {
const blocks: WolaiBlock[] = [];
if (node.children) {
for (const child of node.children) {
if (child.type === 'list') {
// 列表需要特殊处理展开为多个独立的列表项blocks
const listBlocks = this.convertListToWolaiBlocks(child);
blocks.push(...listBlocks);
} else {
const block = this.convertNodeToWolaiBlock(child);
if (block) {
blocks.push(block);
}
}
}
}
return blocks;
}
private convertNodeToWolaiBlock(node: MarkdownNode): WolaiBlock | null {
switch (node.type) {
case 'heading':
return {
type: 'heading',
level: node.depth || 1,
content: this.extractRichTextFromNode(node)
};
case 'paragraph':
const richText = this.extractRichTextFromNode(node);
const plainText = this.extractTextFromNode(node);
if (plainText.trim()) {
return {
type: 'text',
content: richText
};
}
return null;
case 'code':
return {
type: 'code',
content: node.value || '',
language: node.lang || 'text'
};
case 'blockquote':
return {
type: 'quote',
content: this.extractRichTextFromNode(node)
};
default:
// 对于不支持的类型,转换为文本块
const content = this.extractRichTextFromNode(node);
const contentText = this.extractTextFromNode(node);
if (contentText.trim()) {
return {
type: 'text',
content: content
};
}
return null;
}
}
private convertListToWolaiBlocks(listNode: MarkdownNode, depth: number = 0): WolaiBlock[] {
const blocks: WolaiBlock[] = [];
const listType = listNode.ordered ? 'enum_list' : 'bull_list';
if (listNode.children) {
for (const listItem of listNode.children) {
if (listItem.type === 'listItem') {
// 处理列表项的直接文本内容(不包括嵌套列表)
const directTextContent = this.extractDirectTextFromListItem(listItem);
const directPlainText = this.extractTextFromNode(listItem);
if (directPlainText.trim()) {
const block: WolaiBlock = {
type: listType,
content: directTextContent
};
// 添加层级信息用于后续处理
if (depth > 0) {
(block as any).depth = depth;
(block as any).needsParent = true;
}
blocks.push(block);
}
// 处理嵌套列表
const nestedLists = this.extractNestedListsFromListItem(listItem);
for (const nestedList of nestedLists) {
const nestedBlocks = this.convertListToWolaiBlocks(nestedList, depth + 1);
blocks.push(...nestedBlocks);
}
}
}
}
return blocks;
}
private extractDirectTextFromListItem(listItem: MarkdownNode): CreateRichText {
if (!listItem.children) return '';
const richTextParts: (string | WolaiRichText)[] = [];
for (const child of listItem.children) {
// 只提取非列表的内容
if (child.type !== 'list') {
const childRichText = this.extractRichTextFromNode(child);
if (childRichText) {
if (Array.isArray(childRichText)) {
richTextParts.push(...childRichText);
} else {
richTextParts.push(childRichText);
}
}
}
}
// 如果只有一个元素且是字符串,直接返回字符串
if (richTextParts.length === 1 && typeof richTextParts[0] === 'string') {
return richTextParts[0];
}
// 如果有多个元素或包含格式化文本,返回数组
return richTextParts.length > 0 ? richTextParts : '';
}
private extractNestedListsFromListItem(listItem: MarkdownNode): MarkdownNode[] {
const nestedLists: MarkdownNode[] = [];
if (listItem.children) {
for (const child of listItem.children) {
if (child.type === 'list') {
nestedLists.push(child);
}
}
}
return nestedLists;
}
private extractTextFromNode(node: MarkdownNode): string {
const richText = this.extractRichTextFromNode(node);
if (typeof richText === 'string') {
return richText;
} else if (Array.isArray(richText)) {
return richText.map(item =>
typeof item === 'string' ? item : item.title
).join('');
} else {
return richText.title;
}
}
private extractRichTextFromNode(node: MarkdownNode): CreateRichText {
if (node.value) {
return node.value;
}
if (node.children) {
const richTextParts: (string | WolaiRichText)[] = [];
for (const child of node.children) {
const childRichText = this.convertNodeToRichText(child);
if (childRichText) {
if (Array.isArray(childRichText)) {
richTextParts.push(...childRichText);
} else {
richTextParts.push(childRichText);
}
}
}
// 如果只有一个元素且是字符串,直接返回字符串
if (richTextParts.length === 1 && typeof richTextParts[0] === 'string') {
return richTextParts[0];
}
// 如果有多个元素或包含格式化文本,返回数组
return richTextParts.length > 0 ? richTextParts : '';
}
return '';
}
private convertNodeToRichText(node: MarkdownNode): CreateRichText | null {
switch (node.type) {
case 'text':
return node.value || '';
case 'strong': // **加粗**
const boldText = this.extractTextFromNode(node);
if (boldText.trim()) {
return {
title: boldText,
bold: true
};
}
return null;
case 'emphasis': // *斜体*
const italicText = this.extractTextFromNode(node);
if (italicText.trim()) {
return {
title: italicText,
italic: true
};
}
return null;
case 'inlineCode': // `行内代码`
return {
title: node.value || '',
inline_code: true
};
case 'delete': // ~~删除线~~
const strikeText = this.extractTextFromNode(node);
if (strikeText.trim()) {
return {
title: strikeText,
strikethrough: true
};
}
return null;
case 'link': // [文本](链接)
if (node.url) {
// 处理链接文本,可能包含格式化内容
const linkTextContent = this.extractRichTextFromNode(node);
let linkTitle = '';
// 如果链接文本是简单字符串,直接使用
if (typeof linkTextContent === 'string') {
linkTitle = linkTextContent;
} else if (Array.isArray(linkTextContent)) {
// 如果链接文本包含格式,提取纯文本作为标题
linkTitle = linkTextContent.map(item =>
typeof item === 'string' ? item : item.title
).join('');
} else {
linkTitle = linkTextContent.title;
}
if (linkTitle.trim()) {
return {
title: linkTitle,
link: node.url
};
}
}
return null;
default:
// 对于其他类型,递归处理子节点
if (node.children) {
const childParts: (string | WolaiRichText)[] = [];
for (const child of node.children) {
const childResult = this.convertNodeToRichText(child);
if (childResult) {
if (Array.isArray(childResult)) {
childParts.push(...childResult);
} else {
childParts.push(childResult);
}
}
}
return childParts.length > 0 ? childParts : null;
}
return node.value || null;
}
}
createHash(content: string): string {
// 简单的哈希函数,用于检测文件变化
let hash = 0;
for (let i = 0; i < content.length; i++) {
const char = content.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // 转换为32位整数
}
return Math.abs(hash).toString(16);
}
sanitizeFileName(filename: string): string {
// 从文件名生成合适的标题
return filename
.replace(/\.md$/, '')
.replace(/[-_]/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase());
}
extractTitle(frontMatter: any, filename: string): string {
// 尝试从多个字段获取标题
return frontMatter.title ||
frontMatter.name ||
frontMatter. ||
this.sanitizeFileName(filename);
}
convertWolaiPageToMarkdown(blocks: WolaiPageBlock[], title?: string): string {
let markdown = '';
// 添加标题(如果提供)
if (title) {
markdown += `# ${title}\n\n`;
}
// 转换每个块
for (const block of blocks) {
const blockMarkdown = this.convertWolaiBlockToMarkdown(block);
if (blockMarkdown) {
markdown += blockMarkdown + '\n\n';
}
}
return markdown.trim();
}
private convertWolaiBlockToMarkdown(block: WolaiPageBlock): string {
// 获取缩进级别
const depth = (block as any).depth || 0;
const isChildBlock = (block as any).isChildBlock || false;
const indent = isChildBlock ? '\t'.repeat(depth) : '';
let blockContent = '';
switch (block.type) {
case 'heading':
const level = Math.min(Math.max(block.level || 1, 1), 6); // 限制在1-6级
const headingPrefix = '#'.repeat(level);
const headingContent = this.convertRichTextToMarkdown(block.content);
blockContent = `${headingPrefix} ${headingContent}`;
break;
case 'text':
blockContent = this.convertRichTextToMarkdown(block.content);
break;
case 'quote':
case 'blockquote':
const quoteContent = this.convertRichTextToMarkdown(block.content);
blockContent = `> ${quoteContent}`;
break;
case 'code':
const language = (block as any).language || '';
const codeContent = typeof block.content === 'string' ? block.content :
Array.isArray(block.content) ? block.content.map(c => typeof c === 'string' ? c : c.title).join('') :
block.content?.title || '';
blockContent = `\`\`\`${language}\n${codeContent}\n\`\`\``;
break;
case 'bull_list':
case 'unordered_list':
const bulletContent = this.convertRichTextToMarkdown(block.content);
// 无序列表直接应用缩进
return `${indent}- ${bulletContent}`;
case 'enum_list':
case 'ordered_list':
const enumContent = this.convertRichTextToMarkdown(block.content);
// 有序列表需要正确的缩进但保持1.编号
return `${indent}1. ${enumContent}`;
case 'image':
// 处理图片块
const altText = this.convertRichTextToMarkdown(block.content) || 'image';
const imageUrl = (block as any).url || '';
blockContent = imageUrl ? `![${altText}](${imageUrl})` : `*[图片: ${altText}]*`;
break;
case 'divider':
case 'separator':
blockContent = '---';
break;
case 'table':
// 表格暂时作为文本处理
blockContent = this.convertRichTextToMarkdown(block.content) || '*[表格内容]*';
break;
case 'equation':
// 数学公式
const equation = this.convertRichTextToMarkdown(block.content);
blockContent = `$$\n${equation}\n$$`;
break;
case 'toggle':
// 折叠块
const toggleContent = this.convertRichTextToMarkdown(block.content);
blockContent = `<details>\n<summary>${toggleContent}</summary>\n\n</details>`;
break;
default:
// 对于未知类型,作为普通文本处理并记录日志
console.warn(`Unknown block type: ${block.type}, treating as text`);
const content = this.convertRichTextToMarkdown(block.content);
blockContent = content || '';
}
// 应用缩进(对于非列表项)
if (indent && blockContent) {
// 对于其他类型,给每行添加缩进
const lines = blockContent.split('\n');
return lines.map(line => line.trim() ? `${indent}${line}` : line).join('\n');
}
return blockContent;
}
private convertRichTextToMarkdown(content?: CreateRichText): string {
if (!content) {
return '';
}
if (typeof content === 'string') {
return content;
}
if (Array.isArray(content)) {
return content.map(item => this.convertRichTextToMarkdown(item)).join('');
}
// 处理WolaiRichText对象
const richText = content as WolaiRichText;
let result = richText.title || '';
// 应用格式
if (richText.bold) {
result = `**${result}**`;
}
if (richText.italic) {
result = `*${result}*`;
}
if (richText.strikethrough) {
result = `~~${result}~~`;
}
if (richText.inline_code) {
result = `\`${result}\``;
}
if (richText.link) {
result = `[${result}](${richText.link})`;
}
return result;
}
}

369
src/SettingsTab.ts Normal file
View File

@ -0,0 +1,369 @@
import { App, PluginSettingTab, Setting, Notice, ButtonComponent } from 'obsidian';
import WolaiSyncPlugin from '../main';
import { WolaiSyncSettings } from './types';
export class WolaiSyncSettingTab extends PluginSettingTab {
plugin: WolaiSyncPlugin;
constructor(app: App, plugin: WolaiSyncPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: 'Wolai 同步设置' });
// API调用统计区域
this.createAPIStatsSection();
// 分隔线
containerEl.createEl('hr');
// Obsidian 设置区域
this.createObsidianSection();
// 分隔线
containerEl.createEl('hr');
// Wolai API 设置区域
this.createWolaiSection();
// 分隔线
containerEl.createEl('hr');
// 同步设置区域
this.createSyncSection();
// 分隔线
containerEl.createEl('hr');
// 操作按钮区域
this.createActionSection();
}
private createAPIStatsSection(): void {
const { containerEl } = this;
containerEl.createEl('h3', { text: 'API 调用统计' });
// 获取API统计数据
const stats = this.plugin.syncManager?.getAPICallStats() || { total: 0, today: 0, lastReset: 0 };
const statsContainer = containerEl.createDiv({ cls: 'wolai-sync-stats' });
// 今日调用次数
const todayEl = statsContainer.createDiv({ cls: 'stat-item' });
todayEl.createEl('span', { text: '今日API调用: ', cls: 'stat-label' });
todayEl.createEl('span', { text: stats.today.toString(), cls: 'stat-value' });
// 总调用次数
const totalEl = statsContainer.createDiv({ cls: 'stat-item' });
totalEl.createEl('span', { text: '总API调用: ', cls: 'stat-label' });
totalEl.createEl('span', { text: stats.total.toString(), cls: 'stat-value' });
// 重置按钮
new Setting(containerEl)
.setName('重置API统计')
.setDesc('清零所有API调用计数')
.addButton(button => {
button
.setButtonText('重置统计')
.setCta()
.onClick(async () => {
if (this.plugin.syncManager) {
this.plugin.syncManager.resetAPICallStats();
new Notice('API调用统计已重置');
this.display(); // 刷新显示
}
});
});
// 添加样式
if (!document.querySelector('.wolai-sync-stats-style')) {
const style = document.createElement('style');
style.className = 'wolai-sync-stats-style';
style.textContent = `
.wolai-sync-stats {
background: var(--background-secondary);
padding: 16px;
border-radius: 8px;
margin: 12px 0;
}
.stat-item {
display: flex;
justify-content: space-between;
margin: 8px 0;
}
.stat-label {
font-weight: 500;
}
.stat-value {
font-weight: bold;
color: var(--text-accent);
}
`;
document.head.appendChild(style);
}
}
private createObsidianSection(): void {
const { containerEl } = this;
containerEl.createEl('h3', { text: 'Obsidian 设置' });
new Setting(containerEl)
.setName('同步文件夹')
.setDesc('选择要同步到 Wolai 的文件夹路径')
.addText(text => text
.setPlaceholder('例如: Notes/Wolai')
.setValue(this.plugin.settings.obsidianFolder)
.onChange(async (value) => {
this.plugin.settings.obsidianFolder = value;
await this.plugin.saveSettings();
}));
}
private createWolaiSection(): void {
const { containerEl } = this;
containerEl.createEl('h3', { text: 'Wolai API 设置' });
new Setting(containerEl)
.setName('数据库 ID')
.setDesc('Wolai 数据库的唯一标识符')
.addText(text => text
.setPlaceholder('请输入数据库 ID')
.setValue(this.plugin.settings.wolaiDatabaseId)
.onChange(async (value) => {
this.plugin.settings.wolaiDatabaseId = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('App ID')
.setDesc('Wolai 应用程序 ID')
.addText(text => text
.setPlaceholder('请输入 App ID')
.setValue(this.plugin.settings.wolaiAppId)
.onChange(async (value) => {
this.plugin.settings.wolaiAppId = value;
await this.plugin.saveSettings();
// 更新API实例
this.plugin.updateSyncManager();
}));
new Setting(containerEl)
.setName('App Secret')
.setDesc('Wolai 应用程序密钥(敏感信息,请妥善保管)')
.addText(text => {
text.inputEl.type = 'password';
text
.setPlaceholder('请输入 App Secret')
.setValue(this.plugin.settings.wolaiAppSecret)
.onChange(async (value) => {
this.plugin.settings.wolaiAppSecret = value;
await this.plugin.saveSettings();
// 更新API实例
this.plugin.updateSyncManager();
});
});
// 连接测试按钮
new Setting(containerEl)
.setName('测试连接')
.setDesc('验证 Wolai API 配置是否正确')
.addButton(button => {
button
.setButtonText('测试连接')
.setCta()
.onClick(async () => {
button.setButtonText('测试中...');
button.setDisabled(true);
try {
if (!this.plugin.settings.wolaiAppId || !this.plugin.settings.wolaiAppSecret) {
new Notice('请先填写 App ID 和 App Secret');
return;
}
const isValid = await this.plugin.syncManager?.validateSync();
if (isValid) {
new Notice('Wolai API 连接成功!');
} else {
new Notice('Wolai API 连接失败,请检查配置');
}
} catch (error) {
console.error('Connection test failed:', error);
new Notice('连接测试失败');
} finally {
button.setButtonText('测试连接');
button.setDisabled(false);
}
});
});
}
private createSyncSection(): void {
const { containerEl } = this;
containerEl.createEl('h3', { text: '同步设置' });
new Setting(containerEl)
.setName('启用自动同步')
.setDesc('是否启用定时自动同步功能')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.autoSync)
.onChange(async (value) => {
this.plugin.settings.autoSync = value;
await this.plugin.saveSettings();
// 更新定时器
if (value) {
this.plugin.startScheduledSync();
new Notice('自动同步已启用');
} else {
this.plugin.stopScheduledSync();
new Notice('自动同步已禁用');
}
}));
new Setting(containerEl)
.setName('启用文件监听')
.setDesc('监听文件夹变化并自动同步可能会频繁调用API')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableFileWatcher)
.onChange(async (value) => {
this.plugin.settings.enableFileWatcher = value;
await this.plugin.saveSettings();
if (value) {
new Notice('文件监听已启用');
} else {
new Notice('文件监听已禁用');
}
}));
new Setting(containerEl)
.setName('同步间隔')
.setDesc('自动同步的时间间隔(分钟)')
.addSlider(slider => slider
.setLimits(5, 120, 5)
.setValue(this.plugin.settings.syncInterval)
.setDynamicTooltip()
.onChange(async (value) => {
this.plugin.settings.syncInterval = value;
await this.plugin.saveSettings();
// 如果自动同步已启用,重新启动定时器
if (this.plugin.settings.autoSync) {
this.plugin.stopScheduledSync();
this.plugin.startScheduledSync();
}
}));
// 显示上次同步时间
if (this.plugin.settings.lastSyncTime > 0) {
const lastSyncDate = new Date(this.plugin.settings.lastSyncTime);
const lastSyncText = `上次同步: ${lastSyncDate.toLocaleString('zh-CN')}`;
containerEl.createDiv({
text: lastSyncText,
cls: 'setting-item-description'
});
}
}
private createActionSection(): void {
const { containerEl } = this;
containerEl.createEl('h3', { text: '同步操作' });
// 手动双向同步按钮
new Setting(containerEl)
.setName('立即双向同步')
.setDesc('执行完整的双向同步Obsidian → Wolai 和 Wolai → Obsidian')
.addButton(button => {
button
.setButtonText('开始双向同步')
.setCta()
.onClick(async () => {
button.setButtonText('同步中...');
button.setDisabled(true);
try {
if (!this.plugin.syncManager) {
new Notice('同步管理器未初始化');
return;
}
const result = await this.plugin.syncManager.fullSync();
const totalSynced = result.obsidianToWolai + result.wolaiToObsidian;
if (totalSynced > 0) {
new Notice(`同步完成Obsidian→Wolai: ${result.obsidianToWolai}个文件Wolai→Obsidian: ${result.wolaiToObsidian}个文件`);
} else {
new Notice('没有文件需要同步');
}
// 刷新API统计显示
this.display();
} catch (error) {
console.error('Manual sync failed:', error);
new Notice('同步失败,请查看控制台日志');
} finally {
button.setButtonText('开始双向同步');
button.setDisabled(false);
}
});
});
// 仅Obsidian到Wolai同步按钮
new Setting(containerEl)
.setName('仅同步到 Wolai')
.setDesc('只将Obsidian中的新文件同步到Wolai不从Wolai获取内容')
.addButton(button => {
button
.setButtonText('同步到 Wolai')
.onClick(async () => {
button.setButtonText('同步中...');
button.setDisabled(true);
try {
if (!this.plugin.syncManager) {
new Notice('同步管理器未初始化');
return;
}
const result = await this.plugin.syncManager.fullSync();
if (result.obsidianToWolai > 0) {
new Notice(`已同步 ${result.obsidianToWolai} 个文件到 Wolai`);
} else {
new Notice('没有文件需要同步到 Wolai');
}
// 刷新API统计显示
this.display();
} catch (error) {
console.error('Obsidian to Wolai sync failed:', error);
new Notice('同步失败,请查看控制台日志');
} finally {
button.setButtonText('同步到 Wolai');
button.setDisabled(false);
}
});
});
// 同步状态信息
if (this.plugin.syncManager) {
const stats = this.plugin.syncManager.getSyncStats();
containerEl.createDiv({
text: `同步记录统计: 总计 ${stats.total} 个文件,已同步 ${stats.synced} 个,待同步 ${stats.pending}`,
cls: 'setting-item-description'
});
}
}
}

566
src/SyncManager.ts Normal file
View File

@ -0,0 +1,566 @@
import { Vault, TFile, Notice, TFolder } from 'obsidian';
import { WolaiAPI, WolaiDatabaseRowData, APICallStats } from './WolaiAPI';
import { MarkdownParser } from './MarkdownParser';
import { WolaiSyncSettings, SyncRecord, SyncStatus, WolaiRowSyncInfo } from './types';
import matter from 'gray-matter';
export class SyncManager {
private vault: Vault;
private wolaiAPI: WolaiAPI;
public markdownParser: MarkdownParser;
private settings: WolaiSyncSettings;
private syncRecords: Map<string, SyncRecord> = new Map();
private dataFilePath: string = '.obsidian/plugins/obsidian-wolai-sync/sync-records.json';
private syncingFiles: Set<string> = new Set(); // 正在同步的文件集合
constructor(
vault: Vault,
settings: WolaiSyncSettings
) {
this.vault = vault;
this.settings = settings;
this.wolaiAPI = new WolaiAPI(settings.wolaiAppId, settings.wolaiAppSecret);
this.markdownParser = new MarkdownParser();
// 加载同步记录
this.loadSyncRecords();
}
updateSettings(settings: WolaiSyncSettings): void {
this.settings = settings;
this.wolaiAPI = new WolaiAPI(settings.wolaiAppId, settings.wolaiAppSecret);
}
getAPICallStats(): APICallStats {
return this.wolaiAPI.getAPICallStats();
}
resetAPICallStats(): void {
this.wolaiAPI.resetAPICallStats();
}
private async loadSyncRecords(): Promise<void> {
try {
// 检查数据文件是否存在
const dataFile = this.vault.getAbstractFileByPath(this.dataFilePath);
if (!dataFile || !(dataFile instanceof TFile)) {
console.log('Sync records file not found, starting with empty records');
return;
}
// 读取并解析同步记录
const content = await this.vault.read(dataFile);
const recordsData = JSON.parse(content);
// 将数据转换为Map
this.syncRecords = new Map();
for (const [filePath, record] of Object.entries(recordsData)) {
this.syncRecords.set(filePath, record as SyncRecord);
}
console.log(`Loaded ${this.syncRecords.size} sync records`);
} catch (error) {
console.error('Error loading sync records:', error);
this.syncRecords = new Map();
}
}
private async saveSyncRecords(): Promise<void> {
try {
// 将Map转换为普通对象
const recordsData: { [key: string]: SyncRecord } = {};
for (const [filePath, record] of this.syncRecords) {
recordsData[filePath] = record;
}
// 序列化为JSON
const content = JSON.stringify(recordsData, null, 2);
// 检查数据文件是否存在
const dataFile = this.vault.getAbstractFileByPath(this.dataFilePath);
if (dataFile && dataFile instanceof TFile) {
// 更新现有文件
await this.vault.modify(dataFile, content);
} else {
// 创建新文件
await this.vault.create(this.dataFilePath, content);
}
console.log(`Saved ${this.syncRecords.size} sync records`);
} catch (error) {
console.error('Error saving sync records:', error);
}
}
async syncObsidianToWolai(filePath: string): Promise<boolean> {
try {
// 检查文件是否正在同步中
if (this.syncingFiles.has(filePath)) {
console.log(`File ${filePath} is already being synced, skipping...`);
return true;
}
// 添加到正在同步的文件集合
this.syncingFiles.add(filePath);
console.log(`Starting Obsidian→Wolai sync for file: ${filePath}`);
// 获取文件
const file = this.vault.getAbstractFileByPath(filePath) as TFile;
if (!file || !(file instanceof TFile)) {
console.error(`File not found: ${filePath}`);
return false;
}
// 读取文件内容
const content = await this.vault.read(file);
const parsedMarkdown = this.markdownParser.parseMarkdown(content);
// 检查同步状态
if (!this.markdownParser.needsSync(parsedMarkdown.frontMatter)) {
console.log(`File ${filePath} doesn't need sync (status: ${parsedMarkdown.frontMatter.sync_status})`);
return true;
}
console.log(`Parsing markdown content, found ${parsedMarkdown.blocks.length} blocks`);
console.log('Blocks to be created:', JSON.stringify(parsedMarkdown.blocks, null, 2));
// 解析并准备数据
const fileName = file.basename;
const title = this.markdownParser.extractTitle(parsedMarkdown.frontMatter, fileName);
const rowData = {
...parsedMarkdown.frontMatter,
'标题': title,
'文件名': fileName,
'文件路径': filePath,
'同步时间': new Date().toISOString(),
'同步状态': 'Synced'
};
console.log('Row data to be inserted:', JSON.stringify(rowData, null, 2));
// 插入数据库行并获取页面ID
const pageId = await this.wolaiAPI.insertDatabaseRowAndGetPageId(
this.settings.wolaiDatabaseId,
rowData
);
if (!pageId) {
console.error(`Failed to insert database row for file: ${filePath}`);
return false;
}
console.log(`Database row inserted successfully, got page ID: ${pageId}`);
// 创建块内容
if (parsedMarkdown.blocks.length > 0) {
console.log(`Creating ${parsedMarkdown.blocks.length} blocks for page ${pageId}`);
const blocksResult = await this.wolaiAPI.createBlocks(pageId, parsedMarkdown.blocks);
if (!blocksResult) {
console.error(`Failed to create blocks for file: ${filePath}`);
new Notice(`文件 ${filePath} 同步失败:无法创建块内容`);
return false;
} else {
console.log('Blocks created successfully');
}
} else {
console.log('No blocks to create (empty content)');
}
// 更新文件的同步状态
const updatedContent = this.markdownParser.updateSyncStatus(content, 'Synced', pageId);
await this.vault.modify(file, updatedContent);
// 更新同步记录
const syncRecord: SyncRecord = {
filePath: filePath,
lastModified: file.stat.mtime,
wolaiRowId: pageId,
synced: true,
hash: this.markdownParser.createHash(updatedContent)
};
this.syncRecords.set(filePath, syncRecord);
await this.saveSyncRecords();
console.log(`Successfully synced Obsidian→Wolai: ${filePath}`);
return true;
} catch (error) {
console.error(`Error syncing Obsidian→Wolai ${filePath}:`, error);
new Notice(`同步文件失败: ${filePath}`);
return false;
} finally {
// 无论成功还是失败,都要从正在同步的文件集合中移除
this.syncingFiles.delete(filePath);
}
}
async syncWolaiToObsidian(): Promise<number> {
try {
console.log('Starting Wolai→Obsidian sync...');
// 获取Wolai数据库中标记为"Wait For Syncing"的行
const databaseRows = await this.wolaiAPI.getAllDatabaseContent(this.settings.wolaiDatabaseId);
const waitingRows = databaseRows.filter(row => {
const syncStatus = row.data['同步状态']?.value;
return syncStatus === 'Pending';
});
console.log(`Found ${waitingRows.length} rows waiting for sync from Wolai`);
let successCount = 0;
for (const row of waitingRows) {
const success = await this.createOrUpdateObsidianFile(row);
if (success) {
successCount++;
}
}
return successCount;
} catch (error) {
console.error('Error syncing Wolai→Obsidian:', error);
new Notice('从 Wolai 同步失败');
return 0;
}
}
private async createOrUpdateObsidianFile(row: WolaiDatabaseRowData): Promise<boolean> {
try {
// 提取文件信息
const data = row.data;
const pageName = data['名称']?.value || data['标题']?.value || data['文件名']?.value || `Page_${row.page_id}`;
const fileName = `${pageName.replace(/[<>:"/\\|?*]/g, '_')}.md`;
const filePath = fileName; // 强制使用根据页面名称生成的文件名,不使用数据库中的"文件路径"字段
// 确保文件路径在指定的同步文件夹内
const syncFolder = this.settings.obsidianFolder;
const fullFilePath = syncFolder ? `${syncFolder}/${filePath}` : filePath;
// 创建基础的FrontMatter
const frontMatter: { [key: string]: any } = {
sync_status: 'Synced',
wolai_id: row.page_id,
last_sync: new Date().toISOString()
};
// 添加其他属性
for (const [key, value] of Object.entries(data)) {
if (!['标题', '文件名', '文件路径', '同步时间', '同步状态'].includes(key)) {
frontMatter[key] = value.value;
}
}
// 获取页面内容
console.log(`Getting content for page: ${row.page_id}`);
const pageBlocks = await this.wolaiAPI.getAllPageBlocks(row.page_id);
let markdownContent = '';
if (pageBlocks.length > 0) {
// 转换Wolai页面内容为Markdown
markdownContent = this.markdownParser.convertWolaiPageToMarkdown(pageBlocks, pageName);
console.log(`Converted ${pageBlocks.length} blocks to markdown for ${pageName}`);
} else {
// 如果没有内容,创建基础内容
markdownContent = `# ${pageName}\n\n*此页面从 Wolai 同步页面ID: ${row.page_id}*\n\n`;
console.log(`No blocks found for page ${row.page_id}, using placeholder content`);
}
// 组装完整内容
const fullContent = matter.stringify(markdownContent, frontMatter);
// 检查文件是否存在
const existingFile = this.vault.getAbstractFileByPath(fullFilePath);
if (existingFile && existingFile instanceof TFile) {
// 更新现有文件
await this.vault.modify(existingFile, fullContent);
console.log(`Updated existing file: ${fullFilePath}`);
} else {
// 创建新文件(确保目录存在)
const dirPath = fullFilePath.substring(0, fullFilePath.lastIndexOf('/'));
if (dirPath && dirPath !== '' && !this.vault.getAbstractFileByPath(dirPath)) {
try {
await this.vault.createFolder(dirPath);
console.log(`Created directory: ${dirPath}`);
} catch (error) {
console.error(`Failed to create directory ${dirPath}:`, error);
// 如果目录创建失败,尝试在根目录创建文件
}
}
try {
await this.vault.create(fullFilePath, fullContent);
console.log(`Created new file: ${fullFilePath}`);
} catch (error) {
console.error(`Failed to create file ${fullFilePath}:`, error);
throw error;
}
}
return true;
} catch (error) {
console.error('Error creating/updating Obsidian file:', error);
return false;
}
}
async fullSync(): Promise<{ obsidianToWolai: number; wolaiToObsidian: number }> {
console.log('Starting bidirectional sync...');
// 验证同步前置条件
const isValid = await this.validateSync();
if (!isValid) {
return { obsidianToWolai: 0, wolaiToObsidian: 0 };
}
// 1. 同步 Obsidian → Wolai状态为Pending或Modified的文件
const obsidianFiles = await this.getAllFilesInFolder();
const filesToSyncToWolai: string[] = [];
for (const filePath of obsidianFiles) {
const file = this.vault.getAbstractFileByPath(filePath) as TFile;
if (file && file instanceof TFile) {
const content = await this.vault.read(file);
const parsed = this.markdownParser.parseMarkdown(content);
if (this.markdownParser.needsSync(parsed.frontMatter)) {
filesToSyncToWolai.push(filePath);
}
}
}
console.log(`Found ${filesToSyncToWolai.length} Obsidian files to sync to Wolai`);
let obsidianToWolaiCount = 0;
for (const filePath of filesToSyncToWolai) {
const success = await this.syncObsidianToWolai(filePath);
if (success) {
obsidianToWolaiCount++;
}
// 添加延迟避免API限制
await new Promise(resolve => setTimeout(resolve, 500));
}
// 2. 同步 Wolai → Obsidian状态为Wait For Syncing的行
const wolaiToObsidianCount = await this.syncWolaiToObsidian();
const result = { obsidianToWolai: obsidianToWolaiCount, wolaiToObsidian: wolaiToObsidianCount };
new Notice(`双向同步完成: Obsidian→Wolai ${result.obsidianToWolai}个文件, Wolai→Obsidian ${result.wolaiToObsidian}个文件`);
return result;
}
private async getAllFilesInFolder(): Promise<string[]> {
const files: string[] = [];
if (!this.settings.obsidianFolder) {
return files;
}
const folder = this.vault.getAbstractFileByPath(this.settings.obsidianFolder);
if (!folder || !(folder instanceof TFolder)) {
console.error(`Folder not found: ${this.settings.obsidianFolder}`);
return files;
}
// 递归收集所有 Markdown 文件
const collectFiles = (currentFolder: TFolder) => {
for (const child of currentFolder.children) {
if (child instanceof TFile && child.extension === 'md') {
files.push(child.path);
} else if (child instanceof TFolder) {
collectFiles(child);
}
}
};
collectFiles(folder);
return files;
}
async scheduledSync(): Promise<void> {
console.log('Starting scheduled bidirectional sync...');
const result = await this.fullSync();
// 更新最后同步时间
this.settings.lastSyncTime = Date.now();
console.log(`Scheduled sync completed: ${result.obsidianToWolai + result.wolaiToObsidian} files synced`);
}
async validateSync(): Promise<boolean> {
// 验证Wolai连接
const isConnected = await this.wolaiAPI.validateConnection();
if (!isConnected) {
new Notice('Wolai 连接失败,请检查 API 配置');
return false;
}
// 验证设置
if (!this.settings.obsidianFolder || !this.settings.wolaiDatabaseId) {
new Notice('请先配置同步文件夹和数据库ID');
return false;
}
return true;
}
getSyncRecord(filePath: string): SyncRecord | undefined {
return this.syncRecords.get(filePath);
}
getAllSyncRecords(): Map<string, SyncRecord> {
return new Map(this.syncRecords);
}
async removeSyncRecord(filePath: string): Promise<void> {
this.syncRecords.delete(filePath);
await this.saveSyncRecords();
console.log(`Removed sync record for: ${filePath}`);
}
getSyncStats(): { total: number; synced: number; pending: number } {
const total = this.syncRecords.size;
const synced = Array.from(this.syncRecords.values()).filter(record => record.synced).length;
const pending = total - synced;
return { total, synced, pending };
}
async clearSyncRecords(): Promise<void> {
this.syncRecords.clear();
await this.saveSyncRecords();
console.log('All sync records cleared');
}
// 兼容性方法,保持向后兼容
async syncFile(filePath: string): Promise<boolean> {
return await this.syncObsidianToWolai(filePath);
}
async batchSync(filePaths: string[]): Promise<number> {
let successCount = 0;
for (const filePath of filePaths) {
const success = await this.syncObsidianToWolai(filePath);
if (success) {
successCount++;
}
// 添加延迟避免API限制
await new Promise(resolve => setTimeout(resolve, 500));
}
return successCount;
}
async forceUpdateFileStatus(filePath: string, status: SyncStatus): Promise<string> {
try {
const file = this.vault.getAbstractFileByPath(filePath) as TFile;
if (!file || !(file instanceof TFile)) {
throw new Error(`File not found: ${filePath}`);
}
const content = await this.vault.read(file);
const updatedContent = this.markdownParser.updateSyncStatus(content, status);
await this.vault.modify(file, updatedContent);
console.log(`Updated file ${filePath} sync status to ${status}`);
return updatedContent;
} catch (error) {
console.error(`Error updating file status for ${filePath}:`, error);
throw error;
}
}
async forceSyncObsidianToWolai(filePath: string): Promise<boolean> {
try {
// 强制同步,绕过同步锁和状态检查
console.log(`Starting FORCE sync Obsidian→Wolai for file: ${filePath}`);
// 获取文件
const file = this.vault.getAbstractFileByPath(filePath) as TFile;
if (!file || !(file instanceof TFile)) {
console.error(`File not found: ${filePath}`);
return false;
}
// 读取文件内容
const content = await this.vault.read(file);
const parsedMarkdown = this.markdownParser.parseMarkdown(content);
console.log(`Parsing markdown content, found ${parsedMarkdown.blocks.length} blocks`);
console.log('Blocks to be created:', JSON.stringify(parsedMarkdown.blocks, null, 2));
// 解析并准备数据
const fileName = file.basename;
const title = this.markdownParser.extractTitle(parsedMarkdown.frontMatter, fileName);
const rowData = {
...parsedMarkdown.frontMatter,
'标题': title,
'文件名': fileName,
'文件路径': filePath,
'同步时间': new Date().toISOString(),
'同步状态': 'Synced'
};
console.log('Row data to be inserted:', JSON.stringify(rowData, null, 2));
// 插入数据库行并获取页面ID
const pageId = await this.wolaiAPI.insertDatabaseRowAndGetPageId(
this.settings.wolaiDatabaseId,
rowData
);
if (!pageId) {
console.error(`Failed to insert database row for file: ${filePath}`);
return false;
}
console.log(`Database row inserted successfully, got page ID: ${pageId}`);
// 创建块内容
if (parsedMarkdown.blocks.length > 0) {
console.log(`Creating ${parsedMarkdown.blocks.length} blocks for page ${pageId}`);
const blocksResult = await this.wolaiAPI.createBlocks(pageId, parsedMarkdown.blocks);
if (!blocksResult) {
console.error(`Failed to create blocks for file: ${filePath}`);
new Notice(`文件 ${filePath} 强制同步失败:无法创建块内容`);
return false;
} else {
console.log('Blocks created successfully');
}
} else {
console.log('No blocks to create (empty content)');
}
// 更新文件的同步状态
const updatedContent = this.markdownParser.updateSyncStatus(content, 'Synced', pageId);
await this.vault.modify(file, updatedContent);
// 更新同步记录
const syncRecord: SyncRecord = {
filePath: filePath,
lastModified: file.stat.mtime,
wolaiRowId: pageId,
synced: true,
hash: this.markdownParser.createHash(updatedContent)
};
this.syncRecords.set(filePath, syncRecord);
await this.saveSyncRecords();
console.log(`Successfully FORCE synced Obsidian→Wolai: ${filePath}`);
return true;
} catch (error) {
console.error(`Error force syncing Obsidian→Wolai ${filePath}:`, error);
new Notice(`强制同步文件失败: ${filePath}`);
return false;
}
}
}

481
src/WolaiAPI.ts Normal file
View File

@ -0,0 +1,481 @@
import { Notice } from 'obsidian';
import {
WolaiToken,
WolaiTokenResponse,
WolaiCreateBlocksRequest,
WolaiCreateBlocksResponse,
WolaiInsertRowsRequest,
WolaiInsertRowsResponse,
WolaiDatabaseRow,
WolaiBlock,
WolaiPageResponse,
WolaiPageBlock
} from './types';
export interface WolaiDatabaseContent {
column_order: string[];
rows: WolaiDatabaseRowData[];
}
export interface WolaiDatabaseRowData {
page_id: string;
data: { [key: string]: any };
}
export interface WolaiDatabaseResponse {
data?: WolaiDatabaseContent;
message?: string;
error_code?: number;
status_code?: number;
}
export interface APICallStats {
total: number;
today: number;
lastReset: number;
}
export class WolaiAPI {
private baseUrl = 'https://openapi.wolai.com/v1';
private token: string | null = null;
private tokenExpireTime: number = 0;
private apiCallStats: APICallStats = {
total: 0,
today: 0,
lastReset: new Date().setHours(0, 0, 0, 0) // 今天0点
};
constructor(
private appId: string,
private appSecret: string
) {}
private incrementAPICall(): void {
const today = new Date().setHours(0, 0, 0, 0);
// 如果是新的一天,重置今日计数
if (today > this.apiCallStats.lastReset) {
this.apiCallStats.today = 0;
this.apiCallStats.lastReset = today;
}
this.apiCallStats.total++;
this.apiCallStats.today++;
console.log(`API Call Count - Total: ${this.apiCallStats.total}, Today: ${this.apiCallStats.today}`);
}
getAPICallStats(): APICallStats {
const today = new Date().setHours(0, 0, 0, 0);
// 如果是新的一天,重置今日计数
if (today > this.apiCallStats.lastReset) {
this.apiCallStats.today = 0;
this.apiCallStats.lastReset = today;
}
return { ...this.apiCallStats };
}
resetAPICallStats(): void {
this.apiCallStats = {
total: 0,
today: 0,
lastReset: new Date().setHours(0, 0, 0, 0)
};
console.log('API call stats reset');
}
async createToken(): Promise<string | null> {
try {
const response = await fetch(`${this.baseUrl}/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
appId: this.appId,
appSecret: this.appSecret
})
});
const data: WolaiTokenResponse = await response.json();
if (data.data) {
this.token = data.data.app_token;
this.tokenExpireTime = data.data.expire_time;
console.log('Wolai token created successfully');
// 只有成功时才计入统计
this.incrementAPICall();
return this.token;
} else {
new Notice('获取 Wolai Token 失败,请检查 AppID 和 AppSecret');
return null;
}
} catch (error) {
console.error('Error creating Wolai token:', error);
new Notice('网络错误:无法连接到 Wolai API');
return null;
}
}
async getValidToken(): Promise<string | null> {
// 检查token是否存在且未过期-1表示永不过期
if (this.token && (this.tokenExpireTime === -1 || Date.now() < this.tokenExpireTime)) {
return this.token;
}
// 重新获取token
return await this.createToken();
}
async validateConnection(): Promise<boolean> {
const token = await this.getValidToken();
return token !== null;
}
async insertDatabaseRow(databaseId: string, rowData: WolaiDatabaseRow): Promise<string | null> {
const token = await this.getValidToken();
if (!token) {
return null;
}
try {
const response = await fetch(`${this.baseUrl}/databases/${databaseId}/rows`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token
},
body: JSON.stringify({
rows: [rowData]
})
});
const data: WolaiInsertRowsResponse = await response.json();
if (data.data && data.data.length > 0) {
console.log('Database row inserted successfully:', data.data[0]);
// 只有成功时才计入统计
this.incrementAPICall();
return data.data[0]; // 返回新创建行的链接
} else {
console.error('Failed to insert database row:', data.message);
new Notice(`插入数据库行失败: ${data.message}`);
return null;
}
} catch (error) {
console.error('Error inserting database row:', error);
new Notice('网络错误:无法插入数据库行');
return null;
}
}
private extractPageIdFromUrl(url: string): string | null {
// 从Wolai页面URL中提取页面ID
// URL格式: https://www.wolai.com/{pageId}
const match = url.match(/wolai\.com\/([a-zA-Z0-9]+)/);
return match ? match[1] : null;
}
async insertDatabaseRowAndGetPageId(databaseId: string, rowData: WolaiDatabaseRow): Promise<string | null> {
const rowUrl = await this.insertDatabaseRow(databaseId, rowData);
if (!rowUrl) {
return null;
}
return this.extractPageIdFromUrl(rowUrl);
}
async getDatabaseContent(databaseId: string, pageSize: number = 200, startCursor?: string): Promise<WolaiDatabaseContent | null> {
const token = await this.getValidToken();
if (!token) {
return null;
}
try {
let url = `${this.baseUrl}/databases/${databaseId}?page_size=${pageSize}`;
if (startCursor) {
url += `&start_cursor=${startCursor}`;
}
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': token
}
});
const responseData: WolaiDatabaseResponse = await response.json();
if (responseData.data) {
console.log(`Retrieved ${responseData.data.rows.length} database rows`);
// 只有成功时才计入统计
this.incrementAPICall();
return responseData.data;
} else {
console.error('Failed to get database content:', responseData.message);
new Notice(`获取数据库内容失败: ${responseData.message}`);
return null;
}
} catch (error) {
console.error('Error getting database content:', error);
new Notice('网络错误:无法获取数据库内容');
return null;
}
}
async getAllDatabaseContent(databaseId: string): Promise<WolaiDatabaseRowData[]> {
const allRows: WolaiDatabaseRowData[] = [];
let startCursor: string | undefined = undefined;
let hasMore = true;
while (hasMore) {
const content = await this.getDatabaseContent(databaseId, 200, startCursor);
if (!content) {
break;
}
allRows.push(...content.rows);
// 检查是否有更多页面这需要根据实际API响应结构调整
// 如果API返回分页信息这里需要相应处理
hasMore = false; // 暂时设为false等确认API分页响应格式后调整
}
console.log(`Retrieved total ${allRows.length} database rows`);
return allRows;
}
async createBlocks(parentId: string, blocks: WolaiBlock[]): Promise<string | null> {
const token = await this.getValidToken();
if (!token) {
return null;
}
try {
// 分层处理块:先创建顶级块,再创建嵌套块
const topLevelBlocks = blocks.filter(block => !(block as any).needsParent);
const nestedBlocks = blocks.filter(block => (block as any).needsParent);
// 创建顶级块
if (topLevelBlocks.length > 0) {
await this.createBlocksBatch(parentId, topLevelBlocks);
}
// 对于嵌套块,目前暂时作为顶级块处理
// TODO: 实现真正的嵌套块创建逻辑
if (nestedBlocks.length > 0) {
// 清理临时属性
const cleanedNestedBlocks = nestedBlocks.map(block => {
const cleanBlock = { ...block };
delete (cleanBlock as any).depth;
delete (cleanBlock as any).needsParent;
return cleanBlock;
});
await this.createBlocksBatch(parentId, cleanedNestedBlocks);
}
return parentId; // 返回父页面ID
} catch (error) {
console.error('Error creating blocks:', error);
new Notice('网络错误:无法创建块');
return null;
}
}
private async createBlocksBatch(parentId: string, blocks: WolaiBlock[]): Promise<boolean> {
const token = await this.getValidToken();
if (!token) {
return false;
}
// 分批处理块每批最多20个
const batchSize = 20;
for (let i = 0; i < blocks.length; i += batchSize) {
const batch = blocks.slice(i, i + batchSize);
const response = await fetch(`${this.baseUrl}/blocks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token
},
body: JSON.stringify({
parent_id: parentId,
blocks: batch
})
});
// 检查HTTP状态码
if (!response.ok) {
console.error(`Failed to create batch ${Math.floor(i / batchSize) + 1} blocks: HTTP ${response.status} ${response.statusText}`);
let errorMessage = `HTTP ${response.status} ${response.statusText}`;
try {
// 尝试解析错误响应
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const errorData = await response.json();
errorMessage = errorData.message || errorMessage;
} else {
// 如果不是JSON响应获取文本内容
const errorText = await response.text();
errorMessage = errorText.substring(0, 200) || errorMessage;
}
} catch (parseError) {
console.error('Failed to parse error response:', parseError);
}
new Notice(`创建块失败: ${errorMessage}`);
return false;
}
// 解析成功响应
let data: WolaiCreateBlocksResponse;
try {
data = await response.json();
} catch (parseError) {
console.error(`Failed to parse response JSON for batch ${Math.floor(i / batchSize) + 1}:`, parseError);
new Notice('创建块失败: 响应格式错误');
return false;
}
if (data.data) {
console.log(`Batch ${Math.floor(i / batchSize) + 1} blocks created successfully:`, data.data);
// 只有成功时才计入统计
this.incrementAPICall();
} else {
console.error(`Failed to create batch ${Math.floor(i / batchSize) + 1} blocks:`, data.message || 'Unknown error');
new Notice(`创建块失败: ${data.message || 'Unknown error'}`);
return false;
}
}
return true;
}
async retryWithExponentialBackoff<T>(
operation: () => Promise<T>,
maxRetries: number = 3,
baseDelay: number = 1000
): Promise<T | null> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
if (attempt === maxRetries) {
console.error(`Operation failed after ${maxRetries + 1} attempts:`, error);
return null;
}
const delay = baseDelay * Math.pow(2, attempt);
console.log(`Attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
return null;
}
async getPageContent(pageId: string): Promise<WolaiPageBlock[] | null> {
const token = await this.getValidToken();
if (!token) {
return null;
}
try {
const response = await fetch(`${this.baseUrl}/blocks/${pageId}/children`, {
method: 'GET',
headers: {
'Authorization': token
}
});
if (!response.ok) {
console.error(`Failed to get page content: HTTP ${response.status} ${response.statusText}`);
new Notice(`获取页面内容失败: HTTP ${response.status}`);
return null;
}
const data: WolaiPageResponse = await response.json();
if (data.data) {
console.log(`Retrieved page content with ${data.data.length} blocks`);
// 只有成功时才计入统计
this.incrementAPICall();
return data.data;
} else {
console.error('Failed to get page content:', data.message);
new Notice(`获取页面内容失败: ${data.message}`);
return null;
}
} catch (error) {
console.error('Error getting page content:', error);
new Notice('网络错误:无法获取页面内容');
return null;
}
}
async getAllPageBlocks(pageId: string): Promise<WolaiPageBlock[]> {
const pageContent = await this.getPageContent(pageId);
if (!pageContent) {
return [];
}
// 递归获取所有子块
const allBlocks = await this.expandBlocksWithChildren(pageContent);
return allBlocks;
}
private async expandBlocksWithChildren(blocks: WolaiPageBlock[]): Promise<WolaiPageBlock[]> {
const expandedBlocks: WolaiPageBlock[] = [];
for (const block of blocks) {
// 添加当前块
expandedBlocks.push(block);
// 检查是否有子块需要获取
if (block.children && block.children.ids && block.children.ids.length > 0) {
console.log(`Block ${block.id} has ${block.children.ids.length} children, fetching...`);
try {
// 获取子块内容
const childBlocks = await this.getPageContent(block.id);
if (childBlocks && childBlocks.length > 0) {
console.log(`Retrieved ${childBlocks.length} child blocks for ${block.id}`);
// 递归处理子块的子块
const expandedChildBlocks = await this.expandBlocksWithChildren(childBlocks);
// 为子块添加缩进信息
const indentedChildBlocks = expandedChildBlocks.map(childBlock => ({
...childBlock,
isChildBlock: true,
parentBlockId: block.id,
depth: (block as any).depth ? (block as any).depth + 1 : 1
}));
expandedBlocks.push(...indentedChildBlocks);
}
} catch (error) {
console.error(`Error fetching children for block ${block.id}:`, error);
// 继续处理其他块,不因为单个子块获取失败而停止
}
// 添加延迟避免API限制
await new Promise(resolve => setTimeout(resolve, 200));
}
}
return expandedBlocks;
}
}

150
src/types.ts Normal file
View File

@ -0,0 +1,150 @@
// Wolai API 相关类型定义
export interface WolaiSyncSettings {
obsidianFolder: string; // Obsidian 同步文件夹路径
wolaiDatabaseId: string; // Wolai 数据库ID
wolaiAppId: string; // Wolai AppID
wolaiAppSecret: string; // Wolai AppSecret
syncInterval: number; // 同步间隔(分钟)
autoSync: boolean; // 是否启用自动同步
enableFileWatcher: boolean; // 是否启用文件监听器
lastSyncTime: number; // 上次同步时间戳
}
export interface SyncRecord {
filePath: string; // 文件路径
lastModified: number; // 最后修改时间
wolaiRowId: string; // Wolai 行ID
synced: boolean; // 是否已同步
hash: string; // 文件内容哈希
}
// 同步状态枚举
export type SyncStatus = 'Synced' | 'Pending' | 'Modified' | 'Wait For Syncing';
// Obsidian文件的FrontMatter同步信息
export interface FileSyncInfo {
sync_status: SyncStatus;
wolai_id?: string;
last_sync?: string;
}
// Wolai数据库行同步信息
export interface WolaiRowSyncInfo {
page_id: string;
sync_status: SyncStatus;
obsidian_path?: string;
last_sync?: string;
}
export interface WolaiToken {
app_token: string;
app_id: string;
create_time: number;
expire_time: number;
update_time: number;
}
export interface WolaiTokenResponse {
data: WolaiToken | null;
}
// Wolai CreateRichText 类型定义
export interface WolaiRichText {
type?: 'text' | 'equation';
title: string;
bold?: boolean;
italic?: boolean;
underline?: boolean;
highlight?: boolean;
strikethrough?: boolean;
inline_code?: boolean;
front_color?: string;
back_color?: string;
link?: string; // 链接URL
}
export type CreateRichText = string | WolaiRichText | (string | WolaiRichText)[];
export interface WolaiBlock {
type: string;
content?: CreateRichText;
block_front_color?: string;
block_back_color?: string;
text_alignment?: string;
block_alignment?: string;
level?: number;
language?: string; // 代码块语言属性
parent_id?: string; // 父块ID用于嵌套块
}
export interface WolaiCreateBlocksRequest {
parent_id: string;
blocks: WolaiBlock[];
}
export interface WolaiCreateBlocksResponse {
data?: string;
message?: string;
error_code?: number;
status_code?: number;
}
export interface WolaiDatabaseRow {
[key: string]: any;
}
export interface WolaiInsertRowsRequest {
rows: WolaiDatabaseRow[];
}
export interface WolaiInsertRowsResponse {
data?: string[];
message?: string;
error_code?: number;
status_code?: number;
}
export interface ParsedMarkdown {
frontMatter: { [key: string]: any };
content: string;
blocks: WolaiBlock[];
}
// Remark AST 节点类型
export interface MarkdownNode {
type: string;
children?: MarkdownNode[];
value?: string;
depth?: number;
ordered?: boolean;
checked?: boolean | null;
lang?: string;
meta?: string;
url?: string;
title?: string;
alt?: string;
}
export interface WolaiBlockChildren {
ids: string[];
api_url: string;
}
export interface WolaiPageBlock {
id: string;
type: string;
content?: CreateRichText;
children?: WolaiBlockChildren;
level?: number;
language?: string;
text_alignment?: string;
block_alignment?: string;
block_front_color?: string;
block_back_color?: string;
}
export interface WolaiPageResponse {
data?: WolaiPageBlock[];
message?: string;
error_code?: number;
status_code?: number;
}

View File

@ -1,8 +0,0 @@
/*
This CSS file will be included with your plugin, and
available in the app when your plugin is enabled.
If your plugin does not need CSS, delete this file.
*/

9
sync-records.json Normal file
View File

@ -0,0 +1,9 @@
{
"🌈无限进步/Mail Piler 邮件归档服务器安装.md": {
"filePath": "🌈无限进步/Mail Piler 邮件归档服务器安装.md",
"lastModified": 1748920939375,
"wolaiRowId": "5V5CymRzoayNnMPx7oyJgk",
"synced": true,
"hash": "3e45e191"
}
}

1
todo.md Normal file
View File

@ -0,0 +1 @@
- [ ] 一键生成一个可以同步的数据表格

View File

@ -11,6 +11,8 @@
"importHelpers": true, "importHelpers": true,
"isolatedModules": true, "isolatedModules": true,
"strictNullChecks": true, "strictNullChecks": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"lib": [ "lib": [
"DOM", "DOM",
"ES5", "ES5",
@ -19,6 +21,7 @@
] ]
}, },
"include": [ "include": [
"**/*.ts" "**/*.ts",
"src/**/*.ts"
] ]
} }