feat: Initial commit

This commit is contained in:
Jorahil 2025-01-12 16:05:23 +08:00
parent ee04e2f81f
commit e7c471b505
26 changed files with 2105 additions and 164 deletions

35
.gitignore vendored
View File

@ -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
View File

@ -1 +1,2 @@
tag-version-prefix="" @types:registry=https://registry.npmjs.org/
registry=https://registry.npmjs.org/

203
README.md
View File

@ -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/) 构建

15
cleanup.md Normal file
View File

@ -0,0 +1,15 @@
# 测试清理步骤
1. Obsidian 端
- 禁用插件
- 删除测试文档
- 清除插件设置
2. Notion 端
- 清除测试数据库中的同步记录
- 保留数据库结构
3. 本地文件
- 删除 main.js
- 删除 *.js.map
- 删除 data.json

24
debugging-tips.md Normal file
View File

@ -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

View File

@ -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",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins], ...builtins],
format: "cjs", format: 'cjs',
target: "es2018", target: 'es2020', // ¸üÐÂÄ¿±ê»·¾³Îª ES2020 ÒÔÖ§³Ö async generator
logLevel: "info", logLevel: "info",
sourcemap: prod ? false : "inline", sourcemap: prod ? false : 'inline',
treeShaking: true, treeShaking: true,
outfile: "main.js", outfile: 'main.js',
minify: prod, }).catch(() => process.exit(1));
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

View File

@ -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
} }

25
notion-setup-guide.md Normal file
View File

@ -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

1034
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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",
"dependencies": {
"@notionhq/client": "^2.2.0"
},
"devDependencies": { "devDependencies": {
"@types/node": "^16.11.6", "@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0", "builtin-modules": "^3.3.0",
"@typescript-eslint/parser": "5.29.0", "esbuild": "^0.17.3",
"builtin-modules": "3.3.0", "obsidian": "^1.4.11",
"esbuild": "0.17.3", "tslib": "^2.4.0",
"obsidian": "latest", "typescript": "^4.7.4"
"tslib": "2.4.0",
"typescript": "4.7.4"
} }
} }

32
src/global.d.ts vendored Normal file
View File

@ -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 {};

75
src/main.ts Normal file
View File

@ -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 会自动处理插件的清理工作
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}
}

View File

@ -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.');
}
}
}

93
src/settings.ts Normal file
View File

@ -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);
}
}

7
src/types.ts Normal file
View File

@ -0,0 +1,7 @@
export interface NotionSyncSettings {
notionToken: string;
databaseId: string;
handleDeepLists: 'convert' | 'skip' | 'keep';
autoSync: boolean;
syncOnSave: boolean;
}

13
src/types/index.ts Normal file
View File

@ -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>;
}

114
src/utils/converter.ts Normal file
View File

@ -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;
}
}

15
src/utils/validator.ts Normal file
View File

@ -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;
}
}

30
test-steps.md Normal file
View File

@ -0,0 +1,30 @@
# Obsidian 测试步骤
1. 启动 Obsidian
- 打开测试 vaultH:\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 面板中的错误信息

22
troubleshooting.md Normal file
View File

@ -0,0 +1,22 @@
# 常见问题解决
## 1. 同步失败
- 检查 Notion Token 格式
- 确认数据库 ID 正确
- 验证数据库权限设置
- 检查网络连接
## 2. 格式问题
- 确认深层列表处理方式设置
- 检查 Markdown 语法是否正确
- 验证 Notion 块转换结果
## 3. 权限问题
- 确认 Integration 权限范围
- 检查数据库访问权限
- 验证 Token 有效性
## 4. 性能问题
- 检查文件大小
- 验证网络状态
- 观察同步时间

View File

@ -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"
] ]
} }

29
verification-steps.md Normal file
View File

@ -0,0 +1,29 @@
# 验证步骤
1. 插件安装验证
- 启动 Obsidian
- 检查插件是否出现在已安装插件列表中
- 检查是否有错误提示
2. 配置验证
- 打开插件设置
- 填入 Notion Token
- 填入数据库 ID
- 选择深层列表处理方式
3. 功能验证
- 打开测试文档
- 使用命令面板执行同步命令
- 检查 Notion 数据库中是否出现同步的内容
- 验证深层列表是否按照设置正确处理
4. 错误处理验证
- 尝试使用错误的 Token
- 尝试同步空文档
- 尝试在未配置的情况下同步
- 检查错误提示是否正确
5. 批量同步验证
- 选择多个文件
- 执行批量同步
- 检查成功率提示