init: first revision
This commit is contained in:
parent
1b06abdbbf
commit
62c30367b2
|
@ -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
284
README.md
|
@ -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+
|
||||||
|
|
|
@ -0,0 +1,393 @@
|
||||||
|
# Wolai API
|
||||||
|
通过wolai开放API,可以访问wolai 的块、页面、数据库等(未来开放),在wolai开发者中心创建一个应用,将应用连接到wolai,实现更多自有场景的数据连接。wolai开放API采用RESTful规范,通过 GET,POST...请求来获取数据。发送所有请求的 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”来获取。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 返回参数
|
||||||
|
|
||||||
|
可访问的块链接
|
||||||
|
|
||||||
|
### 请求示例* *
|
||||||
|
|
||||||
|
```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。
|
||||||
|
|
||||||
|

|
||||||
|
- 选择块菜单旁边的 **复制引用视图链接**,/后 ?前面的中间部分为数据表格块 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。
|
||||||
|
|
||||||
|

|
||||||
|
- 选择块菜单旁边的 **复制引用视图链接**,/后 ?前面的中间部分为数据表格块 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)
|
||||||
|
|
|
@ -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
380
main.ts
|
@ -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();
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
12
package.json
12
package.json
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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}]*`;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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.
|
|
||||||
|
|
||||||
*/
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"🌈无限进步/Mail Piler 邮件归档服务器安装.md": {
|
||||||
|
"filePath": "🌈无限进步/Mail Piler 邮件归档服务器安装.md",
|
||||||
|
"lastModified": 1748920939375,
|
||||||
|
"wolaiRowId": "5V5CymRzoayNnMPx7oyJgk",
|
||||||
|
"synced": true,
|
||||||
|
"hash": "3e45e191"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue