Compare commits

...

26 Commits

Author SHA1 Message Date
Jiaxin Peng
a2200b68af chore: bump version to 2.8.4 2026-03-04 10:37:07 +00:00
Jiaxin Peng
95b22619b4 chore: update changelog format and remove version prefix for consistency 2026-03-04 10:37:02 +00:00
Jiaxin Peng
b08c46c233 fix: update release tag patterns and add version bump message in .npmrc 2026-03-04 10:32:32 +00:00
Jiaxin Peng
8dfb82b4ce 2.8.3 2026-03-04 10:29:10 +00:00
Jiaxin Peng
8502650033 fix: remove tag-version-prefix from .npmrc to correct version formatting in package.json 2026-03-04 10:29:06 +00:00
Jiaxin Peng
30e34e3f8c chore: update Node.js version to 22 in workflows and package.json 2026-03-04 10:10:41 +00:00
Jiaxin Peng
ec3d3e85a2 chore: update changelog 2026-03-04 10:00:12 +00:00
Jiaxin Peng
fe04e8df7f 2.8.2 2026-03-04 09:58:39 +00:00
Jiaxin Peng
d36deb7b30 fix: Set tag-version-prefix to "v" for proper versioning 2026-03-04 09:58:28 +00:00
Jiaxin Peng
e8295746e1 feat: Add release notes generation and update release process to use them 2026-03-04 09:52:07 +00:00
Jiaxin Peng
83df1111c4 2.8.1 2026-03-04 09:37:18 +00:00
Jiaxin Peng
6e8be42e3c chore: update changelog 2026-03-04 09:37:13 +00:00
Jiaxin Peng
6b02cb219e feat: Enhance version bumping process to update CHANGELOG.md with new version header 2026-03-04 09:30:32 +00:00
Jiaxin Peng
cd7eb78378 feat: Add isAutoSync option to sync requests and update success notice logic 2026-03-04 09:14:38 +00:00
Jiaxin Peng
3e00f127e9 feat: Add auto sync success notice setting and update upload commands to utilize it 2026-03-04 09:05:41 +00:00
Jiaxin Peng
4fb3b99996 feat: Add auto sync success notification messages in English, Japanese, and Chinese locales 2026-03-04 09:04:57 +00:00
Jiaxin Peng
269d354734 chore: Update roadmap to reflect completion of automatic syncing and file attachments features 2026-01-29 20:25:03 +00:00
Jiaxin Peng
a37bda1575 chore: Update changelog and documentation for v2.8.0 release with new features, changes, and fixes 2026-01-29 20:20:23 +00:00
Jiaxin Peng
19d66917d2 chore: Update changelog for v2.8.0-beta.4 release with new features and fixes 2026-01-29 20:15:11 +00:00
Jiaxin Peng
a9acfbe956 Merge pull request #72 from jxpeng98/dev
Add autosync feature
2026-01-29 20:12:04 +00:00
Jiaxin Peng
879cd66aed docs: Add attachment upload feature to README documentation 2026-01-04 19:11:49 +00:00
Jiaxin Peng
202385ac21 feat: Implement attachment processing and uploading for Notion integration
- Added AttachmentProcessor to handle local attachments in markdown content.
- Introduced AttachmentUploader for managing file uploads to Notion.
- Updated Upload2Notion to utilize new attachment processing and uploading logic.
- Enhanced buildBlocks method to process attachments and apply block rewrites.
- Updated Notion API version to 2025-09-03 for compatibility with new features.
2026-01-04 19:11:37 +00:00
Jiaxin Peng
9c6980d1c9 refactor: Simplify auto sync delay setting initialization 2026-01-04 19:11:23 +00:00
Jiaxin Peng
b61264a1f6 feat: Add message for skipped attachments during auto-sync in multiple languages 2026-01-04 19:11:07 +00:00
Jiaxin Peng
876b6233cb docs: Enhance attachment upload documentation with supported formats and auto sync details 2026-01-04 19:10:59 +00:00
Jiaxin Peng
ccfe40c1f0 fix: Refine release workflow tag patterns to exclude prerelease versions. 2025-12-10 23:18:03 +00:00
28 changed files with 1500 additions and 158 deletions

View File

@@ -23,7 +23,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: "18"
node-version: "22"
- name: Build
id: build

View File

@@ -5,8 +5,14 @@ on:
branches:
- main
tags:
- "v*.*.*"
- "[0-9]+.[0-9]+.[0-9]+"
# Only trigger for release tags (e.g., 1.0.0, 2.3.4)
# Excludes prerelease tags (e.g., 2.8.0-beta.3, 1.0.0-rc.1)
- "[0-9]+.[0-9]+.[0-9]"
- "[0-9]+.[0-9]+.[0-9][0-9]"
- "[0-9]+.[0-9]+.[0-9][0-9][0-9]"
- "[0-9]+.[0-9][0-9].[0-9]"
- "[0-9]+.[0-9][0-9].[0-9][0-9]"
- "[0-9]+.[0-9][0-9].[0-9][0-9][0-9]"
env:
PLUGIN_NAME: share-to-notionnext # Change this to match the id of your plugin.
@@ -21,7 +27,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: "18"
node-version: "22"
# - name: Generate changelog
# id: changelog
@@ -42,21 +48,67 @@ jobs:
ls
echo "tag_name=$(git tag --sort version:refname | tail -n 1)" >> $GITHUB_OUTPUT
- name: Generate release notes
if: startsWith(github.ref, 'refs/tags/')
run: |
node - <<'NODE'
const fs = require('fs');
const tag = process.env.GITHUB_REF_NAME;
if (!tag) {
throw new Error('GITHUB_REF_NAME is not set');
}
const changelog = fs.readFileSync('CHANGELOG.md', 'utf8');
const lines = changelog.split(/\r?\n/);
const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const headerRe = new RegExp(`^##\\s+${escapeRegex(tag)}\\b`);
const start = lines.findIndex((line) => headerRe.test(line));
if (start === -1) {
throw new Error(`Could not find changelog section for tag "${tag}"`);
}
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (/^##\s+/.test(lines[i])) {
end = i;
break;
}
}
let section = lines.slice(start, end);
while (section.length > 0 && section[section.length - 1].trim() === '') {
section.pop();
}
if (section.length > 0 && section[section.length - 1].trim() === '---') {
section.pop();
while (section.length > 0 && section[section.length - 1].trim() === '') {
section.pop();
}
}
fs.writeFileSync('release-notes.md', `${section.join('\n')}\n`);
NODE
- name: Create Release
id: create_release
if: startsWith(github.ref, 'refs/tags/')
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.REPO_ACCESS_TOKEN }}
VERSION: ${{ github.ref }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
body_path: ${{ env.CHANGELOG_FILENAME }}
tag_name: ${{ github.ref_name }}
release_name: ${{ github.ref_name }}
body_path: release-notes.md
draft: false
prerelease: false
- name: Upload zip file
id: upload-zip
if: startsWith(github.ref, 'refs/tags/')
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.REPO_ACCESS_TOKEN }}
@@ -68,6 +120,7 @@ jobs:
- name: Upload main.js
id: upload-main
if: startsWith(github.ref, 'refs/tags/')
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.REPO_ACCESS_TOKEN }}
@@ -79,6 +132,7 @@ jobs:
- name: Upload manifest.json
id: upload-manifest
if: startsWith(github.ref, 'refs/tags/')
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.REPO_ACCESS_TOKEN }}
@@ -90,6 +144,7 @@ jobs:
- name: Upload markdown template
id: upload-md
if: startsWith(github.ref, 'refs/tags/')
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.REPO_ACCESS_TOKEN }}

View File

@@ -18,7 +18,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: "18"
node-version: "22"
- name: Build
id: build

3
.gitignore vendored
View File

@@ -28,3 +28,6 @@ local-data
# vitepress
docs/.vitepress/dist
docs/.vitepress/cache
# claude code
.claude

3
.npmrc
View File

@@ -1 +1,2 @@
tag-version-prefix=""
tag-version-prefix=""
message="chore: bump version to %s"

View File

@@ -1,5 +1,85 @@
# Changelog
## Unreleased
### Added
### Changed
### Fixed
## 2.8.4 (2026-03-04)
### Fixed
- Fixed version bump with `tag-version-prefix="v"` in `.npmrc` causing incorrect version format in `package.json` (e.g. `v2.8.1` instead of `2.8.1`)
## 2.8.1 (2026-03-04)
### Added
- **Auto-sync success notice setting**: Toggle whether to show success notifications for auto-sync (defaults to off)
### Changed
- Auto-sync is quieter by default: success and "start upload" notices are suppressed unless explicitly enabled
### Fixed
- Auto-sync no longer shows "All blocks has been uploaded" (`BlockUploaded`) notice when success notices are disabled
## 2.8.0 (2026-01-29)
### Added
- **Auto Sync**: Automatically sync notes on content or frontmatter changes, with configurable delay and multi-database support
- **Attachment Upload**: Upload local images and PDFs to Notion via the File Upload API and insert them as `image`/`file` blocks
- **Auto-copy Notion Link**: Option to copy the Notion page link to clipboard after syncing
- **Auto-sync frontmatter key**: Customize the frontmatter key for auto-sync database lists (default: `autosync-database`)
- Comprehensive i18n support for UI and notifications
- Prerelease workflow for beta testing via GitHub Actions and BRAT
### Changed
- Improved auto-sync behavior and notices for files without `autosync-database` or missing NotionID
- Limited attachment link parsing to **Wikilinks** and **standard Markdown links** (Obsidian/App URL formats are now TODO/disabled)
- Standardized Notion API request header `Notion-Version` to `2025-09-03`
- Reduced per-file upload limit to **5MB** to maximize compatibility across Notion plans
- Enhanced settings tab and documentation for auto-sync usage
### Fixed
- Fixed mobile compatibility issues by using `window.setTimeout` instead of `NodeJS.Timeout`
- Prevented sync loops and improved change detection for frontmatter/body updates
- File placeholder tokens no longer break due to Markdown underscore parsing
- Better block ordering when attachments are on standalone lines in Markdown
- Preserve image captions when converting `external` images to `file_upload`
- Avoid duplicate filename display on uploaded `file` blocks
- Fixed `undefined` appearing in sync success notification by adding missing `sync-preffix` i18n key
---
## v2.8.0-beta.4 (2026-01-04)
### Added
- **Attachment Upload**: Upload local images and PDFs to Notion via the File Upload API and insert them as `image`/`file` blocks
- **Auto-sync safeguard**: Auto-sync is skipped for notes containing internal attachments (manual sync required)
### Changed
- Limited attachment link parsing to **Wikilinks** and **standard Markdown links** (Obsidian/App URL formats are now TODO/disabled)
- Standardized Notion API request header `Notion-Version` to `2025-09-03`
- Reduced per-file upload limit to **5MB** to maximize compatibility across Notion plans
### Fixed
- File placeholder tokens no longer break due to Markdown underscore parsing
- Better block ordering when attachments are on standalone lines in Markdown
- Preserve image captions when converting `external` images to `file_upload`
- Avoid duplicate filename display on uploaded `file` blocks
---
## v2.8.0-beta.3 (2025-12-10)
### Added

