feat: Initial commit
This commit is contained in:
parent
ee04e2f81f
commit
e7c471b505
|
@ -1,22 +1,23 @@
|
||||||
# vscode
|
# Dependencies
|
||||||
.vscode
|
node_modules/
|
||||||
|
|
||||||
# Intellij
|
# Build files
|
||||||
*.iml
|
|
||||||
.idea
|
|
||||||
|
|
||||||
# npm
|
|
||||||
node_modules
|
|
||||||
|
|
||||||
# Don't include the compiled main.js file in the repo.
|
|
||||||
# They should be uploaded to GitHub releases instead.
|
|
||||||
main.js
|
main.js
|
||||||
|
*.js.map
|
||||||
|
|
||||||
# Exclude sourcemaps
|
# IDE
|
||||||
*.map
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
# obsidian
|
# OS
|
||||||
data.json
|
|
||||||
|
|
||||||
# Exclude macOS Finder (System Explorer) View States
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Obsidian
|
||||||
|
.obsidian/
|
||||||
|
|
||||||
|
# Encrypted settings
|
||||||
|
settings.encrypted
|
||||||
|
|
||||||
|
# Legacy settings
|
||||||
|
data.json
|
3
.npmrc
3
.npmrc
|
@ -1 +1,2 @@
|
||||||
tag-version-prefix=""
|
@types:registry=https://registry.npmjs.org/
|
||||||
|
registry=https://registry.npmjs.org/
|
203
README.md
203
README.md
|
@ -1,94 +1,157 @@
|
||||||
# Obsidian Sample Plugin
|
# Obsidian Notion 同步插件
|
||||||
|
|
||||||
This is a sample plugin for Obsidian (https://obsidian.md).
|
一个用于将 Obsidian 笔记同步到 Notion 数据库的插件。
|
||||||
|
|
||||||
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.
|
- 一键同步笔记到 Notion
|
||||||
- Adds a ribbon icon, which shows a Notice when clicked.
|
- 支持多种 Markdown 元素
|
||||||
- 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.
|
- 可配置的同步设置
|
||||||
|
|
||||||
## First time developing plugins?
|
## 设置指南
|
||||||
|
|
||||||
Quick starting guide for new plugin devs:
|
### 1. 创建 Notion Integration
|
||||||
|
|
||||||
- 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.
|
1. 访问 [Notion Integrations](https://www.notion.so/my-integrations)
|
||||||
- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it).
|
2. 点击"New integration"
|
||||||
- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder.
|
3. 输入集成名称(如"Obsidian Sync")
|
||||||
- Install NodeJS, then run `npm i` in the command line under your repo folder.
|
4. 选择数据库所在的工作区
|
||||||
- Run `npm run dev` to compile your plugin from `main.ts` to `main.js`.
|
5. 设置权限(至少需要读写内容权限)
|
||||||
- Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`.
|
6. 保存并复制 Integration Token
|
||||||
- Reload Obsidian to load the new version of your plugin.
|
|
||||||
- Enable plugin in settings window.
|
|
||||||
- For updates to the Obsidian API run `npm update` in the command line under your repo folder.
|
|
||||||
|
|
||||||
## Releasing new releases
|
### 2. 准备 Notion Database
|
||||||
|
|
||||||
- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release.
|
1. 在 Notion 中创建新数据库(或使用现有数据库)
|
||||||
- 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.
|
2. 数据库必须包含"Name"属性(title 类型)
|
||||||
- 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
|
3. 获取 Database ID:
|
||||||
- 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.
|
- URL 格式如:`https://notion.so/workspace/1234...abcd`
|
||||||
|
- 复制最后一部分(32个字符)- 这就是 Database ID
|
||||||
|
|
||||||
> 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`.
|
### 3. 连接 Database 与 Integration
|
||||||
> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json`
|
|
||||||
|
|
||||||
## Adding your plugin to the community plugin list
|
1. 在 Notion 中打开数据库
|
||||||
|
2. 点击右上角的"..."
|
||||||
|
3. 进入"Connections"
|
||||||
|
4. 找到并添加你的 integration
|
||||||
|
|
||||||
- Check the [plugin guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines).
|
### 4. 配置插件
|
||||||
- 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.
|
|
||||||
|
|
||||||
## How to use
|
1. 打开 Obsidian 设置
|
||||||
|
2. 进入"第三方插件" → "Notion 同步"
|
||||||
|
3. 输入 Integration Token
|
||||||
|
4. 输入 Database ID
|
||||||
|
5. 根据需要配置其他设置
|
||||||
|
|
||||||
- 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
|
### 基本同步
|
||||||
|
|
||||||
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
|
1. 打开要同步的笔记
|
||||||
|
2. 使用以下方法之一:
|
||||||
|
- 在文件菜单中点击"同步到 Notion"(右键)
|
||||||
|
- 使用命令面板(Ctrl/Cmd + P)搜索"同步到 Notion"
|
||||||
|
|
||||||
## 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\`
|
|
||||||
|
|
||||||
## Funding URL
|
- 标题(H1-H3)
|
||||||
|
- 段落
|
||||||
|
- 无序列表(支持多级)
|
||||||
|
- 有序列表
|
||||||
|
- 基本文本格式
|
||||||
|
|
||||||
You can include funding URLs where people who use your plugin can financially support it.
|
### 多级列表处理
|
||||||
|
|
||||||
The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file:
|
插件支持三种方式处理多级列表:
|
||||||
|
|
||||||
```json
|
1. **保持原有层级**(默认)
|
||||||
{
|
```markdown
|
||||||
"fundingUrl": "https://buymeacoffee.com"
|
- 一级项目
|
||||||
}
|
- 二级项目
|
||||||
```
|
- 三级项目
|
||||||
|
```
|
||||||
|
同步到 Notion 后保持原有的层级结构:
|
||||||
|
```
|
||||||
|
? 一级项目
|
||||||
|
? 二级项目
|
||||||
|
? 三级项目
|
||||||
|
```
|
||||||
|
|
||||||
If you have multiple URLs, you can also do:
|
2. **转为平级结构**
|
||||||
|
```markdown
|
||||||
|
- 一级项目
|
||||||
|
- 二级项目
|
||||||
|
- 三级项目
|
||||||
|
```
|
||||||
|
同步到 Notion 后转换为:
|
||||||
|
```
|
||||||
|
? 一级项目
|
||||||
|
? 二级项目
|
||||||
|
? 三级项目
|
||||||
|
```
|
||||||
|
|
||||||
```json
|
3. **忽略子级内容**
|
||||||
{
|
```markdown
|
||||||
"fundingUrl": {
|
- 一级项目
|
||||||
"Buy Me a Coffee": "https://buymeacoffee.com",
|
- 二级项目(会被忽略)
|
||||||
"GitHub Sponsor": "https://github.com/sponsors",
|
- 三级项目(会被忽略)
|
||||||
"Patreon": "https://www.patreon.com/"
|
- 另一个一级项目
|
||||||
}
|
```
|
||||||
}
|
同步到 Notion 后只保留顶级项目:
|
||||||
```
|
```
|
||||||
|
? 一级项目
|
||||||
|
? 另一个一级项目
|
||||||
|
```
|
||||||
|
|
||||||
## API Documentation
|
选择合适的处理方式:
|
||||||
|
- 如果你的 Notion 数据库需要保持文档的完整层级结构,选择"保持原有层级"
|
||||||
|
- 如果你希望简化列表结构便于在 Notion 中查看,选择"转为平级结构"
|
||||||
|
- 如果你只关注顶层信息,选择"忽略子级内容"
|
||||||
|
|
||||||
See https://github.com/obsidianmd/obsidian-api
|
### 设置说明
|
||||||
|
|
||||||
|
- **Integration Token**:Notion 集成令牌(安全加密存储)
|
||||||
|
- **Database ID**:目标 Notion 数据库标识符
|
||||||
|
- **列表处理方式**:控制多级列表的同步行为
|
||||||
|
- 保持原有层级:完整保留列表的层级关系
|
||||||
|
- 转为平级结构:将多级列表转换为同级项目
|
||||||
|
- 忽略子级内容:仅同步顶层列表项
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
### 常见问题
|
||||||
|
|
||||||
|
1. **认证失败**
|
||||||
|
- 验证 Integration Token 是否正确
|
||||||
|
- 检查 Token 是否具有适当权限
|
||||||
|
|
||||||
|
2. **找不到数据库**
|
||||||
|
- 验证 Database ID 是否正确
|
||||||
|
- 确保 Integration 已被授权访问数据库
|
||||||
|
|
||||||
|
3. **同步失败**
|
||||||
|
- 检查网络连接
|
||||||
|
- 确认文件大小在限制内(500KB)
|
||||||
|
- 确保内容格式受支持
|
||||||
|
|
||||||
|
## 安全性
|
||||||
|
|
||||||
|
- Integration Token 采用加密存储
|
||||||
|
- 不向第三方发送数据
|
||||||
|
- 所有通信直接与 Notion API 进行
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT 许可证 - 详见 [LICENSE](LICENSE)
|
||||||
|
|
||||||
|
## 支持
|
||||||
|
|
||||||
|
- [报告问题](https://github.com/e6g2cyvryi/obsidian-notion-sync/issues)
|
||||||
|
- [功能建议](https://github.com/e6g2cyvryi/obsidian-notion-sync/issues)
|
||||||
|
|
||||||
|
## 技术支持
|
||||||
|
|
||||||
|
基于 [Obsidian Plugin API](https://github.com/obsidianmd/obsidian-api) 和 [Notion API](https://developers.notion.com/) 构建
|
|
@ -0,0 +1,15 @@
|
||||||
|
# 测试清理步骤
|
||||||
|
|
||||||
|
1. Obsidian 端
|
||||||
|
- 禁用插件
|
||||||
|
- 删除测试文档
|
||||||
|
- 清除插件设置
|
||||||
|
|
||||||
|
2. Notion 端
|
||||||
|
- 清除测试数据库中的同步记录
|
||||||
|
- 保留数据库结构
|
||||||
|
|
||||||
|
3. 本地文件
|
||||||
|
- 删除 main.js
|
||||||
|
- 删除 *.js.map
|
||||||
|
- 删除 data.json
|
|
@ -0,0 +1,24 @@
|
||||||
|
# 调试技巧
|
||||||
|
|
||||||
|
1. 实时日志查看
|
||||||
|
- 保持开发者工具打开
|
||||||
|
- 在 Console 面板查看日志
|
||||||
|
- 使用 console.log() 添加临时调试信息
|
||||||
|
|
||||||
|
2. 热重载
|
||||||
|
- 修改代码后保存
|
||||||
|
- npm run dev 会自动重新构建
|
||||||
|
- 在 Obsidian 中禁用并重新启用插件
|
||||||
|
|
||||||
|
3. 常见问题排查
|
||||||
|
- 如果插件没有出现在列表中,检查 manifest.json
|
||||||
|
- 如果无法启用插件,检查 main.js 是否生成
|
||||||
|
- 如果同步失败,检查 Notion API 响应
|
||||||
|
|
||||||
|
4. 文件位置验证
|
||||||
|
确保以下文件存在且位置正确:
|
||||||
|
.obsidian/plugins/obsidian-notion-sync/
|
||||||
|
├── manifest.json
|
||||||
|
├── main.js
|
||||||
|
├── styles.css (如果有)
|
||||||
|
└── package.json
|
|
@ -5,45 +5,31 @@ import builtins from "builtin-modules";
|
||||||
const banner =
|
const banner =
|
||||||
`/*
|
`/*
|
||||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||||
if you want to view the source, please visit the github repository of this plugin
|
|
||||||
*/
|
*/
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const prod = (process.argv[2] === "production");
|
const prod = (process.argv[2] === 'production');
|
||||||
|
|
||||||
const context = await esbuild.context({
|
esbuild.build({
|
||||||
banner: {
|
banner: {
|
||||||
js: banner,
|
js: banner,
|
||||||
},
|
},
|
||||||
entryPoints: ["main.ts"],
|
entryPoints: ['src/main.ts'],
|
||||||
bundle: true,
|
bundle: true,
|
||||||
external: [
|
external: [
|
||||||
"obsidian",
|
'obsidian',
|
||||||
"electron",
|
'@codemirror/autocomplete',
|
||||||
"@codemirror/autocomplete",
|
'@codemirror/closebrackets',
|
||||||
"@codemirror/collab",
|
'@codemirror/collab',
|
||||||
"@codemirror/commands",
|
'@codemirror/fold',
|
||||||
"@codemirror/language",
|
'@codemirror/gutter',
|
||||||
"@codemirror/lint",
|
'@codemirror/history',
|
||||||
"@codemirror/search",
|
'@codemirror/language',
|
||||||
"@codemirror/state",
|
...builtins],
|
||||||
"@codemirror/view",
|
format: 'cjs',
|
||||||
"@lezer/common",
|
target: 'es2020', // ¸üÐÂÄ¿±ê»·¾³Îª ES2020 ÒÔÖ§³Ö async generator
|
||||||
"@lezer/highlight",
|
logLevel: "info",
|
||||||
"@lezer/lr",
|
sourcemap: prod ? false : 'inline',
|
||||||
...builtins],
|
treeShaking: true,
|
||||||
format: "cjs",
|
outfile: 'main.js',
|
||||||
target: "es2018",
|
}).catch(() => process.exit(1));
|
||||||
logLevel: "info",
|
|
||||||
sourcemap: prod ? false : "inline",
|
|
||||||
treeShaking: true,
|
|
||||||
outfile: "main.js",
|
|
||||||
minify: prod,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (prod) {
|
|
||||||
await context.rebuild();
|
|
||||||
process.exit(0);
|
|
||||||
} else {
|
|
||||||
await context.watch();
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,11 +1,10 @@
|
||||||
{
|
{
|
||||||
"id": "sample-plugin",
|
"id": "obsidian-notion-sync",
|
||||||
"name": "Sample Plugin",
|
"name": "Notion Sync",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"minAppVersion": "0.15.0",
|
"minAppVersion": "0.15.0",
|
||||||
"description": "Demonstrates some of the capabilities of the Obsidian API.",
|
"description": "Sync your Obsidian notes with Notion",
|
||||||
"author": "Obsidian",
|
"author": "Jorahil",
|
||||||
"authorUrl": "https://obsidian.md",
|
"authorUrl": "https://github.com/e6g2cyvryi",
|
||||||
"fundingUrl": "https://obsidian.md/pricing",
|
"isDesktopOnly": true
|
||||||
"isDesktopOnly": false
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
# Notion 配置指南
|
||||||
|
|
||||||
|
## 1. 创建 Notion Integration
|
||||||
|
1. 访问 https://www.notion.so/my-integrations
|
||||||
|
2. 点击 "New integration"
|
||||||
|
3. 填写名称(如 "Obsidian Sync")
|
||||||
|
4. 选择关联的工作区
|
||||||
|
5. 提交后获取 Integration Token(以 "secret_" 开头)
|
||||||
|
|
||||||
|
## 2. 创建目标数据库
|
||||||
|
1. 在 Notion 中创建一个新页面
|
||||||
|
2. 添加一个新的数据库(全页面)
|
||||||
|
3. 添加以下属性:
|
||||||
|
- Name (标题类型,默认存在)
|
||||||
|
- Tags (多选类型)
|
||||||
|
- LastSync (日期类型)
|
||||||
|
|
||||||
|
## 3. 配置数据库权限
|
||||||
|
1. 打开数据库页面
|
||||||
|
2. 点击右上角的 "Share" 按钮
|
||||||
|
3. 在 "Connections" 部分添加你创建的 Integration
|
||||||
|
|
||||||
|
## 4. 获取数据库 ID
|
||||||
|
1. 在浏览器中打开数据库页面
|
||||||
|
2. URL 中形如 "https://www.notion.so/xxx/yyyyyyy?v=zzz" 的 yyyyyyy 部分就是数据库 ID
|
File diff suppressed because it is too large
Load Diff
45
package.json
45
package.json
|
@ -1,24 +1,25 @@
|
||||||
{
|
{
|
||||||
"name": "obsidian-sample-plugin",
|
"name": "obsidian-notion-sync",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
|
"description": "Sync Obsidian notes with Notion",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node esbuild.config.mjs",
|
"dev": "node esbuild.config.mjs",
|
||||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": ["obsidian", "notion", "sync", "plugin"],
|
||||||
"author": "",
|
"author": "Jorahil",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"dependencies": {
|
||||||
"@types/node": "^16.11.6",
|
"@notionhq/client": "^2.2.0"
|
||||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
},
|
||||||
"@typescript-eslint/parser": "5.29.0",
|
"devDependencies": {
|
||||||
"builtin-modules": "3.3.0",
|
"@types/node": "^16.11.6",
|
||||||
"esbuild": "0.17.3",
|
"builtin-modules": "^3.3.0",
|
||||||
"obsidian": "latest",
|
"esbuild": "^0.17.3",
|
||||||
"tslib": "2.4.0",
|
"obsidian": "^1.4.11",
|
||||||
"typescript": "4.7.4"
|
"tslib": "^2.4.0",
|
||||||
}
|
"typescript": "^4.7.4"
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { App, TFile, Notice, PluginSettingTab, Setting } from 'obsidian';
|
||||||
|
import { Client as NotionClient } from '@notionhq/client';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
app: App;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "obsidian" {
|
||||||
|
interface App {
|
||||||
|
workspace: Workspace;
|
||||||
|
vault: Vault;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Workspace {
|
||||||
|
getActiveFile(): TFile | null;
|
||||||
|
on(name: string, callback: (menu: Menu, file: TFile) => any): EventRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Vault {
|
||||||
|
read(file: TFile): Promise<string>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@notionhq/client" {
|
||||||
|
interface NotionClientTypes {
|
||||||
|
// 如果需要扩展 Notion 客户端类型,在这里添加
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { Plugin, Menu, TFile, Notice } from 'obsidian';
|
||||||
|
import { NotionSyncSettings } from './types';
|
||||||
|
import { NotionSyncSettingTab, DEFAULT_SETTINGS } from './settings';
|
||||||
|
import { NotionService } from './services/notion-service';
|
||||||
|
import { SyncService } from './services/sync-service';
|
||||||
|
import { DataValidator } from './utils/validator';
|
||||||
|
import { StorageService } from './services/storage-service';
|
||||||
|
|
||||||
|
export default class NotionSyncPlugin extends Plugin {
|
||||||
|
settings: NotionSyncSettings;
|
||||||
|
notionService: NotionService;
|
||||||
|
syncService: SyncService;
|
||||||
|
private storageService: StorageService;
|
||||||
|
|
||||||
|
async onload() {
|
||||||
|
try {
|
||||||
|
this.storageService = new StorageService(this.app.vault.adapter);
|
||||||
|
await this.loadSettings();
|
||||||
|
|
||||||
|
this.notionService = new NotionService(this.settings.notionToken);
|
||||||
|
this.syncService = new SyncService(this.app, this.notionService, this.settings);
|
||||||
|
|
||||||
|
// 添加设置标签页
|
||||||
|
this.addSettingTab(new NotionSyncSettingTab(this.app, this));
|
||||||
|
|
||||||
|
// 添加同步命令
|
||||||
|
this.addCommand({
|
||||||
|
id: 'sync-to-notion',
|
||||||
|
name: '同步当前文件到 Notion',
|
||||||
|
callback: async () => {
|
||||||
|
const file = this.app.workspace.getActiveFile();
|
||||||
|
if (file) {
|
||||||
|
await this.syncService.syncFile(file);
|
||||||
|
} else {
|
||||||
|
new Notice('请先打开要同步的文件');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加文件菜单项
|
||||||
|
this.registerEvent(
|
||||||
|
this.app.workspace.on('file-menu', (menu: Menu, file: TFile) => {
|
||||||
|
if (file && file instanceof TFile) {
|
||||||
|
menu.addItem((item) => {
|
||||||
|
item
|
||||||
|
.setTitle('同步到 Notion')
|
||||||
|
.setIcon('upload-cloud')
|
||||||
|
.onClick(async () => {
|
||||||
|
await this.syncService.syncFile(file);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
new Notice('插件加载失败');
|
||||||
|
console.error('Plugin load error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSettings() {
|
||||||
|
const savedSettings = await this.storageService.loadSettings();
|
||||||
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, savedSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSettings() {
|
||||||
|
if (this.settings) {
|
||||||
|
await this.storageService.saveSettings(this.settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onunload() {
|
||||||
|
// Obsidian 会自动处理插件的清理工作
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,166 @@
|
||||||
|
import { Client } from '@notionhq/client';
|
||||||
|
import { NotionSyncSettings } from '../types';
|
||||||
|
import { Notice, requestUrl, RequestUrlParam } from 'obsidian';
|
||||||
|
|
||||||
|
export class NotionService {
|
||||||
|
private client: Client;
|
||||||
|
private readonly MAX_CONTENT_SIZE = 1024 * 1024; // 1MB
|
||||||
|
private readonly token: string;
|
||||||
|
|
||||||
|
constructor(token: string) {
|
||||||
|
this.token = token;
|
||||||
|
this.client = new Client({
|
||||||
|
auth: token,
|
||||||
|
notionVersion: '2022-06-28',
|
||||||
|
fetch: this.customFetch.bind(this)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async customFetch(url: string, options: any): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const headers = {
|
||||||
|
'Authorization': `Bearer ${this.token}`,
|
||||||
|
'Notion-Version': '2022-06-28',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'mode': 'no-cors',
|
||||||
|
'credentials': 'omit'
|
||||||
|
};
|
||||||
|
|
||||||
|
const params: RequestUrlParam = {
|
||||||
|
url,
|
||||||
|
method: options.method,
|
||||||
|
headers,
|
||||||
|
body: options.body
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await requestUrl(params);
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new Error('Authentication failed: Invalid token or insufficient permissions');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status >= 400) {
|
||||||
|
throw new Error(`API error (${response.status}): ${response.text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(response.text, {
|
||||||
|
status: response.status,
|
||||||
|
headers: new Headers(response.headers)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('CORS')) {
|
||||||
|
throw new Error('CORS error: Unable to access Notion API. Try refreshing the token.');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOrUpdatePage(databaseId: string, content: any, settings: NotionSyncSettings, fileName: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const contentSize = new TextEncoder().encode(JSON.stringify(content)).length;
|
||||||
|
if (contentSize > this.MAX_CONTENT_SIZE) {
|
||||||
|
throw new Error('Content size exceeds maximum limit');
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks = this.formatBlocks(content);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestData = {
|
||||||
|
parent: {
|
||||||
|
type: 'database_id',
|
||||||
|
database_id: databaseId
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
title: {
|
||||||
|
type: 'title',
|
||||||
|
title: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: {
|
||||||
|
content: fileName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
children: blocks.map(block => ({
|
||||||
|
object: 'block',
|
||||||
|
...block
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await this.client.pages.create(requestData as any);
|
||||||
|
|
||||||
|
if (!response || !response.id) {
|
||||||
|
throw new Error('Failed to create page in Notion');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (apiError: any) {
|
||||||
|
throw new Error(`Failed to create page: ${apiError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Failed to sync with Notion');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatBlocks(blocks: any[]): any[] {
|
||||||
|
return blocks.map(block => {
|
||||||
|
if (typeof block === 'object' && block.type) {
|
||||||
|
const formattedBlock: any = {
|
||||||
|
type: block.type
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (block.type) {
|
||||||
|
case 'paragraph':
|
||||||
|
formattedBlock[block.type] = {
|
||||||
|
rich_text: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: {
|
||||||
|
content: block.paragraph?.rich_text?.[0]?.text?.content || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'heading_1':
|
||||||
|
case 'heading_2':
|
||||||
|
case 'heading_3':
|
||||||
|
formattedBlock[block.type] = {
|
||||||
|
rich_text: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: {
|
||||||
|
content: block[block.type]?.rich_text?.[0]?.text?.content || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
color: 'default'
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'bulleted_list_item':
|
||||||
|
case 'numbered_list_item':
|
||||||
|
formattedBlock[block.type] = {
|
||||||
|
rich_text: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: {
|
||||||
|
content: block[block.type]?.rich_text?.[0]?.text?.content || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
color: 'default'
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn('Unsupported block type:', block.type);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formattedBlock;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}).filter(block => block !== null);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { DataAdapter, Notice } from 'obsidian';
|
||||||
|
import { NotionSyncSettings } from '../types';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
|
export class StorageService {
|
||||||
|
private adapter: DataAdapter;
|
||||||
|
private readonly SETTINGS_PATH = '.obsidian/plugins/obsidian-notion-sync/settings.encrypted';
|
||||||
|
private readonly LEGACY_SETTINGS_PATH = '.obsidian/plugins/obsidian-notion-sync/data.json';
|
||||||
|
private readonly ALGORITHM = 'aes-256-cbc';
|
||||||
|
private readonly KEY = crypto.scryptSync('obsidian-notion-sync', 'salt', 32);
|
||||||
|
private readonly IV_LENGTH = 16;
|
||||||
|
|
||||||
|
constructor(adapter: DataAdapter) {
|
||||||
|
this.adapter = adapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
private encrypt(text: string): string {
|
||||||
|
if (!text) return '';
|
||||||
|
try {
|
||||||
|
const iv = crypto.randomBytes(this.IV_LENGTH);
|
||||||
|
const cipher = crypto.createCipheriv(this.ALGORITHM, this.KEY, iv);
|
||||||
|
|
||||||
|
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||||
|
encrypted += cipher.final('hex');
|
||||||
|
|
||||||
|
return iv.toString('hex') + ':' + encrypted;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Encryption failed:', error);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private decrypt(encryptedData: string): string {
|
||||||
|
if (!encryptedData) return '';
|
||||||
|
try {
|
||||||
|
if (!encryptedData.includes(':')) {
|
||||||
|
// 未加密的数据,直接返回
|
||||||
|
return encryptedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [ivHex, encryptedText] = encryptedData.split(':');
|
||||||
|
if (!ivHex || !encryptedText) {
|
||||||
|
console.warn('Invalid encrypted data format');
|
||||||
|
return encryptedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iv = Buffer.from(ivHex, 'hex');
|
||||||
|
const decipher = crypto.createDecipheriv(this.ALGORITHM, this.KEY, iv);
|
||||||
|
|
||||||
|
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
|
||||||
|
decrypted += decipher.final('utf8');
|
||||||
|
|
||||||
|
return decrypted;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Decryption failed:', error);
|
||||||
|
return encryptedData; // 如果解密失败,返回原始数据
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSettings(settings: NotionSyncSettings): Promise<void> {
|
||||||
|
const encryptedSettings = {
|
||||||
|
...settings,
|
||||||
|
notionToken: this.encrypt(settings.notionToken),
|
||||||
|
notionDatabaseId: this.encrypt(settings.databaseId)
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.adapter.write(
|
||||||
|
this.SETTINGS_PATH,
|
||||||
|
JSON.stringify(encryptedSettings)
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (await this.adapter.exists(this.LEGACY_SETTINGS_PATH)) {
|
||||||
|
await this.adapter.remove(this.LEGACY_SETTINGS_PATH);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to remove legacy settings file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSettings(): Promise<NotionSyncSettings | null> {
|
||||||
|
try {
|
||||||
|
if (await this.adapter.exists(this.SETTINGS_PATH)) {
|
||||||
|
const data = await this.adapter.read(this.SETTINGS_PATH);
|
||||||
|
const encryptedSettings = JSON.parse(data);
|
||||||
|
const decryptedToken = this.decrypt(encryptedSettings.notionToken);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...encryptedSettings,
|
||||||
|
notionToken: decryptedToken,
|
||||||
|
notionDatabaseId: this.decrypt(encryptedSettings.notionDatabaseId)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await this.adapter.exists(this.LEGACY_SETTINGS_PATH)) {
|
||||||
|
const legacyData = await this.adapter.read(this.LEGACY_SETTINGS_PATH);
|
||||||
|
const legacySettings = JSON.parse(legacyData);
|
||||||
|
await this.saveSettings(legacySettings);
|
||||||
|
return legacySettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load settings:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearSettings(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (await this.adapter.exists(this.SETTINGS_PATH)) {
|
||||||
|
await this.adapter.remove(this.SETTINGS_PATH);
|
||||||
|
}
|
||||||
|
if (await this.adapter.exists(this.LEGACY_SETTINGS_PATH)) {
|
||||||
|
await this.adapter.remove(this.LEGACY_SETTINGS_PATH);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to clear settings:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { App, TFile, Notice } from 'obsidian';
|
||||||
|
import { NotionService } from './notion-service';
|
||||||
|
import { MarkdownConverter } from '../utils/converter';
|
||||||
|
import { NotionSyncSettings } from '../types';
|
||||||
|
import { DataValidator } from '../utils/validator';
|
||||||
|
|
||||||
|
export class SyncService {
|
||||||
|
private app: App;
|
||||||
|
private notionService: NotionService;
|
||||||
|
private settings: NotionSyncSettings;
|
||||||
|
private readonly MAX_FILE_SIZE = 500 * 1024; // 500KB
|
||||||
|
|
||||||
|
constructor(app: App, notionService: NotionService, settings: NotionSyncSettings) {
|
||||||
|
this.app = app;
|
||||||
|
this.notionService = notionService;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateSettings(): boolean {
|
||||||
|
if (!this.settings) {
|
||||||
|
new Notice('Configuration required');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (!this.settings.databaseId || this.settings.databaseId.length < 32) {
|
||||||
|
new Notice('Invalid database ID');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncFile(file: TFile): Promise<void> {
|
||||||
|
if (!this.validateSettings()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file || !(file instanceof TFile)) {
|
||||||
|
new Notice('Invalid file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 检查文件大小
|
||||||
|
if (file.stat.size > this.MAX_FILE_SIZE) {
|
||||||
|
new Notice('File size exceeds maximum limit');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await this.app.vault.read(file);
|
||||||
|
|
||||||
|
if (!DataValidator.validateContent(content)) {
|
||||||
|
new Notice('Invalid file content');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks = MarkdownConverter.convertToNotionBlocks(content);
|
||||||
|
|
||||||
|
await this.notionService.createOrUpdatePage(
|
||||||
|
this.settings.databaseId,
|
||||||
|
blocks,
|
||||||
|
this.settings,
|
||||||
|
file.basename
|
||||||
|
);
|
||||||
|
|
||||||
|
new Notice('Sync completed');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Sync failed:', error);
|
||||||
|
new Notice('Sync failed. Check console for details.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { App, PluginSettingTab, Setting } from 'obsidian';
|
||||||
|
import NotionSyncPlugin from './main';
|
||||||
|
import { NotionSyncSettings } from './types';
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: NotionSyncSettings = {
|
||||||
|
notionToken: '',
|
||||||
|
databaseId: '',
|
||||||
|
handleDeepLists: 'keep',
|
||||||
|
autoSync: false,
|
||||||
|
syncOnSave: false
|
||||||
|
};
|
||||||
|
|
||||||
|
export class NotionSyncSettingTab extends PluginSettingTab {
|
||||||
|
plugin: NotionSyncPlugin;
|
||||||
|
|
||||||
|
constructor(app: App, plugin: NotionSyncPlugin) {
|
||||||
|
super(app, plugin);
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
display(): void {
|
||||||
|
const { containerEl } = this;
|
||||||
|
containerEl.empty();
|
||||||
|
|
||||||
|
containerEl.createEl('h2', { text: '同步设置' });
|
||||||
|
|
||||||
|
// Token 设置
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Integration Token')
|
||||||
|
.setDesc(createFragment(fragment => {
|
||||||
|
fragment.appendText('用于连接 Notion 的授权凭证,');
|
||||||
|
fragment.createEl('a', {
|
||||||
|
text: '点击这里创建',
|
||||||
|
href: 'https://www.notion.so/my-integrations'
|
||||||
|
});
|
||||||
|
fragment.appendText('。创建后请确保赋予读写权限。');
|
||||||
|
}))
|
||||||
|
.addText(text => text
|
||||||
|
.setPlaceholder('secret_...')
|
||||||
|
.setValue(this.maskToken(this.plugin.settings.notionToken))
|
||||||
|
.onChange(async (value) => {
|
||||||
|
if (value && !value.startsWith('••••')) {
|
||||||
|
this.plugin.settings.notionToken = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Database ID 设置
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Database ID')
|
||||||
|
.setDesc(createFragment(fragment => {
|
||||||
|
fragment.appendText('目标数据库的唯一标识,可在数据库页面的网址中找到:');
|
||||||
|
fragment.createEl('code', { text: 'notion.so/workspace/[database-id]' });
|
||||||
|
fragment.createEl('br');
|
||||||
|
fragment.appendText('请先确保数据库已与你的 Integration 共享。');
|
||||||
|
}))
|
||||||
|
.addText(text => text
|
||||||
|
.setPlaceholder('Enter Database ID')
|
||||||
|
.setValue(this.plugin.settings.databaseId)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.databaseId = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 高级设置
|
||||||
|
containerEl.createEl('h3', { text: '高级设置' });
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('列表处理方式')
|
||||||
|
.setDesc(createFragment(fragment => {
|
||||||
|
fragment.appendText('设置如何处理多层级的列表结构。');
|
||||||
|
fragment.createEl('br');
|
||||||
|
fragment.createEl('a', {
|
||||||
|
text: '查看详细说明和示例',
|
||||||
|
href: 'https://github.com/e6g2cyvryi/obsidian-notion-sync#多级列表处理'
|
||||||
|
});
|
||||||
|
}))
|
||||||
|
.addDropdown(dropdown => dropdown
|
||||||
|
.addOption('keep', '保持原有层级')
|
||||||
|
.addOption('convert', '转为平级结构')
|
||||||
|
.addOption('skip', '忽略子级内容')
|
||||||
|
.setValue(this.plugin.settings.handleDeepLists)
|
||||||
|
.onChange(async (value: any) => {
|
||||||
|
this.plugin.settings.handleDeepLists = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private maskToken(token: string): string {
|
||||||
|
if (!token) return '';
|
||||||
|
return '••••' + token.slice(-4);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
export interface NotionSyncSettings {
|
||||||
|
notionToken: string;
|
||||||
|
databaseId: string;
|
||||||
|
handleDeepLists: 'convert' | 'skip' | 'keep';
|
||||||
|
autoSync: boolean;
|
||||||
|
syncOnSave: boolean;
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { Plugin } from 'obsidian';
|
||||||
|
|
||||||
|
export interface NotionSyncSettings {
|
||||||
|
notionToken: string;
|
||||||
|
notionDatabaseId: string;
|
||||||
|
handleDeepLists: 'convert' | 'skip' | 'keep';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotionSyncPlugin extends Plugin {
|
||||||
|
settings: NotionSyncSettings;
|
||||||
|
loadSettings(): Promise<void>;
|
||||||
|
saveSettings(): Promise<void>;
|
||||||
|
}
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { DataValidator } from './validator';
|
||||||
|
|
||||||
|
export class MarkdownConverter {
|
||||||
|
/**
|
||||||
|
* Convert Markdown to Notion blocks
|
||||||
|
*/
|
||||||
|
static convertToNotionBlocks(markdown: string, handleDeepLists: 'convert' | 'skip' | 'keep' = 'keep'): any[] {
|
||||||
|
const blocks: any[] = [];
|
||||||
|
const listStack: any[] = [];
|
||||||
|
let previousLevel = 0;
|
||||||
|
let inList = false; // 添加标志来跟踪是否在列表中
|
||||||
|
|
||||||
|
const lines = markdown.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
const listMatch = line.match(/^(\s*|\t*)([-*+])\s(.+)/);
|
||||||
|
if (listMatch) {
|
||||||
|
inList = true;
|
||||||
|
const [, indent, , content] = listMatch;
|
||||||
|
const level = indent.replace(/\t/g, ' ').length / 4;
|
||||||
|
|
||||||
|
// 创建列表项
|
||||||
|
const listItem = {
|
||||||
|
type: "bulleted_list_item",
|
||||||
|
bulleted_list_item: {
|
||||||
|
rich_text: [{
|
||||||
|
type: "text",
|
||||||
|
text: {
|
||||||
|
content: content.trim()
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (level === 0) {
|
||||||
|
blocks.push(listItem);
|
||||||
|
listStack.length = 0;
|
||||||
|
listStack.push(listItem);
|
||||||
|
} else {
|
||||||
|
// 子列表项处理
|
||||||
|
if (level > previousLevel) {
|
||||||
|
const parent = listStack[listStack.length - 1];
|
||||||
|
if (!parent.bulleted_list_item.children) {
|
||||||
|
parent.bulleted_list_item.children = [];
|
||||||
|
}
|
||||||
|
parent.bulleted_list_item.children.push(listItem);
|
||||||
|
listStack.push(listItem);
|
||||||
|
} else if (level === previousLevel && listStack.length > 1) {
|
||||||
|
const parent = listStack[listStack.length - 2];
|
||||||
|
if (!parent.bulleted_list_item.children) {
|
||||||
|
parent.bulleted_list_item.children = [];
|
||||||
|
}
|
||||||
|
parent.bulleted_list_item.children.push(listItem);
|
||||||
|
listStack[listStack.length - 1] = listItem;
|
||||||
|
} else {
|
||||||
|
while (listStack.length > level) {
|
||||||
|
listStack.pop();
|
||||||
|
}
|
||||||
|
const parent = listStack[listStack.length - 1];
|
||||||
|
if (!parent.bulleted_list_item.children) {
|
||||||
|
parent.bulleted_list_item.children = [];
|
||||||
|
}
|
||||||
|
parent.bulleted_list_item.children.push(listItem);
|
||||||
|
listStack.push(listItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previousLevel = level;
|
||||||
|
} else {
|
||||||
|
// 非列表内容
|
||||||
|
if (inList) {
|
||||||
|
// 如果之前在列表中,重置列表相关状态
|
||||||
|
listStack.length = 0;
|
||||||
|
previousLevel = 0;
|
||||||
|
inList = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
if (trimmedLine) { // 只处理非空行
|
||||||
|
// 处理标题
|
||||||
|
const headingMatch = line.match(/^(#{1,6})\s(.+)/);
|
||||||
|
if (headingMatch) {
|
||||||
|
const level = headingMatch[1].length;
|
||||||
|
const content = headingMatch[2];
|
||||||
|
blocks.push({
|
||||||
|
type: `heading_${level}`,
|
||||||
|
[`heading_${level}`]: {
|
||||||
|
rich_text: [{
|
||||||
|
type: "text",
|
||||||
|
text: {
|
||||||
|
content: content.trim()
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 处理普通段落
|
||||||
|
blocks.push({
|
||||||
|
type: 'paragraph',
|
||||||
|
paragraph: {
|
||||||
|
rich_text: [{
|
||||||
|
type: "text",
|
||||||
|
text: {
|
||||||
|
content: trimmedLine
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Notice } from 'obsidian';
|
||||||
|
|
||||||
|
export class DataValidator {
|
||||||
|
static validateToken(token: string): boolean {
|
||||||
|
return token.startsWith('secret_') && token.length > 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
static validateDatabaseId(id: string): boolean {
|
||||||
|
return /^[a-zA-Z0-9-]{32,}$/.test(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
static validateContent(content: string): boolean {
|
||||||
|
return Boolean(content) && content.length > 0;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Obsidian 测试步骤
|
||||||
|
|
||||||
|
1. 启动 Obsidian
|
||||||
|
- 打开测试 vault(H:\Obsidian\helper-test)
|
||||||
|
- 进入设置 -> 第三方插件
|
||||||
|
- 关闭安全模式
|
||||||
|
- 刷新插件列表
|
||||||
|
- 确认 "Obsidian Notion Sync" 出现在插件列表中
|
||||||
|
- 启用插件
|
||||||
|
|
||||||
|
2. 配置插件
|
||||||
|
- 打开插件设置
|
||||||
|
- 填入 Notion Token
|
||||||
|
- 填入数据库 ID
|
||||||
|
- 选择深层列表处理方式(建议先选择 "convert")
|
||||||
|
|
||||||
|
3. 功能测试
|
||||||
|
- 打开 test-cases/basic.md
|
||||||
|
- 使用命令面板(Ctrl+P)
|
||||||
|
- 输入 "同步到 Notion"
|
||||||
|
- 观察同步结果和提示信息
|
||||||
|
|
||||||
|
4. 错误测试
|
||||||
|
- 尝试同步空文档
|
||||||
|
- 尝试使用错误的 Token
|
||||||
|
- 尝试使用错误的数据库 ID
|
||||||
|
|
||||||
|
5. 日志检查
|
||||||
|
- 打开开发者工具(Ctrl+Shift+I)
|
||||||
|
- 检查 Console 面板中的错误信息
|
|
@ -0,0 +1,22 @@
|
||||||
|
# 常见问题解决
|
||||||
|
|
||||||
|
## 1. 同步失败
|
||||||
|
- 检查 Notion Token 格式
|
||||||
|
- 确认数据库 ID 正确
|
||||||
|
- 验证数据库权限设置
|
||||||
|
- 检查网络连接
|
||||||
|
|
||||||
|
## 2. 格式问题
|
||||||
|
- 确认深层列表处理方式设置
|
||||||
|
- 检查 Markdown 语法是否正确
|
||||||
|
- 验证 Notion 块转换结果
|
||||||
|
|
||||||
|
## 3. 权限问题
|
||||||
|
- 确认 Integration 权限范围
|
||||||
|
- 检查数据库访问权限
|
||||||
|
- 验证 Token 有效性
|
||||||
|
|
||||||
|
## 4. 性能问题
|
||||||
|
- 检查文件大小
|
||||||
|
- 验证网络状态
|
||||||
|
- 观察同步时间
|
|
@ -11,6 +11,7 @@
|
||||||
"importHelpers": true,
|
"importHelpers": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
|
"types": ["node", "obsidian", "@notionhq/client"],
|
||||||
"lib": [
|
"lib": [
|
||||||
"DOM",
|
"DOM",
|
||||||
"ES5",
|
"ES5",
|
||||||
|
@ -19,6 +20,6 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"**/*.ts"
|
"src/**/*.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
# 验证步骤
|
||||||
|
|
||||||
|
1. 插件安装验证
|
||||||
|
- 启动 Obsidian
|
||||||
|
- 检查插件是否出现在已安装插件列表中
|
||||||
|
- 检查是否有错误提示
|
||||||
|
|
||||||
|
2. 配置验证
|
||||||
|
- 打开插件设置
|
||||||
|
- 填入 Notion Token
|
||||||
|
- 填入数据库 ID
|
||||||
|
- 选择深层列表处理方式
|
||||||
|
|
||||||
|
3. 功能验证
|
||||||
|
- 打开测试文档
|
||||||
|
- 使用命令面板执行同步命令
|
||||||
|
- 检查 Notion 数据库中是否出现同步的内容
|
||||||
|
- 验证深层列表是否按照设置正确处理
|
||||||
|
|
||||||
|
4. 错误处理验证
|
||||||
|
- 尝试使用错误的 Token
|
||||||
|
- 尝试同步空文档
|
||||||
|
- 尝试在未配置的情况下同步
|
||||||
|
- 检查错误提示是否正确
|
||||||
|
|
||||||
|
5. 批量同步验证
|
||||||
|
- 选择多个文件
|
||||||
|
- 执行批量同步
|
||||||
|
- 检查成功率提示
|
|
@ -1,3 +1,3 @@
|
||||||
{
|
{
|
||||||
"1.0.0": "0.15.0"
|
"1.0.0": "0.15.0"
|
||||||
}
|
}
|
Loading…
Reference in New Issue