diff --git a/.gitignore b/.gitignore index e09a007..03f18a5 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ data.json # Exclude macOS Finder (System Explorer) View States .DS_Store + +pnpm-lock.yaml \ No newline at end of file diff --git a/README.md b/README.md index c773152..b277ff6 100644 --- a/README.md +++ b/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. -- Adds a ribbon icon, which shows a Notice when clicked. -- Adds a command "Open Sample Modal" which opens a Modal. -- Adds a plugin setting tab to the settings page. -- Registers a global click event and output 'click' to the console. -- Registers a global interval which logs 'setInterval' to the console. +- **🔄 双向同步**: 支持 Obsidian → Wolai 和 Wolai → Obsidian 的双向内容同步 +- **📝 富文本支持**: 完整支持粗体、斜体、行内代码、删除线、链接等 Markdown 格式 +- **🧱 多种块类型**: 支持标题、段落、列表、代码块、引用、分割线等常见 Markdown 元素 +- **📊 状态管理**: 基于 FrontMatter 的智能同步状态跟踪,避免重复同步 +- **📈 API 统计**: 实时监控 API 调用次数,防止超出 Wolai API 限制 +- **⚡ 手动触发**: 精确控制同步时机,避免不必要的 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). -- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder. -- Install NodeJS, then run `npm i` in the command line under your repo folder. -- Run `npm run dev` to compile your plugin from `main.ts` to `main.js`. -- 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. +### 方法二:开发者安装 +1. 克隆或下载此仓库 +2. 在项目目录中运行 `npm install` 安装依赖 +3. 运行 `npm run build` 构建插件 +4. 将生成的 `main.js`、`manifest.json`、`styles.css` 复制到 `.obsidian/plugins/obsidian-wolai-sync/` -## 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. -- 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 -- 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 进行团队协作 +- **内容发布工作流**: 在 Obsidian 中编写文档,自动同步到 Wolai 进行发布 +- **双向数据备份**: 确保重要内容在两个平台上都有备份 +- **团队协作**: 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`. -> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json` +### Wolai 数据库要求 -## 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. -- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin. +| 字段名 | 类型 | 必需 | 说明 | +|--------|------|------|------| +| 标题 | 文本 | ✅ | 对应 Obsidian 文件的标题 | +| 同步状态 | 单选 | ✅ | 值包括: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) -- [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. -- To use eslint with this project, make sure to install eslint from terminal: - - `npm install -g eslint` -- 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\` +1. 访问 Wolai 开发者中心 +2. 创建新应用 +3. 获取 **App ID** 和 **App Secret** +4. 将应用连接到你的 Wolai 工作区 -## 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 -{ - "fundingUrl": "https://buymeacoffee.com" -} +### 3. 同步设置 + +- **启用文件监听器**: 默认关闭,开启后会自动监听文件变化 +- **自动同步**: 定时自动同步功能 +- **同步间隔**: 自动同步的时间间隔(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": { - "Buy Me a Coffee": "https://buymeacoffee.com", - "GitHub Sponsor": "https://github.com/sponsors", - "Patreon": "https://www.patreon.com/" - } -} +## 🔧 为什么设计为手动同步? + +### Wolai API 调用限制 + +基于 Wolai OpenAPI 的以下限制,我们采用了手动同步的设计: + +#### 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+ diff --git a/docs/WolaiAPI.md b/docs/WolaiAPI.md new file mode 100644 index 0000000..20ba442 --- /dev/null +++ b/docs/WolaiAPI.md @@ -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”来获取。 + + ![](https://secure2.wostatic.cn/static/ktT6UgZzBe5ZCmfZwh2jtC/image.png?auth_key=1748942946-exth5iYLWT8w5uaCz5cBPQ-0-c723d3d045c7dbdb2c2cfca99be8d8bf) + + ![](https://secure2.wostatic.cn/static/iTxsrNA6ETPGcffhUdNFUT/image.png?auth_key=1748942946-f3ze7FTwXq9s4KRJEEBHeT-0-b26d5eb066a617c369607bcf01d463b4) + + + +### 返回参数 + +可访问的块链接 + +### 请求示例* * + +```JSON +{ + "parent_id": "父块ID", + "blocks": [ + { + "type": "text", + "content": "Hello ", + "text_alignment": "center" + }, + { + "type": "heading", + "level": 1, + "content": { + "title": "World!", + "front_color": "red" + }, + "text_alignment": "center" + } + ] +} + +``` + + + +### 返回示例 + +```JSON +//成功 +{ + "data":"https://www.wolai.com/o4icCBRxnvHS99j1RRAuAp#iTZum4zcxGNRpdeVcVvzWw" +} + +//失败 +{ + "message": "Token 未填写, 请检查 Header 中 Authorization 字段内是否填写 Token, Token 相关说明请参考:https://www.wolai.com/eLKwqTsGHaXV6SvjedEt43:", + "error_code": 17003, + "status_code": 401 +} +``` + +# 插入数据 POST /databases/{id}/rows +### 接口描述 + +插入数据表格行数据 + +如果插入多选/单选列中包含目前数据表格该列中不存在的选项,会对该多选/单选列自动增加选项。 + +### 请求地址 + +POST ** **`/databases/{id}/rows` + +### 获取数据表格 ID + +**数据表格嵌入块** + +- 从数据表格菜单中获取,选择 下的复制访问链接,并取出链接最后的 ID。 + + ![](https://secure2.wostatic.cn/static/fGYrKkYMQ6miSPxXYgvqmC/image.png?auth_key=1748942760-r6L4Qq7FZ2PVadbCPZBuay-0-80b1abe7088c16b0f2b2680fc766ed5f) +- 选择块菜单旁边的 **复制引用视图链接**,/后 ?前面的中间部分为数据表格块 ID,示例: + + [https://www.wolai.com/5FSqWmTrdAXBXo3EQhDCq4?viewId=s9k2EPzfqUaVSRXPnKGb](https://www.wolai.com/5FSqWmTrdAXBXo3EQhDCq4?viewId=s9k2EPzfqUaVSRXPnKGb) + + 其中 `5FSqWmTrdAXBXo3EQhDCq4` 为数据表格块 ID。 + +**数据表格页面** + +- 从页面域名中获取,示例:[https://www.wolai.com/wolaiteam/4prhMAfrPzoipvkXhPKD34](https://www.wolai.com/wolaiteam/4prhMAfrPzoipvkXhPKD34) 其中`4prhMAfrPzoipvkXhPKD34`为数据表格块 ID。 +- 从数据表格菜单中获取,方法同上。 + +### *请求参数 [Request Path]* + +|||| +|-|-|-| +|**Id** *string*|数据表格块 ID 可以在页面域名内或者数据表格全局菜单获取|`必填`| + + +### *请求参数 [Request Body]* + +|||| +|-|-|-| +|**rows** CreateDatabaseRow[]|要插入的多行数据数组,最多支持单次插入 20行。|`必填`| + + + + +### *返回参数 * + +||| +|-|-| +|**data** *string*[]|数据行页面链接列表| + + +### *请求示例 * + +```JSON +POST https://openapi.wolai.com/v1/databases/c1YSDeeFUKXddmFtV1wTu9/rows + +{ + "rows": [{ + "标题": "标题", + "多选列": ["1", "2"], + "数字": 12, + "CheckBox": false + }] +} +``` + +### *返回示例 * + +```JSON +//成功 +{ + "data": ["https://www.wolai.com/c1YSDeeFUKXddmFtV1wTu9"] +} +} +//失败 +{ + "message": "缺少请求体 Body", + "error_code": 17001, + "status_code": 400 +} +``` + +# 获取表格内容 GET /databases/{id} + + +### 接口描述 + +获取数据表格内容 + +### 请求地址 + +**GET** ** **`/databases/{id}` + +### 获取数据表格 ID + +**数据表格嵌入块** + +- 从数据表格菜单中获取,选择 下的复制访问链接,并取出链接最后的 ID。 + + ![](https://secure2.wostatic.cn/static/fGYrKkYMQ6miSPxXYgvqmC/image.png?auth_key=1748959068-wAhNL72dNEU7j96Rmugo2w-0-11082dd25b5924babcfd38a212645c36) +- 选择块菜单旁边的 **复制引用视图链接**,/后 ?前面的中间部分为数据表格块 ID,示例: + + [https://www.wolai.com/5FSqWmTrdAXBXo3EQhDCq4?viewId=s9k2EPzfqUaVSRXPnKGb](https://www.wolai.com/5FSqWmTrdAXBXo3EQhDCq4?viewId=s9k2EPzfqUaVSRXPnKGb) + + 其中 `5FSqWmTrdAXBXo3EQhDCq4` 为数据表格块 ID。 + +**数据表格页面** + +- 从页面域名中获取,示例:[https://www.wolai.com/wolaiteam/4prhMAfrPzoipvkXhPKD34](https://www.wolai.com/wolaiteam/4prhMAfrPzoipvkXhPKD34) 其中`4prhMAfrPzoipvkXhPKD34`为数据表格块 ID。 +- 从数据表格菜单中获取,方法同上。 + +### *请求参数 [Request Path]* + +|||| +|-|-|-| +|**blockId** *string*|数据表格块 ID 可以在页面域名内或者数据表格全局菜单获取|`必填`| + + +### *返回参数 * + +||| +|-|-| +|**ColumnOrder** *string[]*|列顺序,以列名排序| +|**Rows** DatabaseRowData*[]*|数据表格内容,数据表格行列表,每行包含每列对应的数据| + + + + +### *请求示例 * + +```JSON +GET https://openapi.wolai.com/v1/databases/c1YSDeeFUKXddmFtV1wTu9 +``` + +### *返回示例 * + +```JSON +//成功 +{ + "data": { + "column_order": [ + "标题", + "标签" + ], + "rows": [ + { + "page_id": "4YRtvKiYMQBXGvjz7Y52hC", //行对应的页面 ID + "data": { + "标题": { + "type": "primary", + "value": "测试" + }, + "标签": { + "type": "select", + "value": "待完成" + } + } + } + ] + } +} +//失败 +{ + "message": "Token 未填写, 请检查 Header 中 Authorization 字段内是否填写 Token, Token 相关说明请参考:https://www.wolai.com/wolai/a3qaYWF3P3SWUGxWPvxTjP", + "error_code": 17003, + "status_code": 401 +} +``` + +# 分页参数 +### 请求 + +资源分页都接受以下请求参数(query paramters) + +如: `/block/{id}/children?page_size=10&start_cursor=cursor_id` + +|**参数**|**类型**|**描述**| +|-|-|-| +|`start_cursor`|`string`(可选的)|从上一个响应中返回的`cursor`,用于请求下一页的结果。 默认值: `undefined`,表示从列表的开始返回结果。| +|`page_size`|`number`(可选的)|响应中需要的完整列表中的项目数量。 默认值:`200 `最多:`200 `响应可能包含少于这个数量的结果。| + + +### 响应 + +资源的批量请求会返回以下参数: + +|**字段**|**类型**|**描述**| +|-|-|-| +|`has_more`|`boolean`|当响应包括列表的结尾时,为`false`。否则,为`true`。| +|`next_cursor`|`string`|只有当`has_more`为 `true`时才可用。 用来检索下一页的结果,方法是将该值作为`start_cursor`参数传递给同一个端点。| + +# 块类型 + +[Block](https://www.wolai.com/sBh7HkJUCtEMVcDF8xo9Gz) + +[BlockTypes](https://www.wolai.com/2RsvgCzmLo5fvQrDxfSXyW) + +[BlockBackColors](https://www.wolai.com/bCwb12wh1ke4hYb2GiNHcS) + +[BlockFrontColors](https://www.wolai.com/4wCxFNa2kScj9tRUMRhPzG) + +[InlineTitleType](https://www.wolai.com/2hVfKgFPjZVN8vd7FQpACX) + +[ RichText](https://www.wolai.com/mYn9ePcFv7UzHezoLr3CXE) + +[BlockAlign](https://www.wolai.com/quouz2gwGy7dqnwHWkfZJu) + +[TextAlign](https://www.wolai.com/bwvNHsJmknpnsgdNHKyTAE) + +[CreateRichText](https://www.wolai.com/sCbv85cCmDNvH6mkvANvwj) + +[HeadingLevel](https://www.wolai.com/1hsY7VqXPHov43Qz4booyT) + +[EmojiIcon](https://www.wolai.com/wjXib8ykTN19MNd6LmpTKW) + +[LinkIcon](https://www.wolai.com/faU5M5NK2KYdfNurfu6yfX) + +[CodeSetting](https://www.wolai.com/cKn5FuFjnmHxCxzcHmpn7a) + +[CodeLanguage](https://www.wolai.com/3Uimn9YiAtFQMgFQGxn5BU) + +[LinkCover](https://www.wolai.com/3Bbc25FEey8JULGkdc4BSR) + +[TodoListProStatus](https://www.wolai.com/qJRTMukqYLKdLzjEY2dz11) + +[PageSetting](https://www.wolai.com/79S6Lw12HDXq2GE1EmuUKE) + +[鼠标悬停](https://www.wolai.com/mvpw6R5PxsjqyvFYgqGetQ) + +[分页参数](https://www.wolai.com/83f7rDQPRFCcLwwnjhPMkP) + diff --git a/docs/WolaiBlockType.md b/docs/WolaiBlockType.md new file mode 100644 index 0000000..0abf101 --- /dev/null +++ b/docs/WolaiBlockType.md @@ -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|背景色| + + diff --git a/main.ts b/main.ts index 2d07212..e1921ec 100644 --- a/main.ts +++ b/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! -interface MyPluginSettings { - mySetting: string; +const DEFAULT_SETTINGS: WolaiSyncSettings = { + obsidianFolder: '', + wolaiDatabaseId: '', + wolaiAppId: '', + wolaiAppSecret: '', + syncInterval: 20, + autoSync: true, + enableFileWatcher: false, + lastSyncTime: 0 } -const DEFAULT_SETTINGS: MyPluginSettings = { - mySetting: 'default' -} - -export default class MyPlugin extends Plugin { - settings: MyPluginSettings; +export default class WolaiSyncPlugin extends Plugin { + settings: WolaiSyncSettings; + syncManager: SyncManager; + fileWatcher: FileWatcher; + statusBarItemEl: HTMLElement; + syncIntervalId: number | null = null; async onload() { + console.log('Plugin: Obsidian Wolai Sync loaded'); + new Notice('Wolai Sync plugin loaded'); await this.loadSettings(); + // 初始化同步管理器 + this.syncManager = new SyncManager(this.app.vault, this.settings); + + // 初始化文件监听器 + this.initializeFileWatcher(); + // This creates an icon in the left ribbon. - const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => { - // Called when the user clicks the icon. - new Notice('This is a notice!'); + const ribbonIconEl = this.addRibbonIcon('refresh-ccw-dot', 'Wolai Sync', async (evt: MouseEvent) => { + // 执行手动同步 + await this.performManualSync(); }); // Perform additional things with the ribbon ribbonIconEl.addClass('my-plugin-ribbon-class'); // This adds a status bar item to the bottom of the app. Does not work on mobile apps. - const statusBarItemEl = this.addStatusBarItem(); - statusBarItemEl.setText('Status Bar Text'); - - // 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.statusBarItemEl = this.addStatusBarItem(); + this.updateStatusBar('Ready'); // 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) - // Using this function will automatically remove the event listener when this plugin is disabled. - this.registerDomEvent(document, 'click', (evt: MouseEvent) => { - console.log('click', evt); + // 添加强制同步当前文件的Command + this.addCommand({ + id: 'force-sync-current-file', + 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. - this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000)); + // // If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin) + // // 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() { + 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() { @@ -88,6 +100,232 @@ export default class MyPlugin extends Plugin { async saveSettings() { 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 { + 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 { + 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 { + if (!this.syncManager) { + return false; + } + return await this.syncManager.validateSync(); + } + + async manualSyncFromSettings(): Promise { + 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 { + 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(); } } - -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(); - })); - } -} diff --git a/manifest.json b/manifest.json index dfa940e..d6bbe62 100644 --- a/manifest.json +++ b/manifest.json @@ -1,11 +1,11 @@ { - "id": "sample-plugin", - "name": "Sample Plugin", + "id": "wolai-sync", + "name": "Wolai Sync", "version": "1.0.0", "minAppVersion": "0.15.0", - "description": "Demonstrates some of the capabilities of the Obsidian API.", - "author": "Obsidian", - "authorUrl": "https://obsidian.md", + "description": "Sync Wolai Databases and obsidian folders.", + "author": "Li Wei", + "authorUrl": "https://marsway.red", "fundingUrl": "https://obsidian.md/pricing", "isDesktopOnly": false } diff --git a/package.json b/package.json index 6a00766..217b91d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "obsidian-sample-plugin", + "name": "obsidian-wolai-sync", "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", "scripts": { "dev": "node esbuild.config.mjs", @@ -9,7 +9,7 @@ "version": "node version-bump.mjs && git add manifest.json versions.json" }, "keywords": [], - "author": "", + "author": "Marsway", "license": "MIT", "devDependencies": { "@types/node": "^16.11.6", @@ -17,8 +17,12 @@ "@typescript-eslint/parser": "5.29.0", "builtin-modules": "3.3.0", "esbuild": "0.17.3", + "gray-matter": "^4.0.3", "obsidian": "latest", + "remark": "^14.0.3", + "remark-parse": "^10.0.2", "tslib": "2.4.0", - "typescript": "4.7.4" + "typescript": "4.7.4", + "unified": "^11.0.5" } } diff --git a/src/FileWatcher.ts b/src/FileWatcher.ts new file mode 100644 index 0000000..e687401 --- /dev/null +++ b/src/FileWatcher.ts @@ -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 = 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); + } +} \ No newline at end of file diff --git a/src/MarkdownParser.ts b/src/MarkdownParser.ts new file mode 100644 index 0000000..f264794 --- /dev/null +++ b/src/MarkdownParser.ts @@ -0,0 +1,558 @@ +import matter from 'gray-matter'; +import { unified } from 'unified'; +import remarkParse from 'remark-parse'; +import { ParsedMarkdown, WolaiBlock, MarkdownNode, SyncStatus, FileSyncInfo, CreateRichText, WolaiRichText, WolaiPageBlock } from './types'; + +export class MarkdownParser { + + parseFrontMatter(content: string): ParsedMarkdown { + try { + const parsed = matter(content); + + return { + frontMatter: parsed.data || {}, + content: parsed.content || '', + blocks: [] // 将在后续步骤中填充 + }; + } catch (error) { + console.error('Error parsing front matter:', error); + return { + frontMatter: {}, + content: content, + blocks: [] + }; + } + } + + getSyncInfo(frontMatter: { [key: string]: any }): FileSyncInfo { + return { + sync_status: frontMatter.sync_status || 'Pending', + wolai_id: frontMatter.wolai_id, + last_sync: frontMatter.last_sync + }; + } + + setSyncInfo(frontMatter: { [key: string]: any }, syncInfo: FileSyncInfo): { [key: string]: any } { + return { + ...frontMatter, + sync_status: syncInfo.sync_status, + wolai_id: syncInfo.wolai_id, + last_sync: syncInfo.last_sync + }; + } + + updateSyncStatus(content: string, syncStatus: SyncStatus, wolaiId?: string): string { + try { + const parsed = matter(content); + + // 更新FrontMatter中的同步状态 + const updatedFrontMatter: { [key: string]: any } = { + ...parsed.data, + sync_status: syncStatus, + last_sync: new Date().toISOString() + }; + + if (wolaiId) { + updatedFrontMatter.wolai_id = wolaiId; + } + + // 重新组装文件内容 + return matter.stringify(parsed.content, updatedFrontMatter); + } catch (error) { + console.error('Error updating sync status:', error); + return content; + } + } + + needsSync(frontMatter: { [key: string]: any }): boolean { + const syncInfo = this.getSyncInfo(frontMatter); + return syncInfo.sync_status === 'Pending' || syncInfo.sync_status === 'Modified'; + } + + isSynced(frontMatter: { [key: string]: any }): boolean { + const syncInfo = this.getSyncInfo(frontMatter); + return syncInfo.sync_status === 'Synced'; + } + + parseMarkdownToAST(content: string): MarkdownNode | null { + try { + const processor = unified().use(remarkParse as any); + const ast = processor.parse(content); + return ast as MarkdownNode; + } catch (error) { + console.error('Error parsing markdown to AST:', error); + return null; + } + } + + parseMarkdown(content: string): ParsedMarkdown { + const frontMatterResult = this.parseFrontMatter(content); + const ast = this.parseMarkdownToAST(frontMatterResult.content); + + if (!ast) { + return frontMatterResult; + } + + const blocks = this.convertASTToWolaiBlocks(ast); + + return { + ...frontMatterResult, + blocks: blocks + }; + } + + private convertASTToWolaiBlocks(node: MarkdownNode): WolaiBlock[] { + const blocks: WolaiBlock[] = []; + + if (node.children) { + for (const child of node.children) { + if (child.type === 'list') { + // 列表需要特殊处理,展开为多个独立的列表项blocks + const listBlocks = this.convertListToWolaiBlocks(child); + blocks.push(...listBlocks); + } else { + const block = this.convertNodeToWolaiBlock(child); + if (block) { + blocks.push(block); + } + } + } + } + + return blocks; + } + + private convertNodeToWolaiBlock(node: MarkdownNode): WolaiBlock | null { + switch (node.type) { + case 'heading': + return { + type: 'heading', + level: node.depth || 1, + content: this.extractRichTextFromNode(node) + }; + + case 'paragraph': + const richText = this.extractRichTextFromNode(node); + const plainText = this.extractTextFromNode(node); + if (plainText.trim()) { + return { + type: 'text', + content: richText + }; + } + return null; + + case 'code': + return { + type: 'code', + content: node.value || '', + language: node.lang || 'text' + }; + + case 'blockquote': + return { + type: 'quote', + content: this.extractRichTextFromNode(node) + }; + + default: + // 对于不支持的类型,转换为文本块 + const content = this.extractRichTextFromNode(node); + const contentText = this.extractTextFromNode(node); + if (contentText.trim()) { + return { + type: 'text', + content: content + }; + } + return null; + } + } + + private convertListToWolaiBlocks(listNode: MarkdownNode, depth: number = 0): WolaiBlock[] { + const blocks: WolaiBlock[] = []; + const listType = listNode.ordered ? 'enum_list' : 'bull_list'; + + if (listNode.children) { + for (const listItem of listNode.children) { + if (listItem.type === 'listItem') { + // 处理列表项的直接文本内容(不包括嵌套列表) + const directTextContent = this.extractDirectTextFromListItem(listItem); + const directPlainText = this.extractTextFromNode(listItem); + + if (directPlainText.trim()) { + const block: WolaiBlock = { + type: listType, + content: directTextContent + }; + + // 添加层级信息用于后续处理 + if (depth > 0) { + (block as any).depth = depth; + (block as any).needsParent = true; + } + + blocks.push(block); + } + + // 处理嵌套列表 + const nestedLists = this.extractNestedListsFromListItem(listItem); + for (const nestedList of nestedLists) { + const nestedBlocks = this.convertListToWolaiBlocks(nestedList, depth + 1); + blocks.push(...nestedBlocks); + } + } + } + } + + return blocks; + } + + private extractDirectTextFromListItem(listItem: MarkdownNode): CreateRichText { + if (!listItem.children) return ''; + + const richTextParts: (string | WolaiRichText)[] = []; + for (const child of listItem.children) { + // 只提取非列表的内容 + if (child.type !== 'list') { + const childRichText = this.extractRichTextFromNode(child); + if (childRichText) { + if (Array.isArray(childRichText)) { + richTextParts.push(...childRichText); + } else { + richTextParts.push(childRichText); + } + } + } + } + + // 如果只有一个元素且是字符串,直接返回字符串 + if (richTextParts.length === 1 && typeof richTextParts[0] === 'string') { + return richTextParts[0]; + } + + // 如果有多个元素或包含格式化文本,返回数组 + return richTextParts.length > 0 ? richTextParts : ''; + } + + private extractNestedListsFromListItem(listItem: MarkdownNode): MarkdownNode[] { + const nestedLists: MarkdownNode[] = []; + + if (listItem.children) { + for (const child of listItem.children) { + if (child.type === 'list') { + nestedLists.push(child); + } + } + } + + return nestedLists; + } + + private extractTextFromNode(node: MarkdownNode): string { + const richText = this.extractRichTextFromNode(node); + if (typeof richText === 'string') { + return richText; + } else if (Array.isArray(richText)) { + return richText.map(item => + typeof item === 'string' ? item : item.title + ).join(''); + } else { + return richText.title; + } + } + + private extractRichTextFromNode(node: MarkdownNode): CreateRichText { + if (node.value) { + return node.value; + } + + if (node.children) { + const richTextParts: (string | WolaiRichText)[] = []; + + for (const child of node.children) { + const childRichText = this.convertNodeToRichText(child); + if (childRichText) { + if (Array.isArray(childRichText)) { + richTextParts.push(...childRichText); + } else { + richTextParts.push(childRichText); + } + } + } + + // 如果只有一个元素且是字符串,直接返回字符串 + if (richTextParts.length === 1 && typeof richTextParts[0] === 'string') { + return richTextParts[0]; + } + + // 如果有多个元素或包含格式化文本,返回数组 + return richTextParts.length > 0 ? richTextParts : ''; + } + + return ''; + } + + private convertNodeToRichText(node: MarkdownNode): CreateRichText | null { + switch (node.type) { + case 'text': + return node.value || ''; + + case 'strong': // **加粗** + const boldText = this.extractTextFromNode(node); + if (boldText.trim()) { + return { + title: boldText, + bold: true + }; + } + return null; + + case 'emphasis': // *斜体* + const italicText = this.extractTextFromNode(node); + if (italicText.trim()) { + return { + title: italicText, + italic: true + }; + } + return null; + + case 'inlineCode': // `行内代码` + return { + title: node.value || '', + inline_code: true + }; + + case 'delete': // ~~删除线~~ + const strikeText = this.extractTextFromNode(node); + if (strikeText.trim()) { + return { + title: strikeText, + strikethrough: true + }; + } + return null; + + case 'link': // [文本](链接) + if (node.url) { + // 处理链接文本,可能包含格式化内容 + const linkTextContent = this.extractRichTextFromNode(node); + let linkTitle = ''; + + // 如果链接文本是简单字符串,直接使用 + if (typeof linkTextContent === 'string') { + linkTitle = linkTextContent; + } else if (Array.isArray(linkTextContent)) { + // 如果链接文本包含格式,提取纯文本作为标题 + linkTitle = linkTextContent.map(item => + typeof item === 'string' ? item : item.title + ).join(''); + } else { + linkTitle = linkTextContent.title; + } + + if (linkTitle.trim()) { + return { + title: linkTitle, + link: node.url + }; + } + } + return null; + + default: + // 对于其他类型,递归处理子节点 + if (node.children) { + const childParts: (string | WolaiRichText)[] = []; + for (const child of node.children) { + const childResult = this.convertNodeToRichText(child); + if (childResult) { + if (Array.isArray(childResult)) { + childParts.push(...childResult); + } else { + childParts.push(childResult); + } + } + } + return childParts.length > 0 ? childParts : null; + } + return node.value || null; + } + } + + createHash(content: string): string { + // 简单的哈希函数,用于检测文件变化 + let hash = 0; + for (let i = 0; i < content.length; i++) { + const char = content.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // 转换为32位整数 + } + return Math.abs(hash).toString(16); + } + + sanitizeFileName(filename: string): string { + // 从文件名生成合适的标题 + return filename + .replace(/\.md$/, '') + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, l => l.toUpperCase()); + } + + extractTitle(frontMatter: any, filename: string): string { + // 尝试从多个字段获取标题 + return frontMatter.title || + frontMatter.name || + frontMatter.标题 || + this.sanitizeFileName(filename); + } + + convertWolaiPageToMarkdown(blocks: WolaiPageBlock[], title?: string): string { + let markdown = ''; + + // 添加标题(如果提供) + if (title) { + markdown += `# ${title}\n\n`; + } + + // 转换每个块 + for (const block of blocks) { + const blockMarkdown = this.convertWolaiBlockToMarkdown(block); + if (blockMarkdown) { + markdown += blockMarkdown + '\n\n'; + } + } + + return markdown.trim(); + } + + private convertWolaiBlockToMarkdown(block: WolaiPageBlock): string { + // 获取缩进级别 + const depth = (block as any).depth || 0; + const isChildBlock = (block as any).isChildBlock || false; + const indent = isChildBlock ? '\t'.repeat(depth) : ''; + + let blockContent = ''; + + switch (block.type) { + case 'heading': + const level = Math.min(Math.max(block.level || 1, 1), 6); // 限制在1-6级 + const headingPrefix = '#'.repeat(level); + const headingContent = this.convertRichTextToMarkdown(block.content); + blockContent = `${headingPrefix} ${headingContent}`; + break; + + case 'text': + blockContent = this.convertRichTextToMarkdown(block.content); + break; + + case 'quote': + case 'blockquote': + const quoteContent = this.convertRichTextToMarkdown(block.content); + blockContent = `> ${quoteContent}`; + break; + + case 'code': + const language = (block as any).language || ''; + const codeContent = typeof block.content === 'string' ? block.content : + Array.isArray(block.content) ? block.content.map(c => typeof c === 'string' ? c : c.title).join('') : + block.content?.title || ''; + blockContent = `\`\`\`${language}\n${codeContent}\n\`\`\``; + break; + + case 'bull_list': + case 'unordered_list': + const bulletContent = this.convertRichTextToMarkdown(block.content); + // 无序列表直接应用缩进 + return `${indent}- ${bulletContent}`; + + case 'enum_list': + case 'ordered_list': + const enumContent = this.convertRichTextToMarkdown(block.content); + // 有序列表需要正确的缩进,但保持1.编号 + return `${indent}1. ${enumContent}`; + + case 'image': + // 处理图片块 + const altText = this.convertRichTextToMarkdown(block.content) || 'image'; + const imageUrl = (block as any).url || ''; + blockContent = imageUrl ? `![${altText}](${imageUrl})` : `*[图片: ${altText}]*`; + break; + + case 'divider': + case 'separator': + blockContent = '---'; + break; + + case 'table': + // 表格暂时作为文本处理 + blockContent = this.convertRichTextToMarkdown(block.content) || '*[表格内容]*'; + break; + + case 'equation': + // 数学公式 + const equation = this.convertRichTextToMarkdown(block.content); + blockContent = `$$\n${equation}\n$$`; + break; + + case 'toggle': + // 折叠块 + const toggleContent = this.convertRichTextToMarkdown(block.content); + blockContent = `
\n${toggleContent}\n\n
`; + 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; + } +} \ No newline at end of file diff --git a/src/SettingsTab.ts b/src/SettingsTab.ts new file mode 100644 index 0000000..91bbd1e --- /dev/null +++ b/src/SettingsTab.ts @@ -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' + }); + } + } +} \ No newline at end of file diff --git a/src/SyncManager.ts b/src/SyncManager.ts new file mode 100644 index 0000000..c683cad --- /dev/null +++ b/src/SyncManager.ts @@ -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 = new Map(); + private dataFilePath: string = '.obsidian/plugins/obsidian-wolai-sync/sync-records.json'; + private syncingFiles: Set = 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 验证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 { + return new Map(this.syncRecords); + } + + async removeSyncRecord(filePath: string): Promise { + 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 { + this.syncRecords.clear(); + await this.saveSyncRecords(); + console.log('All sync records cleared'); + } + + // 兼容性方法,保持向后兼容 + async syncFile(filePath: string): Promise { + return await this.syncObsidianToWolai(filePath); + } + + async batchSync(filePaths: string[]): Promise { + 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 { + 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 { + 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; + } + } +} \ No newline at end of file diff --git a/src/WolaiAPI.ts b/src/WolaiAPI.ts new file mode 100644 index 0000000..0ebd2f6 --- /dev/null +++ b/src/WolaiAPI.ts @@ -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 { + 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 { + // 检查token是否存在且未过期(-1表示永不过期) + if (this.token && (this.tokenExpireTime === -1 || Date.now() < this.tokenExpireTime)) { + return this.token; + } + + // 重新获取token + return await this.createToken(); + } + + async validateConnection(): Promise { + const token = await this.getValidToken(); + return token !== null; + } + + async insertDatabaseRow(databaseId: string, rowData: WolaiDatabaseRow): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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( + operation: () => Promise, + maxRetries: number = 3, + baseDelay: number = 1000 + ): Promise { + 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 { + 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 { + const pageContent = await this.getPageContent(pageId); + if (!pageContent) { + return []; + } + + // 递归获取所有子块 + const allBlocks = await this.expandBlocksWithChildren(pageContent); + return allBlocks; + } + + private async expandBlocksWithChildren(blocks: WolaiPageBlock[]): Promise { + 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; + } +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..e188d01 --- /dev/null +++ b/src/types.ts @@ -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; +} \ No newline at end of file diff --git a/styles.css b/styles.css index 71cc60f..e69de29 100644 --- a/styles.css +++ b/styles.css @@ -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. - -*/ diff --git a/sync-records.json b/sync-records.json new file mode 100644 index 0000000..ba9d7e8 --- /dev/null +++ b/sync-records.json @@ -0,0 +1,9 @@ +{ + "🌈无限进步/Mail Piler 邮件归档服务器安装.md": { + "filePath": "🌈无限进步/Mail Piler 邮件归档服务器安装.md", + "lastModified": 1748920939375, + "wolaiRowId": "5V5CymRzoayNnMPx7oyJgk", + "synced": true, + "hash": "3e45e191" + } +} \ No newline at end of file diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..e920cba --- /dev/null +++ b/todo.md @@ -0,0 +1 @@ +- [ ] 一键生成一个可以同步的数据表格 \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index c44b729..e0560e6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,8 @@ "importHelpers": true, "isolatedModules": true, "strictNullChecks": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, "lib": [ "DOM", "ES5", @@ -19,6 +21,7 @@ ] }, "include": [ - "**/*.ts" + "**/*.ts", + "src/**/*.ts" ] }