View File

@@ -14,6 +14,7 @@
- **多种数据库类型**支持通用、NotionNext 和自定义数据库。
- **自定义属性**:在自定义数据库中,可将任何 frontmatter 键映射到任何 Notion 属性。
- **灵活同步**:即时选择要同步到哪个数据库。
- **附件上传**:自动上传本地图片和 PDF 到 Notion支持 Wikilink、Markdown 链接格式。
## 致谢

View File

@@ -15,6 +15,7 @@ Share files from Obsidian to any Notion database using the Notion API. This plug
- **Multiple Database Types**: Supports General, NotionNext, and Custom databases.
- **Custom Properties**: Map any frontmatter key to any Notion property in custom databases.
- **Flexible Syncing**: Choose which database to sync to on-the-fly.
- **Attachment Upload**: Automatically uploads local images and PDFs to Notion, supporting Wikilinks and Markdown links.
## Acknowledgment

View File

@@ -11,6 +11,73 @@ After configuring your Notion database in the plugin settings, you can start syn
To sync a note, open the note you want to sync and use the "Share to NotionNext" command from the command palette or the note context menu. This will create a new page in your Notion database with the content of your Obsidian note.
## Attachment Upload
The plugin automatically detects and uploads local attachments (images, PDFs, etc.) from your notes to Notion.
### Supported Attachment Formats
**Image formats:**
- PNG, JPG, JPEG, GIF, WebP, SVG, HEIC, TIF, TIFF, BMP
**Other formats:**
- PDF
### Supported Link Formats
Currently supported:
#### Wikilink Format (Recommended)
```markdown
![[image.png]]
![[folder/image.png]]
![[image.png|alt text]]
[[document.pdf]]
[[folder/document.pdf]]
```
#### Standard Markdown Format
```markdown
![alt text](image.png)
![alt text](folder/image.png)
![](./relative/path/image.png)
[document.pdf](document.pdf)
[document.pdf](folder/document.pdf)
```
#### TODO
- [ ] Obsidian URL Format
```markdown
![](obsidian://open?vault=MyVault&file=path/to/image.png)
```
- [ ] App URL Format
```markdown
![](app://local/path/to/image.png)
```
### How Attachment Upload Works
1. **Auto Detection**: During sync, the plugin scans your note content and identifies all local attachment references
2. **Upload to Notion**: Detected attachments are uploaded via the Notion File Upload API
3. **Link Replacement**: After successful upload, local links are replaced with Notion file references
4. **Image Display**: Images are displayed as Notion image blocks
5. **File Embedding**: Non-image files like PDFs are embedded as file blocks
### Notes
- Attachment references inside code blocks are not processed
- External URLs (`http://` or `https://`) are not processed
- Single file size limit is 5MB
- Ensure attachment files exist in your Vault
## Auto Sync
The plugin supports automatic syncing that monitors your notes for changes and automatically syncs them to Notion.
@@ -45,46 +112,17 @@ If you change the key name in the settings, update your frontmatter to match.
When auto sync is enabled:
- The plugin monitors markdown files for changes
- **Only files with the auto sync key in frontmatter will be processed**
- Files without the auto sync key are silently skipped - no sync operations or notices
- Files containing internal attachments (local images/PDFs) are skipped - sync them manually
- After you stop editing for the configured delay period, auto sync is triggered
- Files with the `autosync-database` key in frontmatter will be automatically synced
- **First-time upload is supported**: No need to manually sync first - just add the frontmatter key and the plugin will handle the initial upload
- If a file is linked to multiple databases, it will sync to all of them automatically
- After the first sync, a `NotionID-{database}` will be added to the frontmatter for future updates
### Auto Sync Scenarios
#### Scenario A-1: New File Missing Auto Sync Entry
```yaml
---
title: My New Article
tags: [blog, tech]
---
```
**Behavior:**
- ✅ Detects that the auto sync key is missing
- ✅ Detects no NotionID present (new file)
- ✅ Silently skips - no notice shown
- 📝 **To enable auto sync:** Add `autosync-database: [your-db-abbreviation]` to the frontmatter
#### Scenario A-2: Synced File Missing Auto Sync Entry
```yaml
---
title: My Article
NotionID-blog: abc123
---
```
**Behavior:**
- ✅ Detects that the auto sync key is missing
- ✅ Detects existing NotionID (file was synced before)
- ✅ Shows notice: "⚠️ Auto-sync skipped: Add autosync-database to your frontmatter to specify target databases"
- ✅ No sync operation performed
- 📝 **Action Required:** Add `autosync-database: [blog]` to the frontmatter
#### Scenario B: New Document (First-Time Auto Upload)
#### Scenario A: New Document (First-Time Auto Upload)
```yaml
---
@@ -100,7 +138,7 @@ autosync-database: [blog]
- ✅ Shows success/failure notification
- 📝 **No Action Required:** The plugin handles the initial upload automatically
#### Scenario C: Synced to One Database
#### Scenario B: Synced to One Database
```yaml
---
@@ -116,7 +154,7 @@ autosync-database: [blog]
- ✅ Shows success/failure notification from the upload command
- 📝 **No Action Required:** Changes are automatically synced
#### Scenario D: Synced to Multiple Databases
#### Scenario C: Synced to Multiple Databases
```yaml
---
@@ -135,7 +173,7 @@ autosync-database: [blog, portfolio, notes]
- ✅ Shows individual result notifications for each database
- 📝 **No Action Required:** Changes are automatically synced to all linked databases
#### Scenario E: Custom Frontmatter Key
#### Scenario D: Custom Frontmatter Key
```yaml
---

View File

@@ -7,6 +7,35 @@ description: Release notes and updates for Obsidian to NotionNext
Welcome to the Changelog for Obsidian to NotionNext! Here you'll find a detailed list of all the updates, improvements, and bug fixes made to the plugin over time from the version `2.7.0` onwards.
## v2.8.0 (2026-01-29)
### Added
- **Auto Sync**: Automatically sync notes on content or frontmatter changes, with configurable delay and multi-database support
- **Attachment Upload**: Upload local images and PDFs to Notion via the File Upload API and insert them as `image`/`file` blocks
- **Auto-copy Notion Link**: Option to copy the Notion page link to clipboard after syncing
- **Auto-sync frontmatter key**: Customize the frontmatter key for auto-sync database lists (default: `autosync-database`)
- Comprehensive i18n support for UI and notifications
- Prerelease workflow for beta testing via GitHub Actions and BRAT
### Changed
- Improved auto-sync behavior and notices for files without `autosync-database` or missing NotionID
- Limited attachment link parsing to **Wikilinks** and **standard Markdown links** (Obsidian/App URL formats are now TODO/disabled)
- Standardized Notion API request header `Notion-Version` to `2025-09-03`
- Reduced per-file upload limit to **5MB** to maximize compatibility across Notion plans
- Enhanced settings tab and documentation for auto-sync usage
### Fixed
- Fixed mobile compatibility issues by using `window.setTimeout` instead of `NodeJS.Timeout`
- Prevented sync loops and improved change detection for frontmatter/body updates
- File placeholder tokens no longer break due to Markdown underscore parsing
- Better block ordering when attachments are on standalone lines in Markdown
- Preserve image captions when converting `external` images to `file_upload`
- Avoid duplicate filename display on uploaded `file` blocks
- Fixed `undefined` appearing in sync success notification by adding missing `sync-preffix` i18n key
## v2.8.0-beta.2 (2025-11-05)
### Featured

View File

@@ -5,6 +5,6 @@ description: Planned features and improvements for Obsidian to NotionNext
# Roadmap
- [ ] Automatic Syncing: Implement automatic syncing of notes at regular intervals or upon changes.
- [ ] File Attachments: Support for syncing file attachments from Obsidian to Notion.
- [x] Automatic Syncing: Implement automatic syncing of notes at regular intervals or upon changes.
- [x] File Attachments: Support for syncing file attachments from Obsidian to Notion.
- [ ] Relation Property: Enable mapping of Obsidian links to Notion relation properties.

View File

@@ -11,6 +11,73 @@ description: 如何使用 NotionNext 插件将你的 Obsidian 笔记同步到 No
要同步一篇笔记,只需打开你想要同步的笔记,然后从命令面板(`Ctrl/Cmd + P`)或笔记的右键菜单中选择 "Share to NotionNext" 命令。这会在你的 Notion 数据库中创建一个新页面,内容与你的 Obsidian 笔记完全一致。
## 附件上传
插件支持自动检测并上传笔记中的本地附件图片、PDF 等)到 Notion。
### 支持的附件格式
**图片格式:**
- PNG, JPG, JPEG, GIF, WebP, SVG, HEIC, TIF, TIFF, BMP
**其他格式:**
- PDF
### 支持的链接格式
当前支持:
#### Wikilink 格式(推荐)
```markdown
![[image.png]]
![[folder/image.png]]
![[image.png|alt text]]
[[document.pdf]]
[[folder/document.pdf]]
```
#### 标准 Markdown 格式
```markdown
![alt text](image.png)
![alt text](folder/image.png)
![](./relative/path/image.png)
[document.pdf](document.pdf)
[document.pdf](folder/document.pdf)
```
#### TODO
- [ ] Obsidian URL 格式
```markdown
![](obsidian://open?vault=MyVault&file=path/to/image.png)
```
- [ ] App URL 格式
```markdown
![](app://local/path/to/image.png)
```
### 附件上传工作原理
1. **自动检测**:同步时,插件会自动扫描笔记内容,识别所有本地附件引用
2. **上传到 Notion**:检测到的附件会通过 Notion File Upload API 上传
3. **链接替换**:上传成功后,笔记中的本地链接会被替换为 Notion 的文件引用
4. **图片显示**:图片会作为 Notion 的图片块显示
5. **文件嵌入**PDF 等非图片文件会作为文件块嵌入
### 注意事项
- 代码块中的附件引用不会被处理
- 外部 URL`http://` 或 `https://`)不会被处理
- 单个文件大小限制为 5MB
- 确保附件文件存在于 Vault 中
## 自动同步
插件支持自动同步功能,可以监控你的笔记变化并自动同步到 Notion。
@@ -46,6 +113,9 @@ autosync-database: [blog, portfolio]
当自动同步启用后:
- 插件会监控 Markdown 文件的变化
- **只有 frontmatter 中包含自动同步配置键的文件才会被处理**
- 没有配置自动同步键的文件会被静默跳过,不会触发任何同步操作或提示
- 含有本地附件(图片/PDF的文件会跳过自动同步请手动同步
- 在你停止编辑达到配置的延迟时间后,自动触发同步
- **支持首次自动上传**:无需先手动同步,只要添加 frontmatter 键名,插件会自动处理首次上传
- 如果文件关联了多个数据库,会自动同步到所有数据库
@@ -53,40 +123,7 @@ autosync-database: [blog, portfolio]
### 自动同步场景示例
#### 场景 A-1:新文件缺少自动同步配置
```yaml
---
title: 我的新文章
tags: [博客, 技术]
---
```
**行为:**
- ✅ 检测到缺少自动同步配置键
- ✅ 检测到没有 NotionID新文件
- ✅ 静默跳过,不显示任何提示
- 📝 **如需启用自动同步:** 在 frontmatter 中添加 `autosync-database: [你的数据库简称]`
#### 场景 A-2已同步文件缺少自动同步配置
```yaml
---
title: 我的文章
NotionID-blog: abc123
---
```
**行为:**
- ✅ 检测到缺少自动同步配置键
- ✅ 检测到已存在 NotionID说明之前同步过
- ✅ 显示提示:"⚠️ 自动同步已跳过:请在 frontmatter 中添加 autosync-database 以指定目标数据库"
- ✅ 不执行同步操作
- 📝 **需要操作:** 在 frontmatter 中添加 `autosync-database: [blog]`
#### 场景 B新文档首次自动上传
#### 场景 A新文档(首次自动上传)
```yaml
---
@@ -103,7 +140,7 @@ autosync-database: [blog]
- ✅ 显示成功/失败通知
- 📝 **无需操作:** 插件会自动处理首次上传
#### 场景 C:已同步到一个数据库(更新)
#### 场景 B:已同步到一个数据库(更新)
```yaml
---
@@ -120,7 +157,7 @@ autosync-database: [blog]
- ✅ 显示上传命令返回的成功/失败通知
- 📝 **无需操作:** 变更会自动同步
#### 场景 D:同步到多个数据库
#### 场景 C:同步到多个数据库
```yaml
---

View File

@@ -4,3 +4,32 @@ description: Obsidian to NotionNext 的版本更新与变更记录
---
欢迎来到 Obsidian to NotionNext 的更新日志!这里会记录自 `2.7.0` 版本起的所有更新、改进以及问题修复,方便你快速了解插件的演进情况。
## v2.8.0 (2026-01-29)
### 新增
- **自动同步**:当内容或 frontmatter 变化时自动同步,支持可配置延迟与多数据库
- **附件上传**:通过 File Upload API 上传本地图片与 PDF并插入为 `image`/`file`
- **自动复制 Notion 链接**:同步后自动复制页面链接到剪贴板
- **自动同步 frontmatter key**:可自定义自动同步数据库列表的 frontmatter key默认 `autosync-database`
- 完整的 UI 与通知 i18n 支持
- 通过 GitHub Actions + BRAT 的预发布测试流程
### 变更
- 优化自动同步行为与提示:无 `autosync-database` 或缺少 NotionID 的文件处理更智能
- 限制附件链接解析为 **Wikilinks****标准 Markdown 链接**Obsidian/App URL 暂停/待办)
- 统一 Notion API 请求头 `Notion-Version``2025-09-03`
- 单文件上传限制降为 **5MB**,提升兼容性
- 强化设置面板与自动同步相关文档
### 修复
- 移动端兼容性修复:使用 `window.setTimeout` 替代 `NodeJS.Timeout`
- 防止同步循环,改进 frontmatter/正文变更检测
- 修复 Markdown 下划线导致的占位符解析问题
- 修复附件独占行时的块顺序
- 保留 `external` 图片转为 `file_upload` 时的图片标题
- 避免上传 `file` 块时重复显示文件名
- 修复同步成功通知出现 `undefined`(补充 `sync-preffix` i18n key

View File

@@ -3,6 +3,6 @@ title: 路线图
description: Obsidian to NotionNext 的规划功能与未来改进
---
- [ ] 自动同步:支持按时间间隔或内容变更自动同步笔记。
- [ ] 附件支持:允许将 Obsidian 笔记中的附件一同上传到 Notion。
- [x] 自动同步:支持按时间间隔或内容变更自动同步笔记。
- [x] 附件支持:允许将 Obsidian 笔记中的附件一同上传到 Notion。
- [ ] 关联属性:实现将 Obsidian 链接映射到 Notion 的 relation 属性。

View File

@@ -1,10 +1,10 @@
{
"id": "share-to-notionnext",
"name": "Share to NotionNext",
"version": "2.7.0",
"minAppVersion": "0.0.1",
"description": "Shares obsidian md file to notion with notion api for NotionNext web deploy, originally created by EasyChris/obsidian-to-notion.",
"author": "EasyChris, jxpeng98",
"authorUrl": "https://github.com/jxpeng98",
"isDesktopOnly": false
"id": "share-to-notionnext",
"name": "Share to NotionNext",
"version": "2.8.4",
"minAppVersion": "0.0.1",
"description": "Shares obsidian md file to notion with notion api for NotionNext web deploy, originally created by EasyChris/obsidian-to-notion.",
"author": "EasyChris, jxpeng98",
"authorUrl": "https://github.com/jxpeng98",
"isDesktopOnly": false
}

View File

@@ -1,19 +1,19 @@
{
"name": "share-to-notionnext",
"version": "2.7.0",
"version": "2.8.4",
"type": "module",
"description": "Share files to any Notion database using the Notion API, originally created by EasyChris/obsidian-to-notion.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"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 CHANGELOG.md"
},
"keywords": [],
"author": "Jiaxin PENG",
"license": "GNU GPLv3",
"devDependencies": {
"@types/node": "^20.5.7",
"@types/node": "^22.11.3",
"@types/yaml-front-matter": "^4.1.3",
"@typescript-eslint/eslint-plugin": "^6.16.0",
"@typescript-eslint/parser": "^6.16.0",

View File

@@ -44,6 +44,8 @@ export const en = {
AutoSyncDelay: "Auto Sync Delay (seconds)",
AutoSyncDelayDesc: "Delay in seconds to wait before syncing after a change. Prevents excessive syncs (default: 5s, min: 2s).",
AutoSyncDelayText: "Enter delay in seconds",
AutoSyncSuccessNotice: "Auto Sync Success Notice",
AutoSyncSuccessNoticeDesc: "Show a notification when auto-sync succeeds (default: off; failures are still notified).",
NotionGeneralSettingHeader: "General Notion Database Settings",
NotionGeneralButton: "General Database Sync",
NotionGeneralButtonDesc: "Enables the 'Sync to General Database' command in the command palette (default: on).",
@@ -91,6 +93,7 @@ export const en = {
SettingsMigrated: "✨ Settings updated! Auto-Sync is now available. Check the settings to learn more.",
AutoSyncNoNotionID: "🆕 Auto-sync: First upload to Notion",
AutoSyncMissingDatabaseList: "⚠️ Auto-sync skipped: Add `{key}: [database_name]` to your frontmatter to specify target databases.",
AutoSyncSkippedAttachments: "⚠️ Auto-sync skipped: {filename} contains internal attachments (images/PDFs). Please sync manually.",
AutoSyncMultipleSync: "🔄 Auto-sync: Syncing to {count} database(s)...",
AutoSyncFailed: "Auto-sync to {database} failed: {error}",
AutoSyncError: "Auto-sync for {filename} failed: {error}",

View File

@@ -42,6 +42,8 @@ export const ja = {
AutoSyncDelay: "自動同期の遅延(秒)",
AutoSyncDelayDesc: "変更が検知されてから同期を開始するまでの遅延時間。同期の頻発を防ぎますデフォルト5秒、最小2秒。",
AutoSyncDelayText: "遅延秒数を入力",
AutoSyncSuccessNotice: "自動同期成功通知",
AutoSyncSuccessNoticeDesc: "自動同期が成功したときに通知を表示します(デフォルト:オフ。失敗時は通知されます)。",
NotionGeneralSettingHeader: "一般Notionデータベース設定",
NotionGeneralButton: "一般データベース同期",
NotionGeneralButtonDesc: "有効にすると、コマンドパレットに「一般データベースへ同期」が表示されます(デフォルト:オン)。",
@@ -84,6 +86,7 @@ export const ja = {
SettingsMigrated: "✨ 設定が更新されました!自動同期が利用可能です。詳細は設定画面をご確認ください。",
AutoSyncNoNotionID: "🆕 自動同期Notionへ初めてアップロードします",
AutoSyncMissingDatabaseList: "⚠️ 自動同期をスキップfrontmatterに `{key}: [データベース名]` を追加して同期先を指定してください。",
AutoSyncSkippedAttachments: "⚠️ 自動同期をスキップ:{filename} に内部添付(画像/PDFが含まれています。手動で同期してください。",
AutoSyncMultipleSync: "🔄 自動同期:{count}個のデータベースに同期しています...",
AutoSyncFailed: "{database}への自動同期に失敗しました:{error}",
AutoSyncError: "{filename}の自動同期に失敗しました:{error}",

View File

@@ -44,6 +44,8 @@ export const zh = {
AutoSyncDelay: "自动同步延迟(秒)",
AutoSyncDelayDesc: "文档修改后,等待指定秒数再触发自动同步,以避免频繁操作(默认 5 秒,最少 2 秒)",
AutoSyncDelayText: "输入延迟秒数",
AutoSyncSuccessNotice: "自动同步成功通知",
AutoSyncSuccessNoticeDesc: "是否在自动同步成功后弹出通知(默认关闭,仅在失败时通知)",
NotionGeneralSettingHeader: "通用 Notion 数据库设置",
NotionGeneralButton: "通用数据库同步",
NotionGeneralButtonDesc: "启用后,命令面板中将显示“同步到通用数据库”命令(默认开启)",
@@ -87,6 +89,7 @@ export const zh = {
SettingsMigrated: "✨ 插件设置已更新!新增自动同步功能,详情请查看设置。",
AutoSyncNoNotionID: "🆕 自动同步:首次上传到 Notion",
AutoSyncMissingDatabaseList: "⚠️ 自动同步已跳过:请在 frontmatter 中添加 \"{key}\" 以指定目标数据库。",
AutoSyncSkippedAttachments: "⚠️ 自动同步已跳过:检测到 {filename} 含有本地附件(图片/PDF请手动同步。",
AutoSyncMultipleSync: "🔄 自动同步:正在同步到 {count} 个数据库...",
AutoSyncFailed: "同步到 {database} 失败:{error}",
AutoSyncError: "同步 {filename} 失败:{error}",

View File

@@ -4,6 +4,7 @@ import { i18nConfig } from "src/lang/I18n";
import ribbonCommands from "src/commands/NotionCommands";
import { ObsidianSettingTab, PluginSettings, DEFAULT_SETTINGS, DatabaseDetails } from "src/ui/settingTabs";
import { uploadCommandNext, uploadCommandGeneral, uploadCommandCustom } from "src/upload/uploadCommand";
import { AttachmentProcessor } from "src/upload/common/AttachmentProcessor";
import { DEFAULT_AUTO_SYNC_DATABASE_KEY, parseAutoSyncDatabaseList, resolveAutoSyncKey } from "src/utils/frontmatter";
// Remember to rename these classes and interfaces!
@@ -18,6 +19,7 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
private syncingFiles: Set<string> = new Set();
private lastFrontmatterCache: Map<string, any> = new Map();
private lastContentHashCache: Map<string, string> = new Map();
private autoSyncAttachmentBlocked: Set<string> = new Set();
async onload() {
@@ -69,6 +71,7 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
}
this.lastFrontmatterCache.clear();
this.lastContentHashCache.clear();
this.autoSyncAttachmentBlocked.clear();
}
@@ -89,6 +92,9 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
if (typeof this.settings.autoSyncDelay !== 'number' || this.settings.autoSyncDelay < 2) {
this.settings.autoSyncDelay = DEFAULT_SETTINGS.autoSyncDelay;
}
if (typeof this.settings.autoSyncSuccessNotice !== 'boolean') {
this.settings.autoSyncSuccessNotice = DEFAULT_SETTINGS.autoSyncSuccessNotice;
}
if (typeof this.settings.NotionLinkDisplay !== 'boolean') {
this.settings.NotionLinkDisplay = DEFAULT_SETTINGS.NotionLinkDisplay;
}
@@ -109,6 +115,7 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
const needsSave = !loadedData ||
loadedData.autoSync === undefined ||
loadedData.autoSyncDelay === undefined ||
loadedData.autoSyncSuccessNotice === undefined ||
loadedData.NotionLinkDisplay === undefined ||
loadedData.autoCopyNotionLink === undefined ||
loadedData.autoSyncFrontmatterKey === undefined;
@@ -120,6 +127,7 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
} else {
if (loadedData.autoSync === undefined) migratedFields.push('autoSync');
if (loadedData.autoSyncDelay === undefined) migratedFields.push('autoSyncDelay');
if (loadedData.autoSyncSuccessNotice === undefined) migratedFields.push('autoSyncSuccessNotice');
if (loadedData.NotionLinkDisplay === undefined) migratedFields.push('NotionLinkDisplay');
if (loadedData.autoCopyNotionLink === undefined) migratedFields.push('autoCopyNotionLink');
@@ -143,6 +151,7 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
console.log('[Settings] Settings saved successfully', {
autoSync: this.settings.autoSync,
autoSyncDelay: this.settings.autoSyncDelay,
autoSyncSuccessNotice: this.settings.autoSyncSuccessNotice,
NotionLinkDisplay: this.settings.NotionLinkDisplay,
autoSyncFrontmatterKey: this.settings.autoSyncFrontmatterKey,
databaseCount: Object.keys(this.settings.databaseDetails || {}).length
@@ -159,6 +168,10 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
console.warn('[Settings] Invalid autoSyncDelay value, resetting to default');
this.settings.autoSyncDelay = DEFAULT_SETTINGS.autoSyncDelay;
}
if (typeof this.settings.autoSyncSuccessNotice !== 'boolean') {
console.warn('[Settings] Invalid autoSyncSuccessNotice value, resetting to default');
this.settings.autoSyncSuccessNotice = DEFAULT_SETTINGS.autoSyncSuccessNotice;
}
if (typeof this.settings.NotionLinkDisplay !== 'boolean') {
console.warn('[Settings] Invalid NotionLinkDisplay value, resetting to default');
this.settings.NotionLinkDisplay = DEFAULT_SETTINGS.NotionLinkDisplay;
@@ -315,7 +328,14 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
// Get file's frontmatter
const frontMatter = this.app.metadataCache.getFileCache(file)?.frontmatter;
if (!frontMatter) {
console.log(`[AutoSync] No frontmatter found in ${file.path}`);
return;
}
// Check autosync property first - only proceed if it exists
const autoSyncKey = this.getAutoSyncFrontmatterKey();
const autoSyncTargets = parseAutoSyncDatabaseList(frontMatter[autoSyncKey]);
if (autoSyncTargets.length === 0) {
return;
}
@@ -331,50 +351,11 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
const frontmatterOnlyNotionIDChanged = this.onlyNotionIDChanged(lastFrontmatter, frontMatter);
const contentUnchanged = contentHash === lastContentHash;
console.log(`[AutoSync] Change analysis for ${file.basename}:`, {
frontmatterOnlyNotionIDChanged,
contentUnchanged,
frontmatterHasRealChanges: !frontmatterOnlyNotionIDChanged,
contentChanged: !contentUnchanged,
willSync: !(frontmatterOnlyNotionIDChanged && contentUnchanged)
});
// Only skip sync if BOTH conditions are true:
// 1. Frontmatter only has NotionID changes (no real user changes)
// 2. Content is completely unchanged
if (frontmatterOnlyNotionIDChanged && contentUnchanged) {
console.log(`[AutoSync] Only NotionID updated (from sync), content unchanged - skipping auto sync`);
// Update cache even when skipping, so next comparison uses the current state
this.lastFrontmatterCache.set(file.path, { ...frontMatter });
this.lastContentHashCache.set(file.path, contentHash);
return;
}
if (!contentUnchanged) {
console.log(`[AutoSync] Content changed - will sync`);
} else if (!frontmatterOnlyNotionIDChanged) {
console.log(`[AutoSync] Frontmatter changed - will sync`);
}
}
const autoSyncKey = this.getAutoSyncFrontmatterKey();
const autoSyncTargets = parseAutoSyncDatabaseList(frontMatter[autoSyncKey]);
// If no autosync-database field specified
if (autoSyncTargets.length === 0) {
// Check if file has any existing NotionID (meaning it was synced before)
const hasExistingNotionID = Object.keys(frontMatter).some(key =>
key.startsWith('NotionID-') || key === 'NotionID'
);
if (hasExistingNotionID) {
// User likely forgot to add autosync-database - show a notice
const message = i18nConfig.AutoSyncMissingDatabaseList.replace('{key}', autoSyncKey);
new Notice(message, 8000);
console.log(`[AutoSync] File ${file.path} has NotionID but missing autosync-database - showing notice`);
}
// Silently skip files without NotionID (new files that don't need auto-sync)
return;
}
@@ -415,6 +396,20 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
return;
}
// Temporarily disable auto-sync for files containing internal attachments (local images/PDFs)
const attachmentProcessor = new AttachmentProcessor(this, foundDatabases[0].dbDetails);
if (attachmentProcessor.hasInternalAttachments(content, file)) {
if (!this.autoSyncAttachmentBlocked.has(file.path)) {
const message = i18nConfig.AutoSyncSkippedAttachments
.replace('{filename}', file.basename);
new Notice(message, 6000);
this.autoSyncAttachmentBlocked.add(file.path);
}
console.log(`[AutoSync] Internal attachments detected in ${file.path}, auto-sync skipped`);
return;
}
this.autoSyncAttachmentBlocked.delete(file.path);
// Notify user about multiple syncs if applicable
if (foundDatabases.length > 1) {
const message = i18nConfig.AutoSyncMultipleSync.replace('{count}', String(foundDatabases.length));
@@ -430,11 +425,11 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
try {
// Trigger appropriate upload command based on database format
if (dbDetails.format === 'next') {
await uploadCommandNext(this, this.settings, dbDetails, this.app);
await uploadCommandNext(this, this.settings, dbDetails, this.app, { isAutoSync: true });
} else if (dbDetails.format === 'general') {
await uploadCommandGeneral(this, this.settings, dbDetails, this.app);
await uploadCommandGeneral(this, this.settings, dbDetails, this.app, { isAutoSync: true });
} else if (dbDetails.format === 'custom') {
await uploadCommandCustom(this, this.settings, dbDetails, this.app);
await uploadCommandCustom(this, this.settings, dbDetails, this.app, { isAutoSync: true });
}
} catch (error) {
console.error(`[AutoSync] Error syncing to ${dbDetails.fullName}:`, error);

View File

@@ -17,6 +17,7 @@ export interface PluginSettings {
autoCopyNotionLink: boolean;
autoSync: boolean;
autoSyncDelay: number;
autoSyncSuccessNotice: boolean;
autoSyncFrontmatterKey: string;
proxy: string;
GeneralButton: boolean;
@@ -57,6 +58,7 @@ export const DEFAULT_SETTINGS: PluginSettings = {
autoCopyNotionLink: true,
autoSync: false,
autoSyncDelay: 5,
autoSyncSuccessNotice: false,
autoSyncFrontmatterKey: DEFAULT_AUTO_SYNC_DATABASE_KEY,
proxy: "",
GeneralButton: true,
@@ -100,6 +102,15 @@ export class ObsidianSettingTab extends PluginSettingTab {
this.createSettingEl(containerEl, i18nConfig.AutoCopyNotionLink, i18nConfig.AutoCopyNotionLinkDesc, 'toggle', i18nConfig.AutoCopyNotionLink, this.plugin.settings.autoCopyNotionLink, 'autoCopyNotionLink')
this.createSettingEl(containerEl, i18nConfig.AutoSync, i18nConfig.AutoSyncDesc, 'toggle', i18nConfig.AutoSync, this.plugin.settings.autoSync, 'autoSync')
this.createSettingEl(
containerEl,
i18nConfig.AutoSyncSuccessNotice,
i18nConfig.AutoSyncSuccessNoticeDesc,
'toggle',
i18nConfig.AutoSyncSuccessNotice,
this.plugin.settings.autoSyncSuccessNotice,
'autoSyncSuccessNotice'
)
new Setting(containerEl)
.setName(i18nConfig.AutoSyncFrontmatterKey)
@@ -116,7 +127,7 @@ export class ObsidianSettingTab extends PluginSettingTab {
// Auto Sync Delay setting - only visible when autoSync is enabled
this.autoSyncDelayContainer = containerEl.createDiv();
const delaySetting = new Setting(this.autoSyncDelayContainer)
new Setting(this.autoSyncDelayContainer)
.setName(i18nConfig.AutoSyncDelay)
.setDesc(i18nConfig.AutoSyncDelayDesc)
.addText((text) =>
@@ -369,4 +380,3 @@ export class ObsidianSettingTab extends PluginSettingTab {
}
}
}

View File

@@ -6,6 +6,7 @@ import MyPlugin from "src/main";
import { DatabaseDetails } from "../ui/settingTabs";
import { updateYamlInfo } from "./updateYaml";
import { UploadBase, NotionPageResponse } from "./common/UploadBase";
import { AttachmentProcessor, applyBlockRewrites } from "./common/AttachmentProcessor";
export type DatasetType = "general" | "next" | "custom";
@@ -14,6 +15,7 @@ interface BaseSyncRequest {
markdown: string;
nowFile: TFile;
app: App;
isAutoSync?: boolean;
}
interface GeneralSyncRequest extends BaseSyncRequest {
@@ -90,6 +92,7 @@ export class Upload2Notion extends UploadBase {
async sync(request: SyncRequest): Promise<NotionPageResponse> {
const startedAt = Date.now();
this.isAutoSync = !!request.isAutoSync;
let response: NotionPageResponse;
@@ -128,7 +131,7 @@ export class Upload2Notion extends UploadBase {
cover: request.cover,
tags: request.tags,
});
const blocks = this.buildBlocks(request.markdown, {
const blocks = await this.buildBlocks(request.markdown, request.nowFile, {
notionLimits: {truncate: false},
});
const notionId = this.getNotionId(request.app, request.nowFile);
@@ -153,7 +156,7 @@ export class Upload2Notion extends UploadBase {
slug: request.slug,
category: request.category,
});
const blocks = this.buildBlocks(request.markdown, {
const blocks = await this.buildBlocks(request.markdown, request.nowFile, {
notionLimits: {truncate: false},
});
this.splitRichTextParagraphs(blocks);
@@ -187,7 +190,7 @@ export class Upload2Notion extends UploadBase {
console.log(`[Upload2Notion] Handling custom dataset`, {
customKeys: Object.keys(request.customValues || {}),
});
const blocks = this.buildBlocks(request.markdown, {
const blocks = await this.buildBlocks(request.markdown, request.nowFile, {
strictImageUrls: true,
notionLimits: {truncate: false},
});
@@ -206,10 +209,24 @@ export class Upload2Notion extends UploadBase {
});
}
private buildBlocks(markdown: string, options: Record<string, unknown>): any[] {
private async buildBlocks(markdown: string, nowFile: TFile, options: Record<string, unknown>): Promise<any[]> {
const yamlContent: any = yamlFrontMatter.loadFront(markdown);
const content = yamlContent.__content;
let content: string = yamlContent.__content ?? "";
// Process local attachments
const processor = new AttachmentProcessor(this.plugin, this.dbDetails);
const result = await processor.processContent(content, nowFile);
content = result.content;
const imageUrlToUploadId = result.imageUrlToUploadId;
const filePlaceholderToUpload = result.filePlaceholderToUpload;
const blocks = markdownToBlocks(content, options);
// Apply block rewrites for uploaded files
if (Object.keys(imageUrlToUploadId).length > 0 || Object.keys(filePlaceholderToUpload).length > 0) {
applyBlockRewrites(blocks, { imageUrlToUploadId, filePlaceholderToUpload });
}
this.debugLog("Upload2Notion", "Converted markdown to blocks", {
blockCount: blocks.length,
firstBlockTypes: blocks.slice(0, 5).map((block: any) => block?.type),

View File

@@ -0,0 +1,697 @@
import { App, TFile, normalizePath } from "obsidian";
import { AttachmentUploader } from "./AttachmentUploader";
import type MyPlugin from "src/main";
import type { DatabaseDetails } from "../../ui/settingTabs";
export interface AttachmentPrepareResult {
content: string;
imageUrlToUploadId: Record<string, string>;
filePlaceholderToUpload: Record<string, { id: string; name: string }>;
}
interface LocalAttachment {
file: TFile;
originalRef: string;
}
const IMAGE_EXTENSIONS = new Set([
"png", "jpg", "jpeg", "gif", "webp", "svg", "heic", "tif", "tiff", "bmp"
]);
const SUPPORTED_EXTENSIONS = new Set([
...IMAGE_EXTENSIONS, "pdf"
]);
export class AttachmentProcessor {
private plugin: MyPlugin;
private dbDetails: DatabaseDetails;
private uploader: AttachmentUploader;
constructor(plugin: MyPlugin, dbDetails: DatabaseDetails) {
this.plugin = plugin;
this.dbDetails = dbDetails;
this.uploader = new AttachmentUploader(plugin, dbDetails);
}
private isStandaloneOnLine(input: string, offset: number, match: string): boolean {
const lineStart = input.lastIndexOf("\n", Math.max(0, offset - 1)) + 1;
const lineEndIdx = input.indexOf("\n", offset + match.length);
const lineEnd = lineEndIdx === -1 ? input.length : lineEndIdx;
const before = input.slice(lineStart, offset).trim();
const after = input.slice(offset + match.length, lineEnd).trim();
return before.length === 0 && after.length === 0;
}
hasInternalAttachments(content: string, sourceFile: TFile): boolean {
const app = this.plugin.app;
const contentWithoutCode = content.replace(/```[\s\S]*?```|`[^`\n]+`/g, "");
const embedImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
const embedWikilinkRegex = /!\[\[([^\]]+)\]\]/g;
const linkMarkdownRegex = /(?<!!)\[([^\]]+)\]\(([^)]+)\)/g;
const linkWikilinkRegex = /(?<!!)\[\[([^\]]+)\]\]/g;
let match: RegExpExecArray | null;
while ((match = embedImageRegex.exec(contentWithoutCode)) !== null) {
const rawPath = this.parseDestination(match[2]);
if (this.shouldSkipLinkDestination(rawPath)) continue;
const file = this.resolveFile(app, sourceFile, rawPath, { log: false });
if (file && this.isSupported(file)) return true;
}
while ((match = embedWikilinkRegex.exec(contentWithoutCode)) !== null) {
const linkPath = this.parseWikilink(match[1]);
if (this.shouldSkipLinkDestination(linkPath)) continue;
const file = this.resolveFile(app, sourceFile, linkPath, { log: false });
if (file && this.isSupported(file)) return true;
}
while ((match = linkMarkdownRegex.exec(contentWithoutCode)) !== null) {
const rawPath = this.parseDestination(match[2]);
if (this.shouldSkipLinkDestination(rawPath)) continue;
const file = this.resolveFile(app, sourceFile, rawPath, { log: false });
if (file && this.isSupported(file)) return true;
}
while ((match = linkWikilinkRegex.exec(contentWithoutCode)) !== null) {
const linkPath = this.parseWikilink(match[1]);
if (this.shouldSkipLinkDestination(linkPath)) continue;
const file = this.resolveFile(app, sourceFile, linkPath, { log: false });
if (file && this.isSupported(file)) return true;
}
return false;
}
async processContent(content: string, sourceFile: TFile): Promise<AttachmentPrepareResult> {
console.log(`[AttachmentProcessor] Starting attachment processing for file: ${sourceFile.path}`);
// Strip code blocks before processing to avoid matching inside them
const codeBlockPlaceholders: string[] = [];
const contentWithoutCode = content.replace(/```[\s\S]*?```|`[^`\n]+`/g, (match) => {
const placeholder = `__CODE_BLOCK_${codeBlockPlaceholders.length}__`;
codeBlockPlaceholders.push(match);
return placeholder;
});
console.log(`[AttachmentProcessor] Stripped ${codeBlockPlaceholders.length} code blocks`);
const { internal, external } = this.collectAttachments(contentWithoutCode, sourceFile);
if (external.length > 0) {
console.log(`[AttachmentProcessor] Found ${external.length} external reference(s) (will be skipped):`);
external.forEach((ref, idx) => {
console.log(` ${idx + 1}. [EXTERNAL] ${ref}`);
});
}
if (internal.length === 0) {
console.log(`[AttachmentProcessor] No internal attachments found in ${sourceFile.path}`);
return {
content,
imageUrlToUploadId: {},
filePlaceholderToUpload: {},
};
}
console.log(`[AttachmentProcessor] Found ${internal.length} internal attachment(s) to upload:`);
internal.forEach((attachment, idx) => {
const typeLabel = this.isImage(attachment.file) ? 'IMAGE' : 'FILE';
const sizeKB = (attachment.file.stat.size / 1024).toFixed(2);
console.log(` ${idx + 1}. [${typeLabel}] ${attachment.file.path} (${sizeKB} KB) - Ref: "${attachment.originalRef}"`);
});
const uploadedMap = new Map<string, { id: string; file: TFile }>();
for (const attachment of internal) {
try {
console.log(`[AttachmentProcessor] Uploading: ${attachment.file.path} (${(attachment.file.stat.size / 1024).toFixed(2)} KB)`);
const result = await this.uploader.uploadFile(attachment.file);
uploadedMap.set(attachment.file.path, { id: result.id, file: attachment.file });
console.log(`[AttachmentProcessor] ✓ Uploaded successfully: ${attachment.file.name} -> ${result.id}`);
} catch (error) {
console.error(`[AttachmentProcessor] ✗ Failed to upload ${attachment.file.path}:`, error);
}
}
console.log(`[AttachmentProcessor] Upload complete: ${uploadedMap.size}/${internal.length} successful`);
const rewriteResult = this.rewriteContent(contentWithoutCode, sourceFile, uploadedMap);
// Restore code blocks
let restoredContent = rewriteResult.content;
codeBlockPlaceholders.forEach((code, idx) => {
restoredContent = restoredContent.replace(`__CODE_BLOCK_${idx}__`, code);
});
console.log(`[AttachmentProcessor] Content rewrite complete:`, {
imageReplacements: Object.keys(rewriteResult.imageUrlToUploadId).length,
fileReplacements: Object.keys(rewriteResult.filePlaceholderToUpload).length,
});
return {
content: restoredContent,
imageUrlToUploadId: rewriteResult.imageUrlToUploadId,
filePlaceholderToUpload: rewriteResult.filePlaceholderToUpload,
};
}
private collectAttachments(content: string, sourceFile: TFile): { internal: LocalAttachment[]; external: string[] } {
const internal: LocalAttachment[] = [];
const external: string[] = [];
const seen = new Set<string>();
const app = this.plugin.app;
console.log(`[AttachmentProcessor] Scanning for attachments in content (${content.length} chars)`);
// Match all types of references: ![...](...) ![[...]] [...](...) [[...]]
const embedImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
const embedWikilinkRegex = /!\[\[([^\]]+)\]\]/g;
const linkMarkdownRegex = /(?<!!)\[([^\]]+)\]\(([^)]+)\)/g;
const linkWikilinkRegex = /(?<!!)\[\[([^\]]+)\]\]/g;
let match;
// Process embedded images: ![alt](path)
let embedCount = 0;
while ((match = embedImageRegex.exec(content)) !== null) {
embedCount++;
const rawPath = this.parseDestination(match[2]);
console.log(`[AttachmentProcessor] Found embedded image #${embedCount}: "${match[0]}" -> parsed path: "${rawPath}"`);
if (this.shouldSkipLinkDestination(rawPath)) {
if (this.isTodoUrl(rawPath)) {
console.log(`[AttachmentProcessor] ⊘ Unsupported URL scheme (TODO, skipped): ${rawPath}`);
} else {
console.log(`[AttachmentProcessor] ⊘ External URL (skipped): ${rawPath}`);
}
external.push(match[0]);
continue;
}
const file = this.resolveFile(app, sourceFile, rawPath);
if (!file) {
console.log(`[AttachmentProcessor] ✗ Could not resolve file for path: "${rawPath}"`);
} else if (!this.isSupported(file)) {
console.log(`[AttachmentProcessor] ✗ Unsupported file type: ${file.extension} (${file.path})`);
} else if (seen.has(file.path)) {
console.log(`[AttachmentProcessor] ⊚ Duplicate (already added): ${file.path}`);
} else {
seen.add(file.path);
internal.push({ file, originalRef: match[0] });
console.log(`[AttachmentProcessor] ✓ Added: ${file.path}`);
}
}
// Process embedded wikilinks: ![[path]]
let wikiEmbedCount = 0;
while ((match = embedWikilinkRegex.exec(content)) !== null) {
wikiEmbedCount++;
const linkPath = this.parseWikilink(match[1]);
console.log(`[AttachmentProcessor] Found embedded wikilink #${wikiEmbedCount}: "${match[0]}" -> parsed path: "${linkPath}"`);
if (this.shouldSkipLinkDestination(linkPath)) {
if (this.isTodoUrl(linkPath)) {
console.log(`[AttachmentProcessor] ⊘ Unsupported URL scheme (TODO, skipped): ${linkPath}`);
} else {
console.log(`[AttachmentProcessor] ⊘ External URL (skipped): ${linkPath}`);
}
external.push(match[0]);
continue;
}
const file = this.resolveFile(app, sourceFile, linkPath);
if (!file) {
console.log(`[AttachmentProcessor] ✗ Could not resolve file for path: "${linkPath}"`);
} else if (!this.isSupported(file)) {
console.log(`[AttachmentProcessor] ✗ Unsupported file type: ${file.extension} (${file.path})`);
} else if (seen.has(file.path)) {
console.log(`[AttachmentProcessor] ⊚ Duplicate (already added): ${file.path}`);
} else {
seen.add(file.path);
internal.push({ file, originalRef: match[0] });
console.log(`[AttachmentProcessor] ✓ Added: ${file.path}`);
}
}
// Process markdown links: [text](path)
let linkCount = 0;
while ((match = linkMarkdownRegex.exec(content)) !== null) {
linkCount++;
const rawPath = this.parseDestination(match[2]);
console.log(`[AttachmentProcessor] Found markdown link #${linkCount}: "${match[0]}" -> parsed path: "${rawPath}"`);
if (this.shouldSkipLinkDestination(rawPath)) {
if (this.isTodoUrl(rawPath)) {
console.log(`[AttachmentProcessor] ⊘ Unsupported URL scheme (TODO, skipped): ${rawPath}`);
} else {
console.log(`[AttachmentProcessor] ⊘ External URL (skipped): ${rawPath}`);
}
external.push(match[0]);
continue;
}
const file = this.resolveFile(app, sourceFile, rawPath);
if (!file) {
console.log(`[AttachmentProcessor] ✗ Could not resolve file for path: "${rawPath}"`);
} else if (!this.isSupported(file)) {
console.log(`[AttachmentProcessor] ✗ Unsupported file type: ${file.extension} (${file.path})`);
} else if (seen.has(file.path)) {
console.log(`[AttachmentProcessor] ⊚ Duplicate (already added): ${file.path}`);
} else {
seen.add(file.path);
internal.push({ file, originalRef: match[0] });
console.log(`[AttachmentProcessor] ✓ Added: ${file.path}`);
}
}
// Process wikilink references: [[path]]
let wikilinkCount = 0;
while ((match = linkWikilinkRegex.exec(content)) !== null) {
wikilinkCount++;
const linkPath = this.parseWikilink(match[1]);
console.log(`[AttachmentProcessor] Found wikilink reference #${wikilinkCount}: "${match[0]}" -> parsed path: "${linkPath}"`);
if (this.shouldSkipLinkDestination(linkPath)) {
if (this.isTodoUrl(linkPath)) {
console.log(`[AttachmentProcessor] ⊘ Unsupported URL scheme (TODO, skipped): ${linkPath}`);
} else {
console.log(`[AttachmentProcessor] ⊘ External URL (skipped): ${linkPath}`);
}
external.push(match[0]);
continue;
}
const file = this.resolveFile(app, sourceFile, linkPath);
if (!file) {
console.log(`[AttachmentProcessor] ✗ Could not resolve file for path: "${linkPath}"`);
} else if (!this.isSupported(file)) {
console.log(`[AttachmentProcessor] ✗ Unsupported file type: ${file.extension} (${file.path})`);
} else if (seen.has(file.path)) {
console.log(`[AttachmentProcessor] ⊚ Duplicate (already added): ${file.path}`);
} else {
seen.add(file.path);
internal.push({ file, originalRef: match[0] });
console.log(`[AttachmentProcessor] ✓ Added: ${file.path}`);
}
}
console.log(`[AttachmentProcessor] Scan complete: ${embedCount} embeds, ${wikiEmbedCount} wiki-embeds, ${linkCount} links, ${wikilinkCount} wikilinks -> ${internal.length} internal attachments, ${external.length} external references`);
return { internal, external };
}
private rewriteContent(
content: string,
sourceFile: TFile,
uploadedMap: Map<string, { id: string; file: TFile }>
): AttachmentPrepareResult {
const imageUrlToUploadId: Record<string, string> = {};
const filePlaceholderToUpload: Record<string, { id: string; name: string }> = {};
const app = this.plugin.app;
let rewritten = content;
// Rewrite embedded images: ![alt](path)
rewritten = rewritten.replace(
/!\[([^\]]*)\]\(([^)]+)\)/g,
(fullMatch, altText, rawDest, offset, input) => {
const path = this.parseDestination(rawDest);
if (this.shouldSkipLinkDestination(path)) return fullMatch;
const file = this.resolveFile(app, sourceFile, path);
if (!file) return fullMatch;
const uploaded = uploadedMap.get(file.path);
if (!uploaded) return fullMatch;
if (this.isImage(file)) {
const sentinelUrl = this.buildSentinelUrl(uploaded.id, file, altText);
imageUrlToUploadId[sentinelUrl] = uploaded.id;
const markdown = `![${altText}](${sentinelUrl})`;
return typeof offset === "number" && typeof input === "string" && this.isStandaloneOnLine(input, offset, fullMatch)
? `\n\n${markdown}\n\n`
: markdown;
} else {
const token = `__NOTION_FILE_UPLOAD__:${uploaded.id}`;
filePlaceholderToUpload[token] = { id: uploaded.id, name: file.name };
return `\n\n\`${token}\`\n\n`;
}
}
);
// Rewrite embedded wikilinks: ![[path]]
rewritten = rewritten.replace(
/!\[\[([^\]]+)\]\]/g,
(fullMatch, inner, offset, input) => {
const linkPath = this.parseWikilink(inner);
if (this.shouldSkipLinkDestination(linkPath)) return fullMatch;
const file = this.resolveFile(app, sourceFile, linkPath);
if (!file) return fullMatch;
const uploaded = uploadedMap.get(file.path);
if (!uploaded) return fullMatch;
if (this.isImage(file)) {
const sentinelUrl = this.buildSentinelUrl(uploaded.id, file);
imageUrlToUploadId[sentinelUrl] = uploaded.id;
const markdown = `![](${sentinelUrl})`;
return typeof offset === "number" && typeof input === "string" && this.isStandaloneOnLine(input, offset, fullMatch)
? `\n\n${markdown}\n\n`
: markdown;
} else {
const token = `__NOTION_FILE_UPLOAD__:${uploaded.id}`;
filePlaceholderToUpload[token] = { id: uploaded.id, name: file.name };
return `\n\n\`${token}\`\n\n`;
}
}
);
// Rewrite markdown links: [text](path)
rewritten = rewritten.replace(
/(?<!!)\[([^\]]+)\]\(([^)]+)\)/g,
(fullMatch, _linkText, rawDest) => {
const path = this.parseDestination(rawDest);
if (this.shouldSkipLinkDestination(path)) return fullMatch;
const file = this.resolveFile(app, sourceFile, path);
if (!file) return fullMatch;
const uploaded = uploadedMap.get(file.path);
if (!uploaded) return fullMatch;
// For markdown links, always use file placeholder (non-image treatment)
const token = `__NOTION_FILE_UPLOAD__:${uploaded.id}`;
filePlaceholderToUpload[token] = { id: uploaded.id, name: file.name };
return `\n\n\`${token}\`\n\n`;
}
);
// Rewrite wikilink references: [[path]]
rewritten = rewritten.replace(
/(?<!!)\[\[([^\]]+)\]\]/g,
(fullMatch, inner) => {
const linkPath = this.parseWikilink(inner);
if (this.shouldSkipLinkDestination(linkPath)) return fullMatch;
const file = this.resolveFile(app, sourceFile, linkPath);
if (!file) return fullMatch;
const uploaded = uploadedMap.get(file.path);
if (!uploaded) return fullMatch;
// For wikilink references, always use file placeholder (non-image treatment)
const token = `__NOTION_FILE_UPLOAD__:${uploaded.id}`;
filePlaceholderToUpload[token] = { id: uploaded.id, name: file.name };
return `\n\n\`${token}\`\n\n`;
}
);
return { content: rewritten, imageUrlToUploadId, filePlaceholderToUpload };
}
private parseDestination(rawDest: string): string {
const trimmed = rawDest.trim();
// Handle angle-bracket wrapped URLs: <path>
if (trimmed.startsWith("<") && trimmed.includes(">")) {
const end = trimmed.indexOf(">");
return this.decodePathOrUrl(trimmed.slice(1, end));
}
// Take first non-space segment
const match = trimmed.match(/^(\S+)/);
return this.decodePathOrUrl(match ? match[1] : trimmed);
}
private parseWikilink(inner: string): string {
const trimmed = inner.trim();
// Remove alias: [[path|alias]]
const beforeAlias = trimmed.split("|")[0]?.trim() ?? trimmed;
// Remove heading: [[path#heading]]
const beforeHeading = beforeAlias.split("#")[0]?.trim() ?? beforeAlias;
return this.decodePathOrUrl(beforeHeading);
}
private decodePathOrUrl(value: string): string {
/*
// TODO: Support `obsidian://` and `app://` URL destinations.
// For now we only support wikilink + standard markdown formats with vault paths.
if (value.startsWith("obsidian://") || value.startsWith("app://")) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
*/
// For regular paths, strip query/hash and decode
const stripped = value.split(/[?#]/)[0] ?? value;
try {
return decodeURIComponent(stripped);
} catch {
return stripped;
}
}
private isExternalUrl(link: string): boolean {
return link.startsWith("http://") || link.startsWith("https://");
}
private isTodoUrl(link: string): boolean {
return link.startsWith("obsidian://") || link.startsWith("app://");
}
private shouldSkipLinkDestination(link: string): boolean {
return this.isExternalUrl(link) || this.isTodoUrl(link);
}
private resolveFile(app: App, sourceFile: TFile, link: string, options?: { log?: boolean }): TFile | null {
const shouldLog = options?.log !== false;
const log = (...args: any[]) => {
if (shouldLog) console.log(...args);
};
if (!link.trim()) {
log(`[AttachmentProcessor] resolveFile: empty link`);
return null;
}
// TODO: Support `obsidian://` and `app://` URL destinations.
if (this.isTodoUrl(link)) {
return null;
}
/*
// Handle obsidian:// URLs
if (link.startsWith("obsidian://")) {
const filePath = this.parseObsidianUrl(link);
log(`[AttachmentProcessor] resolveFile: obsidian:// URL -> extracted path: "${filePath}"`);
if (!filePath) return null;
const file = app.vault.getAbstractFileByPath(normalizePath(filePath));
if (file instanceof TFile) {
log(`[AttachmentProcessor] resolveFile: ✓ Resolved obsidian:// to: ${file.path}`);
return file;
}
log(`[AttachmentProcessor] resolveFile: ✗ Failed to resolve obsidian:// path: ${filePath}`);
return null;
}
// Handle app://local/ URLs (Obsidian internal)
if (link.startsWith("app://")) {
const filePath = this.parseAppUrl(link);
log(`[AttachmentProcessor] resolveFile: app:// URL -> extracted path: "${filePath}"`);
if (!filePath) return null;
const vaultCandidate = filePath.startsWith("/") ? filePath.slice(1) : filePath;
let file = app.vault.getAbstractFileByPath(normalizePath(vaultCandidate));
if (file instanceof TFile) {
log(`[AttachmentProcessor] resolveFile: ✓ Resolved app:// to: ${file.path}`);
return file;
}
const mapped = this.mapAbsolutePathToVault(app, filePath);
if (mapped) {
file = app.vault.getAbstractFileByPath(normalizePath(mapped));
if (file instanceof TFile) {
log(`[AttachmentProcessor] resolveFile: ✓ Resolved app:// absolute path to: ${file.path}`);
return file;
}
}
log(`[AttachmentProcessor] resolveFile: ✗ Failed to resolve app:// path: ${filePath}`);
return null;
}
*/
// Try metadata cache first
const cached = app.metadataCache.getFirstLinkpathDest(link, sourceFile.path);
if (cached instanceof TFile) {
log(`[AttachmentProcessor] resolveFile: ✓ Resolved via metadata cache: ${link} -> ${cached.path}`);
return cached;
}
// Try absolute path
const byPath = app.vault.getAbstractFileByPath(normalizePath(link));
if (byPath instanceof TFile) {
log(`[AttachmentProcessor] resolveFile: ✓ Resolved via absolute path: ${link} -> ${byPath.path}`);
return byPath;
}
// Try relative to source file
const sourceDir = sourceFile.path.includes("/")
? sourceFile.path.slice(0, sourceFile.path.lastIndexOf("/"))
: "";
const relPath = normalizePath(sourceDir ? `${sourceDir}/${link}` : link);
const byRel = app.vault.getAbstractFileByPath(relPath);
if (byRel instanceof TFile) {
log(`[AttachmentProcessor] resolveFile: ✓ Resolved via relative path: ${link} -> ${byRel.path}`);
return byRel;
}
log(`[AttachmentProcessor] resolveFile: ✗ Failed to resolve: ${link} (tried cache, absolute, relative)`);
return null;
}
/*
// TODO: Support `obsidian://` URL destinations.
private parseObsidianUrl(url: string): string | null {
try {
const urlObj = new URL(url);
// obsidian://open?vault=VaultName&file=path/to/file.png
const filePath = urlObj.searchParams.get("file");
if (filePath) {
return decodeURIComponent(filePath);
}
return null;
} catch {
return null;
}
}
// TODO: Support `app://` URL destinations.
private parseAppUrl(url: string): string | null {
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
if (!pathname || pathname === "/") return null;
return decodeURIComponent(pathname);
} catch {
return null;
}
}
// TODO: Support mapping absolute paths to vault paths for `app://local/...`.
private mapAbsolutePathToVault(app: App, absolutePath: string): string | null {
const adapter: any = app.vault.adapter;
if (typeof adapter?.getBasePath !== "function") {
return null;
}
let normalizedAbsolute = absolutePath.replace(/\\/g, "/");
if (/^\/[A-Za-z]:\//.test(normalizedAbsolute)) {
normalizedAbsolute = normalizedAbsolute.slice(1);
}
let basePath = String(adapter.getBasePath()).replace(/\\/g, "/");
if (basePath.endsWith("/")) {
basePath = basePath.slice(0, -1);
}
if (/^\/[A-Za-z]:\//.test(basePath)) {
basePath = basePath.slice(1);
}
const windowsStyle = /^[A-Za-z]:\//.test(basePath);
const compareAbsolute = windowsStyle ? normalizedAbsolute.toLowerCase() : normalizedAbsolute;
const compareBase = windowsStyle ? basePath.toLowerCase() : basePath;
if (!compareAbsolute.startsWith(compareBase)) {
return null;
}
let relative = normalizedAbsolute.slice(basePath.length);
if (relative.startsWith("/")) {
relative = relative.slice(1);
}
return relative || null;
}
*/
private isSupported(file: TFile): boolean {
const ext = file.extension?.toLowerCase() ?? "";
return SUPPORTED_EXTENSIONS.has(ext);
}
private isImage(file: TFile): boolean {
const ext = file.extension?.toLowerCase() ?? "";
return IMAGE_EXTENSIONS.has(ext);
}
private buildSentinelUrl(uploadId: string, file: TFile, _altText?: string): string {
const ext = file.extension?.toLowerCase() ?? "";
const suffix = ext ? `.${ext}` : "";
return `https://notion-file-upload.local/${uploadId}${suffix}`;
}
}
export function applyBlockRewrites(
blocks: any[],
rewrites: Pick<AttachmentPrepareResult, "imageUrlToUploadId" | "filePlaceholderToUpload">
): void {
transformBlocksInPlace(blocks, rewrites);
}
function transformBlocksInPlace(
blocks: any[],
rewrites: Pick<AttachmentPrepareResult, "imageUrlToUploadId" | "filePlaceholderToUpload">
): void {
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
// Transform image blocks with sentinel URLs
if (block?.type === "image" && block?.image?.type === "external") {
const url = block.image?.external?.url;
if (url && rewrites.imageUrlToUploadId[url]) {
const caption = block.image?.caption;
block.image = {
type: "file_upload",
file_upload: { id: rewrites.imageUrlToUploadId[url] },
...(caption ? { caption } : {}),
};
}
}
// Transform paragraph placeholders to file blocks
if (block?.type === "paragraph") {
const token = extractParagraphText(block);
if (token && rewrites.filePlaceholderToUpload[token]) {
const { id, name } = rewrites.filePlaceholderToUpload[token];
blocks[i] = buildFileBlock(id, name);
}
}
// Recurse into children
const inner = block?.[block?.type];
if (inner?.children && Array.isArray(inner.children)) {
transformBlocksInPlace(inner.children, rewrites);
}
}
}
function extractParagraphText(block: any): string | undefined {
const richText = block?.paragraph?.rich_text;
if (!Array.isArray(richText) || richText.length === 0) return undefined;
return richText
.map((item: any) => item?.plain_text ?? item?.text?.content ?? "")
.join("")
.trim() || undefined;
}
function buildFileBlock(uploadId: string, name: string): any {
return {
object: "block",
type: "file",
file: {
type: "file_upload",
file_upload: { id: uploadId },
},
};
}

View File

@@ -0,0 +1,256 @@
import { TFile, requestUrl } from "obsidian";
import type MyPlugin from "src/main";
import type { DatabaseDetails } from "../../ui/settingTabs";
const NOTION_API_VERSION = "2025-09-03";
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
interface FileUploadSession {
id: string;
status: string;
upload_url?: string;
}
interface UploadResult {
id: string;
filename: string;
}
export class AttachmentUploader {
private plugin: MyPlugin;
private dbDetails: DatabaseDetails;
private textEncoder = new TextEncoder();
constructor(plugin: MyPlugin, dbDetails: DatabaseDetails) {
this.plugin = plugin;
this.dbDetails = dbDetails;
}
async uploadFile(file: TFile): Promise<UploadResult> {
const { notionAPI } = this.dbDetails;
const fileSizeBytes = file.stat?.size ?? 0;
const contentType = this.getContentType(file.extension);
if (fileSizeBytes > MAX_UPLOAD_BYTES) {
throw new Error(
`File too large for Notion upload (max 5MB): ${file.path} (${(fileSizeBytes / 1024 / 1024).toFixed(2)} MB)`,
);
}
const mode = "single_part";
console.log(`[AttachmentUploader] uploadFile: ${file.name}`, {
path: file.path,
size: `${(fileSizeBytes / 1024).toFixed(2)} KB`,
contentType,
mode,
});
const session = await this.createUploadSession({
mode,
notionAPI,
});
console.log(`[AttachmentUploader] Upload session created:`, {
sessionId: session.id,
status: session.status,
});
const binary = await this.plugin.app.vault.readBinary(file);
console.log(`[AttachmentUploader] Read binary data: ${binary.byteLength} bytes`);
if (binary.byteLength > MAX_UPLOAD_BYTES) {
throw new Error(
`File too large for Notion upload (max 5MB): ${file.path} (${(binary.byteLength / 1024 / 1024).toFixed(2)} MB)`,
);
}
const uploadUrl =
session.upload_url ??
`https://api.notion.com/v1/file_uploads/${encodeURIComponent(session.id)}/send`;
await this.sendFileData(session.id, uploadUrl, binary, notionAPI, file.name, contentType);
console.log(`[AttachmentUploader] Upload complete: ${file.name} -> ${session.id}`);
return { id: session.id, filename: file.name };
}
private async createUploadSession(params: {
mode: string;
notionAPI: string;
}): Promise<FileUploadSession> {
console.log(`[AttachmentUploader] Creating upload session:`, {
mode: params.mode,
});
const response = await this.requestWithRetry({
url: "https://api.notion.com/v1/file_uploads",
method: "POST",
headers: {
accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${params.notionAPI}`,
"Notion-Version": NOTION_API_VERSION,
},
body: JSON.stringify({
mode: params.mode,
}),
throw: false,
});
const data = response.json;
if (response.status < 200 || response.status >= 300) {
console.error(`[AttachmentUploader] Failed to create upload session:`, {
status: response.status,
message: data?.message,
response: data,
});
throw new Error(`Failed to create upload session: ${data?.message ?? response.status}`);
}
const id = data?.id ?? data?.file_upload?.id;
if (!id) {
throw new Error("Upload session response missing id");
}
return { id, status: data.status, upload_url: data.upload_url };
}
private async sendFileData(
fileUploadId: string,
uploadUrl: string,
binary: ArrayBuffer,
notionAPI: string,
filename: string,
contentType: string,
): Promise<void> {
console.log(`[AttachmentUploader] Sending file data for session: ${fileUploadId} (${binary.byteLength} bytes)`);
const { body, boundary } = this.buildMultipartBody({
fieldName: "file",
filename,
contentType: contentType || "application/octet-stream",
binary,
});
const response = await this.requestWithRetry({
url: uploadUrl,
method: "POST",
headers: {
accept: "application/json",
"Content-Type": `multipart/form-data; boundary=${boundary}`,
Authorization: `Bearer ${notionAPI}`,
"Notion-Version": NOTION_API_VERSION,
},
body,
throw: false,
});
const data = response.json;
if (response.status < 200 || response.status >= 300) {
console.error(`[AttachmentUploader] Failed to send file data:`, {
sessionId: fileUploadId,
status: response.status,
message: data?.message,
response: data,
});
throw new Error(`Failed to send file data: ${data?.message ?? response.status}`);
}
console.log(`[AttachmentUploader] File data sent successfully for session: ${fileUploadId}`);
}
private buildMultipartBody(params: {
fieldName: string;
filename: string;
contentType: string;
binary: ArrayBuffer;
}): { body: ArrayBuffer; boundary: string } {
const boundary = `----NotionFormBoundary${Math.random().toString(16).slice(2)}${Math.random().toString(16).slice(2)}`;
const safeFilename = params.filename.replace(/"/g, '\\"');
const prefix = [
`--${boundary}\r\n`,
`Content-Disposition: form-data; name="${params.fieldName}"; filename="${safeFilename}"\r\n`,
`Content-Type: ${params.contentType}\r\n`,
`\r\n`,
].join("");
const suffix = `\r\n--${boundary}--\r\n`;
const prefixBytes = this.textEncoder.encode(prefix);
const fileBytes = new Uint8Array(params.binary);
const suffixBytes = this.textEncoder.encode(suffix);
const out = new Uint8Array(prefixBytes.length + fileBytes.length + suffixBytes.length);
out.set(prefixBytes, 0);
out.set(fileBytes, prefixBytes.length);
out.set(suffixBytes, prefixBytes.length + fileBytes.length);
return { body: out.buffer, boundary };
}
private async requestWithRetry(params: any, maxAttempts = 4): Promise<any> {
let attempt = 0;
let lastError: unknown;
while (attempt < maxAttempts) {
attempt++;
try {
const response = await requestUrl(params);
if (this.shouldRetry(response.status) && attempt < maxAttempts) {
const delayMs = this.getRetryDelay(response, attempt);
console.warn(`[AttachmentUploader] Retryable status ${response.status}, attempt ${attempt}/${maxAttempts}, retrying in ${delayMs}ms`);
await this.sleep(delayMs);
continue;
}
return response;
} catch (error: unknown) {
lastError = error;
console.error(`[AttachmentUploader] Request failed, attempt ${attempt}/${maxAttempts}:`, error);
if (attempt >= maxAttempts) break;
const delayMs = this.getRetryDelay(undefined, attempt);
console.warn(`[AttachmentUploader] Retrying in ${delayMs}ms`);
await this.sleep(delayMs);
}
}
console.error(`[AttachmentUploader] Request failed after ${maxAttempts} attempts`);
throw lastError ?? new Error("Request failed after retries");
}
private shouldRetry(status: number): boolean {
return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
}
private getRetryDelay(response: any, attempt: number): number {
const retryAfter = response?.headers?.["retry-after"] ?? response?.headers?.["Retry-After"];
if (retryAfter) {
const seconds = parseInt(retryAfter, 10);
if (!isNaN(seconds)) return seconds * 1000;
}
const base = 500;
const max = 8000;
const expo = Math.min(max, base * Math.pow(2, attempt - 1));
const jitter = Math.floor(Math.random() * 250);
return expo + jitter;
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
private getContentType(extension: string): string {
const ext = extension.toLowerCase();
const mimeTypes: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
heic: "image/heic",
tif: "image/tiff",
tiff: "image/tiff",
bmp: "image/bmp",
pdf: "application/pdf",
};
return mimeTypes[ext] ?? "application/octet-stream";
}
}

View File

@@ -3,6 +3,8 @@ import MyPlugin from "src/main";
import { DatabaseDetails } from "../../ui/settingTabs";
import { i18nConfig } from "../../lang/I18n";
const NOTION_API_VERSION = "2025-09-03";
export interface NotionPageResponse {
response: any;
data: any;
@@ -16,12 +18,20 @@ interface PreparedBlocks {
export abstract class UploadBase {
protected plugin: MyPlugin;
protected dbDetails: DatabaseDetails;
protected isAutoSync = false;
protected constructor(plugin: MyPlugin, dbDetails: DatabaseDetails) {
this.plugin = plugin;
this.dbDetails = dbDetails;
}
private shouldShowSuccessNotices(): boolean {
if (!this.isAutoSync) {
return true;
}
return !!this.plugin.settings.autoSyncSuccessNotice;
}
async deletePage(notionID: string) {
const {notionAPI} = this.dbDetails;
return requestUrl({
@@ -30,7 +40,7 @@ export abstract class UploadBase {
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + notionAPI,
"Notion-Version": "2022-06-28",
"Notion-Version": NOTION_API_VERSION,
},
body: "",
throw: false,
@@ -116,7 +126,7 @@ export abstract class UploadBase {
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + notionAPI,
"Notion-Version": "2022-06-28",
"Notion-Version": NOTION_API_VERSION,
},
body: JSON.stringify(body),
throw: false,
@@ -171,7 +181,7 @@ export abstract class UploadBase {
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + notionAPI,
"Notion-Version": "2022-06-28",
"Notion-Version": NOTION_API_VERSION,
},
body: JSON.stringify(extraBlocks),
throw: false,
@@ -188,7 +198,9 @@ export abstract class UploadBase {
console.log(`${i18nConfig["ExtraBlockUploaded"]} to page: ${pageId}`);
if (i === extraChunks.length - 1) {
console.log(`${i18nConfig["BlockUploaded"]} to page: ${pageId}`);
new Notice(`${i18nConfig["BlockUploaded"]} page: ${pageId}`, 5000);
if (this.shouldShowSuccessNotices()) {
new Notice(`${i18nConfig["BlockUploaded"]} page: ${pageId}`, 5000);
}
}
}
}
@@ -201,7 +213,7 @@ export abstract class UploadBase {
method: "GET",
headers: {
Authorization: "Bearer " + notionAPI,
"Notion-Version": "2022-06-28",
"Notion-Version": NOTION_API_VERSION,
},
throw: false,
}).catch((error) =>

View File

@@ -10,6 +10,10 @@ import {getNowFileMarkdownContentCustom} from "./common/getMarkdownCustom";
const SYNC_ERROR_NOTICE_DURATION = 8000;
interface UploadCommandOptions {
isAutoSync?: boolean;
}
function extractErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message;
@@ -17,6 +21,16 @@ function extractErrorMessage(error: unknown): string {
return String(error);
}
function shouldShowAutoSyncSuccessNotice(
plugin: ObsidianSyncNotionPlugin,
options?: UploadCommandOptions,
): boolean {
if (!options?.isAutoSync) {
return true;
}
return !!plugin.settings.autoSyncSuccessNotice;
}
function notifySyncError(prefix: string, basename: string, error: unknown): void {
const errorMessage = extractErrorMessage(error);
console.error(`${prefix} Sync failed`, error);
@@ -39,6 +53,7 @@ export async function uploadCommandNext(
settings: PluginSettings,
dbDetails: DatabaseDetails,
app: App,
options?: UploadCommandOptions,
) {
const {notionAPI, databaseID} = dbDetails;
@@ -91,6 +106,7 @@ export async function uploadCommandNext(
try {
res = await upload.sync({
dataset: "next",
isAutoSync: options?.isAutoSync,
title: basename,
emoji: emoji || "",
cover: cover || "",
@@ -118,7 +134,9 @@ export async function uploadCommandNext(
const {response} = res;
if (response.status === 200) {
new Notice(`${i18nConfig["sync-preffix"]} ${basename} ${i18nConfig["sync-success"]}`).noticeEl.style.color = "green";
if (shouldShowAutoSyncSuccessNotice(plugin, options)) {
new Notice(`${i18nConfig["sync-preffix"]} ${basename} ${i18nConfig["sync-success"]}`).noticeEl.style.color = "green";
}
logCommandDebug("uploadCommandNext", "Sync succeeded", {
filename: basename,
@@ -146,6 +164,7 @@ export async function uploadCommandGeneral(
settings: PluginSettings,
dbDetails: DatabaseDetails,
app: App,
options?: UploadCommandOptions,
) {
const {notionAPI, databaseID} = dbDetails;
@@ -160,7 +179,9 @@ export async function uploadCommandGeneral(
const {markDownData, nowFile, cover, tags} = await getNowFileMarkdownContentGeneral(app, settings)
new Notice(i18nConfig.StartUpload.replace('{filename}', nowFile.basename));
if (!options?.isAutoSync) {
new Notice(i18nConfig.StartUpload.replace('{filename}', nowFile.basename));
}
console.log(`Start upload ${nowFile.basename}`);
if (markDownData) {
@@ -177,6 +198,7 @@ export async function uploadCommandGeneral(
try {
res = await upload.sync({
dataset: "general",
isAutoSync: options?.isAutoSync,
title: basename,
cover: cover || "",
tags: tags || [],
@@ -195,7 +217,9 @@ export async function uploadCommandGeneral(
const {response} = res;
if (response.status === 200) {
new Notice(`${i18nConfig["sync-preffix"]} ${basename} ${i18nConfig["sync-success"]}`).noticeEl.style.color = "green";
if (shouldShowAutoSyncSuccessNotice(plugin, options)) {
new Notice(`${i18nConfig["sync-preffix"]} ${basename} ${i18nConfig["sync-success"]}`).noticeEl.style.color = "green";
}
logCommandDebug("uploadCommandGeneral", "Sync succeeded", {
filename: basename,
@@ -223,6 +247,7 @@ export async function uploadCommandCustom(
settings: PluginSettings,
dbDetails: DatabaseDetails,
app: App,
options?: UploadCommandOptions,
) {
const {notionAPI, databaseID} = dbDetails;
@@ -237,7 +262,9 @@ export async function uploadCommandCustom(
const {markDownData, nowFile, cover, customValues} = await getNowFileMarkdownContentCustom(app, dbDetails)
new Notice(i18nConfig.StartUpload.replace('{filename}', nowFile.basename));
if (!options?.isAutoSync) {
new Notice(i18nConfig.StartUpload.replace('{filename}', nowFile.basename));
}
console.log(`Start upload ${nowFile.basename}`);
if (markDownData) {
@@ -254,6 +281,7 @@ export async function uploadCommandCustom(
try {
res = await upload.sync({
dataset: "custom",
isAutoSync: options?.isAutoSync,
cover: cover || "",
customValues,
markdown: markDownData,
@@ -272,7 +300,9 @@ export async function uploadCommandCustom(
const {response} = res;
if (response.status === 200) {
new Notice(`${i18nConfig["sync-preffix"]} ${basename} ${i18nConfig["sync-success"]}`).noticeEl.style.color = "green";
if (shouldShowAutoSyncSuccessNotice(plugin, options)) {
new Notice(`${i18nConfig["sync-preffix"]} ${basename} ${i18nConfig["sync-success"]}`).noticeEl.style.color = "green";
}
logCommandDebug("uploadCommandCustom", "Sync succeeded", {
filename: basename,

View File

@@ -12,3 +12,40 @@ writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
versions[targetVersion] = minAppVersion;
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));
// tag the changelog by converting "Unreleased" to the new version section
function bumpChangelog(version) {
const changelogPath = "CHANGELOG.md";
const content = readFileSync(changelogPath, "utf8");
const unreleasedHeader = "## Unreleased";
if (!content.includes(unreleasedHeader)) {
console.warn(`[version-bump] ${unreleasedHeader} not found in ${changelogPath}, skipping changelog tagging`);
return;
}
const today = new Date().toISOString().slice(0, 10);
const releaseHeader = `## ${version} (${today})`;
const updated = content.replace(unreleasedHeader, releaseHeader);
const marker = "# Changelog\n\n";
if (!updated.startsWith(marker)) {
writeFileSync(changelogPath, updated);
return;
}
const newUnreleased = [
"## Unreleased",
"",
"### Added",
"",
"### Changed",
"",
"### Fixed",
"",
].join("\n");
writeFileSync(changelogPath, marker + newUnreleased + updated.slice(marker.length));
}
bumpChangelog(targetVersion);

View File

@@ -1,4 +1,9 @@
{
"1.0.0": "0.9.7",
"1.0.1": "0.12.0"
}
"1.0.1": "0.12.0",
"2.8.0": "0.0.1",
"2.8.1": "0.0.1",
"2.8.2": "0.0.1",
"2.8.3": "0.0.1",
"2.8.4": "0.0.1"
}