Compare commits

...

24 Commits

Author SHA1 Message Date
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
Jiaxin Peng
90dcc1aef0 chore: release v2.8.0-beta.3 2025-12-10 23:15:18 +00:00
Jiaxin Peng
7053a74e24 feat: Add 'sync-preffix' translation key to multiple locales. 2025-12-10 23:05:07 +00:00
Jiaxin Peng
5144f10e77 feat: Add auto-copy Notion link setting 2025-12-10 23:04:58 +00:00
Jiaxin Peng
5806a2831b fix: remove unnecessary resetAutoSyncNoticeCache call when autoSyncFrontmatterKey changes 2025-12-10 22:51:08 +00:00
Jiaxin Peng
eeaf7c036d feat: Enable first-time auto-upload for new documents with autosync-database key and refine auto-sync notice logic. 2025-12-10 21:51:04 +00:00
Jiaxin Peng
3de92d3f54 feat: Enable first-time auto-upload for new documents and update documentation. 2025-12-10 21:32:25 +00:00
Jiaxin Peng
e6b13e5eee feat: auto copy after upload 2025-12-10 21:32:06 +00:00
Jiaxin Peng
f4def623bb styles: improve the translation 2025-12-10 21:30:49 +00:00
Jiaxin Peng
3620505b56 update beta version 2.8.0-beta.2 2025-11-06 16:51:42 +00:00
Jiaxin Peng
e9355aaf92 docs: enhance auto sync documentation with frontmatter key configuration and examples 2025-11-06 16:50:50 +00:00
Jiaxin Peng
943ec6af6d Merge pull request #68 from jxpeng98/autosync-filter
Autosync filter
2025-11-06 16:44:58 +00:00
Jiaxin Peng
bb4b75c82e feat: add auto sync frontmatter key configuration and update related messages 2025-11-06 16:43:24 +00:00
Jiaxin Peng
ae4488546c feat: add auto sync database list handling and notifications for missing entries 2025-11-06 16:27:24 +00:00
Jiaxin Peng
7661bc94c7 fix: update changelog to reflect version v2.8.0-beta.1 release 2025-11-01 00:44:35 +00:00
Jiaxin Peng
e8a9594ea1 fix: refine tag patterns for release triggers in workflow 2025-11-01 00:42:01 +00:00
24 changed files with 1823 additions and 288 deletions

View File

@@ -2,8 +2,17 @@ name: Release
on: on:
push: push:
branches:
- main
tags: tags:
- "*" # Only trigger for release tags (e.g., v1.0.0, v2.3.4)
# Excludes prerelease tags (e.g., v2.8.0-beta.3, v1.0.0-rc.1)
- "v[0-9]+.[0-9]+.[0-9]"
- "v[0-9]+.[0-9]+.[0-9][0-9]"
- "v[0-9]+.[0-9]+.[0-9][0-9][0-9]"
- "v[0-9]+.[0-9][0-9].[0-9]"
- "v[0-9]+.[0-9][0-9].[0-9][0-9]"
- "v[0-9]+.[0-9][0-9].[0-9][0-9][0-9]"
env: env:
PLUGIN_NAME: share-to-notionnext # Change this to match the id of your plugin. PLUGIN_NAME: share-to-notionnext # Change this to match the id of your plugin.

3
.gitignore vendored
View File

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

View File

@@ -1,6 +1,85 @@
# Changelog # Changelog
## [Unreleased] ## 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.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
- **Auto-copy Notion Link setting**: New toggle to automatically copy the Notion page link to clipboard after syncing (defaults to on)
- **Smart auto-sync notice**: Show notice only for files that were previously synced but missing `autosync-database` field; new files are silently skipped
### Fixed
- Fixed `undefined` appearing in sync success notification by adding missing `sync-preffix` i18n key
- Fixed build error caused by removed `resetAutoSyncNoticeCache()` method reference
- Added `autoCopyNotionLink` to settings migration logic for seamless upgrades
### Changed
- Improved auto-sync behavior: files without `autosync-database` are now silently ignored unless they have an existing NotionID
- Updated documentation with new auto-sync scenarios (A-1 and A-2)
---
## v2.8.0-beta.2 (2025-11-05)
### Featured
- Added setting to customise the frontmatter key used for auto sync database lists (defaults to `autosync-database`)
## v2.8.0-beta.1 (2025-10-31)
### Added ### Added
@@ -12,6 +91,7 @@
- Works on both desktop and mobile platforms - Works on both desktop and mobile platforms
- Added comprehensive i18n support for all UI elements and notifications - Added comprehensive i18n support for all UI elements and notifications
- Added prerelease workflow for beta testing via GitHub Actions and BRAT - Added prerelease workflow for beta testing via GitHub Actions and BRAT
- Added setting to customise the frontmatter key used for auto sync database lists (defaults to `autosync-database`)
### Changed ### Changed

View File

@@ -14,6 +14,7 @@
- **多种数据库类型**支持通用、NotionNext 和自定义数据库。 - **多种数据库类型**支持通用、NotionNext 和自定义数据库。
- **自定义属性**:在自定义数据库中,可将任何 frontmatter 键映射到任何 Notion 属性。 - **自定义属性**:在自定义数据库中,可将任何 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. - **Multiple Database Types**: Supports General, NotionNext, and Custom databases.
- **Custom Properties**: Map any frontmatter key to any Notion property in 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. - **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 ## Acknowledgment

View File

@@ -22,6 +22,21 @@ In the plugin settings, you can add and configure the Notion databases you want
- [3⃣ Custom Database](#3⃣-custom-database) - [3⃣ Custom Database](#3⃣-custom-database)
- [Finalizing Configuration](#finalizing-configuration) - [Finalizing Configuration](#finalizing-configuration)
## Auto Sync Frontmatter Entry
If you enable auto sync, the plugin needs a frontmatter entry that lists which configured databases should receive updates. You can customise the name of this entry in **Settings → Auto Sync Frontmatter Key** (default: `autosync-database`). Use any text you like—letters, numbers, emojis, or other scripts are all supported.
In your note's frontmatter, add the configured key and list the database abbreviations you created in the settings:
```yaml
---
title: My Article
autosync-database: [blog, ideas]
---
```
The entry can be a YAML list or comma-separated string, and manual uploads will automatically add the current database abbreviation if it is missing. If you change the key name in settings, update your frontmatter to match the new value.
## 1⃣ General Database ## 1⃣ General Database
This is the most basic database type and is suitable for most users. This is the most basic database type and is suitable for most users.
@@ -109,4 +124,4 @@ Relation and Rollup types are not supported yet.
## Finalizing Configuration ## Finalizing Configuration
After configuring your database, make sure to save your settings. You can now start syncing your Obsidian notes to the configured Notion database by using the "Share to NotionNext" command from the command palette or the note context menu. After configuring your database, make sure to save your settings. You can now start syncing your Obsidian notes to the configured Notion database by using the "Share to NotionNext" command from the command palette or the note context menu.

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. 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 ## Auto Sync
The plugin supports automatic syncing that monitors your notes for changes and automatically syncs them to Notion. The plugin supports automatic syncing that monitors your notes for changes and automatically syncs them to Notion.
@@ -22,30 +89,54 @@ The plugin supports automatic syncing that monitors your notes for changes and a
3. Enable the toggle 3. Enable the toggle
4. Configure the "Auto Sync Delay" (default: 5 seconds, minimum: 2 seconds) 4. Configure the "Auto Sync Delay" (default: 5 seconds, minimum: 2 seconds)
### Prepare the Frontmatter
Auto sync reads the database list from the frontmatter key you configured in **Settings → Auto Sync Frontmatter Key** (default: `autosync-database`). To make sure your notes can sync automatically:
- Add the configured key to your note's frontmatter
- List one or more database abbreviations that you defined in the plugin settings
- Keep the list updated if you change the databases a note should sync to
Example with the default key:
```yaml
---
title: My Article
autosync-database: [blog, portfolio]
---
```
If you change the key name in the settings, update your frontmatter to match.
### How Auto Sync Works ### How Auto Sync Works
When auto sync is enabled: When auto sync is enabled:
- The plugin monitors markdown files for changes - 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 - After you stop editing for the configured delay period, auto sync is triggered
- Only files that have already been synced to Notion (have a NotionID in frontmatter) will be auto-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 - 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 ### Auto Sync Scenarios
#### Scenario A: New Document (Not Yet Synced) #### Scenario A: New Document (First-Time Auto Upload)
```yaml ```yaml
--- ---
title: My New Article title: My New Article
tags: [blog, tech] autosync-database: [blog]
--- ---
``` ```
**Behavior:** **Behavior:**
- ✅ Detects no NotionID present - ✅ Detects no NotionID present but `autosync-database` is configured
- ✅ Shows notice: "⚠️ Auto sync skipped: This document has not been synced to Notion, please upload manually first" - ✅ Automatically performs first-time upload to the Blog database
- ✅ No sync operation performed - ✅ Adds `NotionID-blog: xxx` to the frontmatter after successful upload
- 📝 **Action Required:** Manually sync the document first using the command palette - ✅ Shows success/failure notification
- 📝 **No Action Required:** The plugin handles the initial upload automatically
#### Scenario B: Synced to One Database #### Scenario B: Synced to One Database
@@ -53,6 +144,7 @@ tags: [blog, tech]
--- ---
title: My Article title: My Article
NotionID-blog: abc123 NotionID-blog: abc123
autosync-database: [blog]
--- ---
``` ```
@@ -70,19 +162,35 @@ title: My Article
NotionID-blog: abc123 NotionID-blog: abc123
NotionID-portfolio: def456 NotionID-portfolio: def456
NotionID-notes: ghi789 NotionID-notes: ghi789
autosync-database: [blog, portfolio, notes]
--- ---
``` ```
**Behavior:** **Behavior:**
- ✅ Detects 3 NotionIDs - ✅ Detects 3 database targets
- ✅ Shows notice: "🔄 Auto sync: Syncing to 3 database(s)..." - ✅ Shows notice: "🔄 Auto sync: Syncing to 3 database(s)..."
- ✅ Syncs to all 3 databases sequentially - ✅ Syncs to all 3 databases sequentially
- ✅ Shows individual result notifications for each database - ✅ Shows individual result notifications for each database
- 📝 **No Action Required:** Changes are automatically synced to all linked databases - 📝 **No Action Required:** Changes are automatically synced to all linked databases
#### Scenario D: Custom Frontmatter Key
```yaml
---
title: My Article
NotionID-blog: abc123
NotionID-portfolio: def456
🚀-sync-targets: [blog, portfolio]
---
```
**Behavior:**
- ✅ Uses your custom key (for example `🚀-sync-targets`) configured in settings
- ✅ Syncs to the listed databases when NotionIDs are present
- 📝 **Remember:** Update both the setting and your frontmatter if you rename the key
### Auto Sync Best Practices ### Auto Sync Best Practices
1. **First Sync Manually**: Always perform the first sync manually to establish the NotionID link 1. **Add Frontmatter Key**: Just add `autosync-database: [your-db]` to enable auto sync - no manual upload needed
2. **Configure Delay Appropriately**: Set a longer delay (5-10 seconds) if you make frequent edits 2. **Configure Delay Appropriately**: Set a longer delay (5-10 seconds) if you make frequent edits
3. **Monitor Sync Status**: Check the notifications to ensure syncs complete successfully 3. **Monitor Sync Status**: Check the notifications to ensure syncs complete successfully
4. **Check Logs**: Open the developer console (Ctrl+Shift+I / Cmd+Option+I) to view detailed sync logs 4. **Check Logs**: Open the developer console (Ctrl+Shift+I / Cmd+Option+I) to view detailed sync logs

View File

@@ -6,3 +6,64 @@ description: Release notes and updates for Obsidian to NotionNext
# Changelog # Changelog
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. 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
- Added setting to customise the frontmatter key used for auto sync database lists (defaults to `autosync-database`)
## v2.8.0-beta.1 (2025-10-31)
### Added
- **Auto Sync Feature**: Automatically sync notes to Notion when content or frontmatter changes
- Configurable delay (default: 5 seconds, minimum: 2 seconds)
- Support for multiple database syncing
- Smart detection to avoid sync loops when only NotionID is updated
- Content hash comparison to detect body text changes
- Works on both desktop and mobile platforms
- Added comprehensive i18n support for all UI elements and notifications
- Added prerelease workflow for beta testing via GitHub Actions and BRAT
### Changed
- Enhanced settings tab with auto-sync configuration options
- Improved debug logging for better troubleshooting
- Updated documentation with auto-sync usage guide and troubleshooting section
### Fixed
- Fixed mobile compatibility issues by using `window.setTimeout` instead of `NodeJS.Timeout`
- Fixed sync loop prevention logic to properly handle frontmatter and content changes
- Fixed cache update timing to ensure accurate change detection

View File

@@ -11,6 +11,73 @@ description: 如何使用 NotionNext 插件将你的 Obsidian 笔记同步到 No
要同步一篇笔记,只需打开你想要同步的笔记,然后从命令面板(`Ctrl/Cmd + P`)或笔记的右键菜单中选择 "Share to NotionNext" 命令。这会在你的 Notion 数据库中创建一个新页面,内容与你的 Obsidian 笔记完全一致。 要同步一篇笔记,只需打开你想要同步的笔记,然后从命令面板(`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。 插件支持自动同步功能,可以监控你的笔记变化并自动同步到 Notion。
@@ -22,46 +89,71 @@ description: 如何使用 NotionNext 插件将你的 Obsidian 笔记同步到 No
3. 开启该开关 3. 开启该开关
4. 配置"自动同步延迟时间"默认5秒最小2秒 4. 配置"自动同步延迟时间"默认5秒最小2秒
### 准备 Frontmatter
自动同步会读取你在 **设置 → 自动同步 Frontmatter 键名** 中配置的键名(默认为 `autosync-database`)来确定要同步的数据库。要让你的笔记能够自动同步:
- 在笔记的 frontmatter 中添加配置的键名
- 列出一个或多个你在插件设置中定义的数据库简称
- 如果修改了笔记要同步的数据库,记得更新列表
示例(使用默认键名):
```yaml
---
title: 我的文章
autosync-database: [blog, portfolio]
---
```
如果你在设置中修改了键名,记得同时更新你的 frontmatter。
### 自动同步工作原理 ### 自动同步工作原理
当自动同步启用后: 当自动同步启用后:
- 插件会监控 Markdown 文件的变化 - 插件会监控 Markdown 文件的变化
- **只有 frontmatter 中包含自动同步配置键的文件才会被处理**
- 没有配置自动同步键的文件会被静默跳过,不会触发任何同步操作或提示
- 含有本地附件(图片/PDF的文件会跳过自动同步请手动同步
- 在你停止编辑达到配置的延迟时间后,自动触发同步 - 在你停止编辑达到配置的延迟时间后,自动触发同步
- 只有已经同步过的文件frontmatter 中有 NotionID才会被自动同步 - **支持首次自动上传**:无需先手动同步,只要添加 frontmatter 键名,插件会自动处理首次上传
- 如果文件关联了多个数据库,会自动同步到所有数据库 - 如果文件关联了多个数据库,会自动同步到所有数据库
- 首次同步后,会自动在 frontmatter 中添加 `NotionID-{数据库}` 用于后续更新
### 自动同步场景示例 ### 自动同步场景示例
#### 场景 A新文档未同步 #### 场景 A新文档首次自动上传
```yaml ```yaml
--- ---
title: 我的新文章 title: 我的新文章
tags: [博客, 技术] autosync-database: [blog]
--- ---
``` ```
**行为:** **行为:**
- ✅ 检测到没有 NotionID - ✅ 检测到没有 NotionID,但配置了 `autosync-database`
- ✅ 显示提示:"⚠️ 自动同步跳过:此文档未同步到 Notion请先手动上传" - ✅ 自动执行首次上传到 Blog 数据库
- ✅ 不执行同步操作 - ✅ 上传成功后自动添加 `NotionID-blog: xxx` 到 frontmatter
- 📝 **需要操作:** 先使用命令面板手动同步文档 - ✅ 显示成功/失败通知
- 📝 **无需操作:** 插件会自动处理首次上传
#### 场景 B已同步到一个数据库 #### 场景 B已同步到一个数据库(更新)
```yaml ```yaml
--- ---
title: 我的文章 title: 我的文章
NotionID-blog: abc123 NotionID-blog: abc123
autosync-database: [blog]
--- ---
``` ```
**行为:** **行为:**
- ✅ 检测到 1 个 NotionID - ✅ 检测到 1 个 NotionID
- ✅ 自动同步到 Blog 数据库 - ✅ 自动同步更新到 Blog 数据库
- ✅ 显示上传命令返回的成功/失败通知 - ✅ 显示上传命令返回的成功/失败通知
- 📝 **无需操作:** 变更会自动同步 - 📝 **无需操作:** 变更会自动同步
@@ -73,12 +165,13 @@ title: 我的文章
NotionID-blog: abc123 NotionID-blog: abc123
NotionID-portfolio: def456 NotionID-portfolio: def456
NotionID-notes: ghi789 NotionID-notes: ghi789
autosync-database: [blog, portfolio, notes]
--- ---
``` ```
**行为:** **行为:**
- ✅ 检测到 3 个 NotionID - ✅ 检测到 3 个数据库目标
- ✅ 显示提示:"🔄 自动同步:正在同步到 3 个数据库..." - ✅ 显示提示:"🔄 自动同步:正在同步到 3 个数据库..."
- ✅ 依次同步到所有 3 个数据库 - ✅ 依次同步到所有 3 个数据库
- ✅ 为每个数据库显示独立的结果通知 - ✅ 为每个数据库显示独立的结果通知
@@ -86,7 +179,7 @@ NotionID-notes: ghi789
### 自动同步最佳实践 ### 自动同步最佳实践
1. **首次手动同步**:始终先手动执行第一次同步以建立 NotionID 链接 1. **添加 Frontmatter 配置**:只需添加 `autosync-database: [你的数据库]` 即可启用自动同步,无需手动上传
2. **合理配置延迟**如果你经常编辑设置较长的延迟时间5-10 秒) 2. **合理配置延迟**如果你经常编辑设置较长的延迟时间5-10 秒)
3. **监控同步状态**:注意查看通知以确保同步成功完成 3. **监控同步状态**:注意查看通知以确保同步成功完成
4. **查看日志**打开开发者控制台Ctrl+Shift+I / Cmd+Option+I查看详细的同步日志 4. **查看日志**打开开发者控制台Ctrl+Shift+I / Cmd+Option+I查看详细的同步日志

View File

@@ -4,3 +4,32 @@ description: Obsidian to NotionNext 的版本更新与变更记录
--- ---
欢迎来到 Obsidian to NotionNext 的更新日志!这里会记录自 `2.7.0` 版本起的所有更新、改进以及问题修复,方便你快速了解插件的演进情况。 欢迎来到 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

@@ -1,7 +1,7 @@
{ {
"id": "share-to-notionnext", "id": "share-to-notionnext",
"name": "Share to NotionNext", "name": "Share to NotionNext",
"version": "2.7.0", "version": "2.8.0",
"minAppVersion": "0.0.1", "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.", "description": "Shares obsidian md file to notion with notion api for NotionNext web deploy, originally created by EasyChris/obsidian-to-notion.",
"author": "EasyChris, jxpeng98", "author": "EasyChris, jxpeng98",

View File

@@ -1,6 +1,6 @@
{ {
"name": "share-to-notionnext", "name": "share-to-notionnext",
"version": "2.7.0", "version": "2.8.0",
"type": "module", "type": "module",
"description": "Share files to any Notion database using the Notion API, originally created by EasyChris/obsidian-to-notion.", "description": "Share files to any Notion database using the Notion API, originally created by EasyChris/obsidian-to-notion.",
"main": "main.js", "main": "main.js",

View File

@@ -1,96 +1,102 @@
export const en = { export const en = {
databaseFormat: "Database Format", databaseFormat: "Database Format",
databaseFormatDesc: "Select the database format you want to sync to NotionNext or General", databaseFormatDesc: "Select the database format to sync to: NotionNext or General.",
databaseNext: "NotionNext", databaseNext: "NotionNext",
databaseGeneral: "General", databaseGeneral: "General",
databaseCustom: "Custom", databaseCustom: "Custom",
databaseFullName: "Database Full Name", databaseFullName: "Database Full Name",
databaseFullNameDesc: "Please give a full name for your database", databaseFullNameDesc: "Set a full name for your database.",
databaseFullNameText: "Enter your database full name", databaseFullNameText: "Enter your database's full name",
databaseAbbreviateName: "Database Abbreviate Name", databaseAbbreviateName: "Abbreviated Name",
databaseAbbreviateNameDesc: "Please give a nick name for your database", databaseAbbreviateNameDesc: "Set a shorter, abbreviated name for your database.",
databaseAbbreviateNameText: "Enter your database nick name", databaseAbbreviateNameText: "Enter your database's abbreviated name",
ribbonIcon: "Share to NotionNext", ribbonIcon: "Sync to NotionNext",
GeneralSetting: "General information Settings", GeneralSetting: "General Settings",
CommandID: "share-to-notionnext", CommandID: "share-to-notionnext",
CommandName: "Share to NotionNext Database", CommandName: "Sync to NotionNext",
CommandIDGeneral: "share-to-notion", CommandIDGeneral: "share-to-notion",
CommandNameGeneral: "Share to Notion General Database", CommandNameGeneral: "Sync to General Database",
NotionNextButton: "NotionNext Sync", NotionNextButton: "NotionNext Sync",
NotionNextButtonDesc: "Open this option, Sync to NotionNext command will be displayed in the command palette (default: ON)", NotionNextButtonDesc: "Enables the 'Sync to NotionNext' command in the command palette (default: on).",
NotionNextSettingHeader: "NotionNext Database Settings", NotionNextSettingHeader: "NotionNext Database Settings",
NotionAPI: "Notion API Token", NotionAPI: "Notion API Token",
NotionAPIDesc: "Generate from https://www.notion.so/my-integrations", NotionAPIDesc: "Get yours from notion.so/my-integrations.",
NotionAPIText: "Enter your Notion API Token", NotionAPIText: "Enter your Notion API Token",
DatabaseID: "Database ID", DatabaseID: "Database ID",
DatabaseIDDesc: "Collect from the top-right Share --> Publish", DatabaseIDDesc: "Find this in your Notion page's top-right 'Share' menu.",
DatabaseIDText: "Enter your Database ID", DatabaseIDText: "Enter your Database ID",
BannerUrl: "Banner url (optional)", BannerUrl: "Banner URL (optional)",
BannerUrlDesc: BannerUrlDesc:
"Default is empty, if you want to show a banner, please enter the url (like: https://abc.com/b.png)", "Leave empty for no banner. If you want a banner, enter an image URL (e.g., https://abc.com/b.png).",
BannerUrlText: "Enter your banner url", BannerUrlText: "Enter your banner URL",
NotionUser: "Notion ID (username, optional)", NotionUser: "Notion Username (optional)",
NotionUserDesc: NotionUserDesc:
"Collect from share link likes:https://username.notion.site. Your notion id is [username]", "If your site is username.notion.site, your username is [username].",
NotionUserText: "Enter your notion ID", NotionUserText: "Enter your Notion username",
NotionLinkDisplay: "Notion Link Display", NotionLinkDisplay: "Display Notion Link",
NotionLinkDisplayDesc: "Default is ON, if you want to hide the link in the front matter, please turn it off", NotionLinkDisplayDesc: "If disabled, the Notion link won't be added to the front matter after syncing (default: on).",
AutoCopyNotionLink: "Auto-copy Notion Link",
AutoCopyNotionLinkDesc: "Automatically copy the Notion page link to the clipboard after syncing (default: on).",
AutoSync: "Auto Sync", AutoSync: "Auto Sync",
AutoSyncDesc: "Automatically sync to Notion when frontmatter or content is modified (requires existing NotionID)", AutoSyncDesc: "Automatically syncs changes to Notion when the file's frontmatter or content is modified. Supports creating and updating pages.",
AutoSyncFrontmatterKey: "Auto Sync Frontmatter Key",
AutoSyncFrontmatterKeyDesc: "Specify the frontmatter key used to list the databases this file should auto-sync to (defaults to 'autosync-database').",
AutoSyncDelay: "Auto Sync Delay (seconds)", AutoSyncDelay: "Auto Sync Delay (seconds)",
AutoSyncDelayDesc: "How many seconds to wait after document modification before triggering auto sync (default: 5 seconds, minimum: 2 seconds)", AutoSyncDelayDesc: "Delay in seconds to wait before syncing after a change. Prevents excessive syncs (default: 5s, min: 2s).",
AutoSyncDelayText: "Enter delay in seconds", AutoSyncDelayText: "Enter delay in seconds",
NotionGeneralSettingHeader: "General Notion Database Settings", NotionGeneralSettingHeader: "General Notion Database Settings",
NotionGeneralButton: "Notion General Sync", NotionGeneralButton: "General Database Sync",
NotionGeneralButtonDesc: "Open this option, Sync to Notion General Database command will be displayed in the command palette (default: ON)", NotionGeneralButtonDesc: "Enables the 'Sync to General Database' command in the command palette (default: on).",
NotionTagButton: "Notion Tags Sync", NotionTagButton: "Sync Tags",
NotionTagButtonDesc: "Sync Tags to Notion General Database (default: ON)", NotionTagButtonDesc: "Sync Obsidian tags to the Notion database (default: on).",
NotionCustomTitle: "Customise title property", NotionCustomTitle: "Custom Title Property",
NotionCustomTitleDesc: "Modify the column name of the Notion database (default: OFF)", NotionCustomTitleDesc: "Customize the title property's name in your Notion database (default: off).",
NotionCustomTitleName: "Preferred title name", NotionCustomTitleName: "Custom Title Property Name",
NotionCustomTitleNameDesc: "Enter the preferred title name for the first column of the Notion database (default: title)", NotionCustomTitleNameDesc: "Enter the custom name for the title property of your Notion database (default: 'title').",
NotionCustomTitleText: "Enter the name", NotionCustomTitleText: "Enter the property name",
NotionCustomValues: "Customise values property", NotionCustomValues: "Custom Properties",
NotionCustomValuesDesc: "Modify the column name of the Notion databaseone per line", NotionCustomValuesDesc: "Define custom properties to sync to your Notion database, one per line.",
NotionCustomValuesText: "Enter all properties that you want to sync", NotionCustomValuesText: "Enter all properties you want to sync",
NotYetFinish: "Not finished. This function will be available in the next version", NotYetFinish: "This feature will be available in a future version.",
PlaceHolder: "Enter database Name", PlaceHolder: "Enter database name",
"notion-logo": "Share to NotionNext", "notion-logo": "Sync to NotionNext",
"sync-preffix": "Sync to ", "sync-preffix": "📄",
"sync-success": "success", "sync-success": "Successfully synced to NotionNext:\n",
"sync-fail": "failed", "sync-fail": "Failed to sync to NotionNext:\n",
"open-notion": "Please open the file that needs to be synchronized", "open-notion": "Please open a file to sync first.",
"config-secrets-notion-api": "config-secrets-notion-api":
"Please set up the notion API in the settings tab.", "Please configure your Notion API key in the plugin settings.",
"config-secrets-database-id": "config-secrets-database-id":
"Please set up the database id in the settings tab.", "Please configure your Database ID in the plugin settings.",
"set-tags-fail": "set-tags-fail":
"Set tags fail,please check the frontmatter of the file or close the tag switch in the settings tab.", "Failed to set tags. Check the frontmatter or disable tag sync in settings.",
NNonMissing: NNonMissing:
"The 'NNon' property is missing in the settings. Please set it up.", "The 'NNon' property is not set. Please select a NotionNext database in settings.",
"set-api-id": "set-api-id":
"Please set up the notion API and database ID in the settings tab.", "Please configure your Notion API key and Database ID in the plugin settings.",
NotionCustomSettingHeader: "Notion Custom Database Settings", NotionCustomSettingHeader: "Notion Custom Database Settings",
NotionCustomButton: "Notion Customised command switch", NotionCustomButton: "Enable Custom Database Command",
NotionCustomButtonDesc: "Open this option, Sync to Notion Customised Database command will be displayed in the command palette", NotionCustomButtonDesc: "If enabled, the 'Sync to Custom Database' command appears in the command palette.",
CustomPropertyName: "Property Name", CustomPropertyName: "Property Name",
CustomPropertyFirstColumn: "Title Column", CustomPropertyFirstColumn: "Title Property Name",
CustomPropertyFirstColumnDesc: "The title of the page, must be the first property", CustomPropertyFirstColumnDesc: "The page title. This must be the first property in the list.",
CustomProperty: "Property", CustomProperty: "Property",
AddCustomProperty: "Add Custom Property", AddCustomProperty: "Add Custom Property",
AddNewProperty: "Add New Property", AddNewProperty: "Add New Property",
AddNewPropertyDesc: "Add new property match with your notion database", AddNewPropertyDesc: "Add a new property that matches a property in your Notion database.",
CopyErrorMessage: "Auto copy failed, please copy it manually", CopyErrorMessage: "Auto-copy failed. Please copy the link manually.",
BlockUploaded: "All blocks uploaded", BlockUploaded: "All content blocks uploaded successfully.",
ExtraBlockUploaded: "Extra blocks uploaded", ExtraBlockUploaded: "Additional blocks uploaded successfully.",
CheckConsole: "Check the console for more information \n opt+cmd+i/ctrl+shift+i", CheckConsole: "For more details, open the developer console (opt+cmd+i or ctrl+shift+i).",
SettingsMigrated: "✨ Plugin settings updated! Auto sync feature added, check plugin settings", SettingsMigrated: "✨ Settings updated! Auto-Sync is now available. Check the settings to learn more.",
AutoSyncNoNotionID: "⚠️ Auto sync skipped: This document has not been synced to Notion, please upload manually first", AutoSyncNoNotionID: "🆕 Auto-sync: First upload to Notion",
AutoSyncMultipleSync: "🔄 Auto sync: Syncing to {count} database(s)...", AutoSyncMissingDatabaseList: "⚠️ Auto-sync skipped: Add `{key}: [database_name]` to your frontmatter to specify target databases.",
AutoSyncFailed: "Auto sync to {database} failed: {error}", AutoSyncSkippedAttachments: "⚠️ Auto-sync skipped: {filename} contains internal attachments (images/PDFs). Please sync manually.",
AutoSyncError: "Auto sync failed for {filename}: {error}", AutoSyncMultipleSync: "🔄 Auto-sync: Syncing to {count} database(s)...",
"reach-mobile-limit": "The number of blocks exceeds the limit of 100, please use the desktop plugin", AutoSyncFailed: "Auto-sync to {database} failed: {error}",
StartUpload: "Start upload {filename}", AutoSyncError: "Auto-sync for {filename} failed: {error}",
"reach-mobile-limit": "Block limit (100) reached. For unlimited blocks, please use the desktop version.",
StartUpload: "Starting upload for {filename}...",
AddNewDatabase: "Add New Database", AddNewDatabase: "Add New Database",
AddNewDatabaseDesc: "Add a new database configuration", AddNewDatabaseDesc: "Add a new database configuration",
AddNewDatabaseTooltip: "Add New Database", AddNewDatabaseTooltip: "Add New Database",
@@ -98,14 +104,14 @@ export const en = {
Preview: "Preview", Preview: "Preview",
DatabaseFormatLabel: "Database Format", DatabaseFormatLabel: "Database Format",
DatabaseFullNameLabel: "Database Full Name", DatabaseFullNameLabel: "Database Full Name",
DatabaseAbbreviateNameLabel: "Database Abbreviate Name", DatabaseAbbreviateNameLabel: "Abbreviated Name",
NotionAPILabel: "Notion API Key", NotionAPILabel: "Notion API Key",
DatabaseIDLabel: "Database ID", DatabaseIDLabel: "Database ID",
ToggleAPIKeyVisibility: "Toggle API Key Visibility", ToggleAPIKeyVisibility: "Toggle API Key Visibility",
CopyAPIKey: "Copy API Key", CopyAPIKey: "Copy API Key",
APIKeyCopied: "API Key copied to clipboard", APIKeyCopied: "API key copied to clipboard.",
ToggleDatabaseIDVisibility: "Toggle Database ID Visibility", ToggleDatabaseIDVisibility: "Toggle Database ID Visibility",
CopyDatabaseID: "Copy Database ID", CopyDatabaseID: "Copy Database ID",
DatabaseIDCopied: "Database ID copied to clipboard", DatabaseIDCopied: "Database ID copied to clipboard.",
AddNewDatabaseModal: "Add new database", AddNewDatabaseModal: "Add New Database",
} }

View File

@@ -1,88 +1,95 @@
export const ja = { export const ja = {
databaseFormat: "データベース形式", databaseFormat: "データベース形式",
databaseFormatDesc: "同期したいデータベース形式を選択してください", databaseFormatDesc: "同期先のデータベース形式を選択してくださいNotionNext または 一般)。",
databaseNext: "NotionNext", databaseNext: "NotionNext",
databaseGeneral: "一般的なNotion", databaseGeneral: "一般",
databaseCustom: "カスタム", databaseCustom: "カスタム",
databaseFullName: "データベースの全称", databaseFullName: "データベースの全称",
databaseFullNameDesc: "データベースの全称を入力してください", databaseFullNameDesc: "データベースのフルネームを設定します。",
databaseFullNameText: "データベースの全称を入力", databaseFullNameText: "データベースの全称を入力",
databaseAbbreviateName: "データベースの略称", databaseAbbreviateName: "データベースの略称",
databaseAbbreviateNameDesc: "データベースの略称を入力してください", databaseAbbreviateNameDesc: "データベースの略称を設定します。",
databaseAbbreviateNameText: "データベースの略称を入力", databaseAbbreviateNameText: "データベースの略称を入力",
ribbonIcon: "NotionNextで共有", ribbonIcon: "NotionNextへ同期",
GeneralSetting: "一般設定", GeneralSetting: "一般設定",
CommandID: "share-to-notionnext", CommandID: "share-to-notionnext",
CommandName: "NotionNextデータベースに共有", CommandName: "NotionNextへ同期",
CommandIDGeneral: "share-to-notion", CommandIDGeneral: "share-to-notion",
CommandNameGeneral: "一般的なNotionデータベースに共有", CommandNameGeneral: "一般データベースへ同期",
NotionNextButton: "NotionNext同期", NotionNextButton: "NotionNext同期",
NotionNextButtonDesc: "このオプションを開くと、NotionNext同期コマンドがコマンドパレットに表示されます(デフォルト:ON", NotionNextButtonDesc: "有効にすると、コマンドパレットに「NotionNext同期」が表示されます(デフォルト:オン)。",
NotionNextSettingHeader: "NotionNextデータベース設定", NotionNextSettingHeader: "NotionNextデータベース設定",
NotionAPI: "Notion API トークン", NotionAPI: "Notion API トークン",
NotionAPIDesc: "https://www.notion.so/my-integrations から生成してください", NotionAPIDesc: "notion.so/my-integrations から取得します。",
NotionAPIText: "Notion API トークンを入力", NotionAPIText: "Notion API トークンを入力",
DatabaseID: "データベースID", DatabaseID: "データベースID",
DatabaseIDDesc: "右上共有 --> 公開から取得してください", DatabaseIDDesc: "Notionページの右上共有」メニューから取得します。",
DatabaseIDText: "データベースIDを入力", DatabaseIDText: "データベースIDを入力",
BannerUrl: "バナーURL任意", BannerUrl: "バナーURL任意",
BannerUrlDesc: "デフォルトは空白です。バナーを表示したい場合は、URLを入力してくださいhttps://abc.com/b.png", BannerUrlDesc: "空のままにするとバナーは表示されません。表示するには画像のURLを入力してくださいhttps://abc.com/b.png",
BannerUrlText: "バナーのURLを入力", BannerUrlText: "バナーのURLを入力",
NotionUser: "Notion IDユーザー名任意)", NotionUser: "Notionユーザー名任意)",
NotionUserDesc: "共有リンクから取得https://username.notion.site。Notion IDは[username]です", NotionUserDesc: "共有リンクが `username.notion.site` の場合、Notionユーザー名は `[username]` です",
NotionUserText: "Notion IDを入力", NotionUserText: "Notionユーザー名を入力",
NotionLinkDisplay: "Notionリンク表示", NotionLinkDisplay: "Notionリンク表示",
NotionLinkDisplayDesc: "デフォルトはONです。front matterにリンクを非表示にしたい場合は、オフにしてください", NotionLinkDisplayDesc: "デフォルトで有効。無効にすると、同期後にfront matterへNotionリンクが追加されません。",
AutoCopyNotionLink: "Notionリンクを自動コピー",
AutoCopyNotionLinkDesc: "同期完了後、Notionページのリンクをクリップボードに自動コピーしますデフォルトオン。",
AutoSync: "自動同期", AutoSync: "自動同期",
AutoSyncDesc: "frontmatter またはコンテンツが変更されたときに自動的に Notion に同期しますNotionID が必要)", AutoSyncDesc: "ファイルの内容(frontmatterまたは本文)が変更されると、自動でNotionに同期します。新規作成と更新の両方に対応。",
AutoSyncDelay: "自動同期遅延時間(秒)", AutoSyncFrontmatterKey: "自動同期 frontmatter キー",
AutoSyncDelayDesc: "ドキュメントの変更後、自動同期をトリガーするまでの待機時間デフォルト5秒、最小2秒", AutoSyncFrontmatterKeyDesc: "自動同期の対象となるデータベースをリストアップするための frontmatterキーを設定しますデフォルトautosync-database",
AutoSyncDelay: "自動同期の遅延(秒)",
AutoSyncDelayDesc: "変更が検知されてから同期を開始するまでの遅延時間。同期の頻発を防ぎますデフォルト5秒、最小2秒。",
AutoSyncDelayText: "遅延秒数を入力", AutoSyncDelayText: "遅延秒数を入力",
NotionGeneralSettingHeader: "一般的なNotionデータベース設定", NotionGeneralSettingHeader: "一般Notionデータベース設定",
NotionGeneralButton: "一般的なNotion同期", NotionGeneralButton: "一般データベース同期",
NotionGeneralButtonDesc: "このオプションを開くと、一般的なNotionデータベース同期コマンドがコマンドパレットに表示されます(デフォルト:ON", NotionGeneralButtonDesc: "有効にすると、コマンドパレットに「一般データベース同期」が表示されます(デフォルト:オン)。",
NotionTagButton: "Notionタグ同期", NotionTagButton: "タグ同期",
NotionTagButtonDesc: "タグを一般的なNotionデータベースに同期デフォルトON", NotionTagButtonDesc: "ObsidianのタグをNotionデータベースに同期します(デフォルト:オン)。",
NotionCustomTitle: "タイトルのカスタマイズ", NotionCustomTitle: "タイトルプロパティをカスタム",
NotionCustomTitleDesc: "Notionデータベースの列名を変更(デフォルト:OFF", NotionCustomTitleDesc: "Notionデータベースのタイトル列の名前をカスタマイズします(デフォルト:オフ)。",
NotionCustomTitleName: "希望のタイトル名", NotionCustomTitleName: "カスタムタイトル名",
NotionCustomTitleNameDesc: "Notionデータベースの最初の列のための希望のタイトル名を入力デフォルトtitle", NotionCustomTitleNameDesc: "Notionデータベースのタイトル列に使用するカスタム名を入力してください(デフォルト:title」)。",
NotionCustomTitleText: "名を入力", NotionCustomTitleText: "プロパティ名を入力",
NotionCustomValues: "値のカスタマイズ", NotionCustomValues: "カスタムプロパティ",
NotionCustomValuesDesc: "Notionデータベースの列名を変更、1行に1つ", NotionCustomValuesDesc: "Notionデータベースに同期するカスタムプロパティを1行に1つずつ定義します。",
NotionCustomValuesText: "同期したいすべてのプロパティを入力", NotionCustomValuesText: "同期したいすべてのプロパティを入力",
NotYetFinish: "未完了。この機能はのバージョンで利用可能になります", NotYetFinish: "この機能は将来のバージョンで利用可能になります",
PlaceHolder: "データベース名を入力", PlaceHolder: "データベース名を入力",
"notion-logo": "NotionNextで共有", "notion-logo": "NotionNextへ同期",
"sync-success": "NotionNextへの同期に成功\n", "sync-preffix": "📄",
"sync-fail": "NotionNextへの同期に失敗:\n", "sync-success": "NotionNextへの同期が成功しました。\n",
"open-notion": "同期が必要なファイルを開いてください", "sync-fail": "NotionNextへの同期に失敗しました。\n",
"config-secrets-notion-api": "設定タブでNotion APIを設定してください", "open-notion": "同期するファイルを先に開いてください",
"config-secrets-database-id": "設定タブでデータベースIDを設定してください", "config-secrets-notion-api": "プラグイン設定でNotion APIキーを設定してください",
"set-tags-fail": "タグの設定に失敗。ファイルのfrontmatterを確認するか、設定タブでタグのスイッチをオフにしてください", "config-secrets-database-id": "プラグイン設定でデータベースIDを設定してください",
NNonMissing: "設定に 'NNon' プロパティがありません。設定してください", "set-tags-fail": "タグの設定に失敗しました。frontmatterを確認するか、設定でタグ同期を無効にしてください",
"set-api-id": "設定タブでNotion APIおよびデータベースIDを設定してください", NNonMissing: "'NNon'プロパティが設定されていません。設定でNotionNextデータベースを選択してください",
NotionCustomSettingHeader: "Notionカスタムデータベース設定", "set-api-id": "プラグイン設定でNotion APIキーとデータベースIDを設定してください。",
NotionCustomButton: "Notionカスタマイズコマンドの切り替え", NotionCustomSettingHeader: "Notionカスタムデータベース設定",
NotionCustomButtonDesc: "このオプションを開くと、Notionカスタムデータベース同期コマンドがコマンドパレットに表示されます", NotionCustomButton: "カスタムデータベースコマンドを有効化",
CustomPropertyName: "カスタムプロパティ名", NotionCustomButtonDesc: "有効にすると、「カスタムデータベースへ同期」コマンドがコマンドパレットに表示されます。",
CustomPropertyFirstColumn: "最初の列のカスタムプロパティ名", CustomPropertyName: "プロパティ名",
CustomPropertyFirstColumnDesc: "最初の列のカスタムプロパティ名を入力してください", CustomPropertyFirstColumn: "タイトルプロパティ名",
CustomProperty: "カスタムプロパティ", CustomPropertyFirstColumnDesc: "ページのタイトル。これはリストの最初のプロパティである必要があります。",
CustomProperty: "プロパティ",
AddCustomProperty: "カスタムプロパティを追加", AddCustomProperty: "カスタムプロパティを追加",
AddNewProperty: "新しいプロパティを追加", AddNewProperty: "新しいプロパティを追加",
AddNewPropertyDesc: "新しいプロパティを追加してください", AddNewPropertyDesc: "Notionデータベースのプロパティと一致する新しいプロパティを追加します。",
CopyErrorMessage: "自動コピーに失敗しました", CopyErrorMessage: "リンクの自動コピーに失敗しました。手動でコピーしてください。",
BlockUploaded: "ブロックアップロードされました", BlockUploaded: "すべてのブロックアップロードました",
ExtraBlockUploaded: "追加ブロックアップロードされました", ExtraBlockUploaded: "追加ブロックアップロードました",
CheckConsole: "詳細情報を確認するには、コンソールを開いてください \n opt+cmd+i/ctrl+shift+i", CheckConsole: "詳細は、開発者コンソール(opt+cmd+i または ctrl+shift+i)で確認できます。",
SettingsMigrated: "✨ プラグイン設定が更新されました!自動同期機能が追加されました。設定を確認してください", SettingsMigrated: "✨ 設定が更新されました!自動同期が利用可能です。詳細は設定画面をご確認ください",
AutoSyncNoNotionID: "⚠️ 自動同期をスキップ:このドキュメントは Notion に同期されていません。まず手動でアップロードしてください", AutoSyncNoNotionID: "🆕 自動同期Notionへ初めてアップロードします",
AutoSyncMultipleSync: "🔄 自動同期{count} 個のデータベースに同期中...", AutoSyncMissingDatabaseList: "⚠️ 自動同期をスキップfrontmatterに `{key}: [データベース名]` を追加して同期先を指定してください。",
AutoSyncFailed: "{database} への自動同期に失敗しました:{error}", AutoSyncSkippedAttachments: "⚠️ 自動同期をスキップ:{filename} に内部添付(画像/PDFが含まれています。手動で同期してください。",
AutoSyncError: "{filename} の自動同期に失敗しました:{error}", AutoSyncMultipleSync: "🔄 自動同期:{count}個のデータベースに同期しています...",
"reach-mobile-limit": "ブロック数が100の制限を超えています。デスクトップ版プラグインを使用してください", AutoSyncFailed: "{database}への自動同期に失敗しました:{error}",
StartUpload: "アップロード開始 {filename}", AutoSyncError: "{filename}の自動同期に失敗しました:{error}",
"reach-mobile-limit": "ブロック上限100に達しました。ブロック数に制限のないデスクトップ版をご利用ください。",
StartUpload: "{filename} のアップロードを開始します...",
AddNewDatabase: "新しいデータベースを追加", AddNewDatabase: "新しいデータベースを追加",
AddNewDatabaseDesc: "新しいデータベース構成を追加", AddNewDatabaseDesc: "新しいデータベース構成を追加",
AddNewDatabaseTooltip: "新しいデータベースを追加", AddNewDatabaseTooltip: "新しいデータベースを追加",
@@ -95,9 +102,9 @@ export const ja = {
DatabaseIDLabel: "データベース ID", DatabaseIDLabel: "データベース ID",
ToggleAPIKeyVisibility: "API キーの表示を切り替え", ToggleAPIKeyVisibility: "API キーの表示を切り替え",
CopyAPIKey: "API キーをコピー", CopyAPIKey: "API キーをコピー",
APIKeyCopied: "API キーをクリップボードにコピーしました", APIKeyCopied: "APIキーをクリップボードにコピーしました",
ToggleDatabaseIDVisibility: "データベース ID の表示を切り替え", ToggleDatabaseIDVisibility: "データベース ID の表示を切り替え",
CopyDatabaseID: "データベース ID をコピー", CopyDatabaseID: "データベース ID をコピー",
DatabaseIDCopied: "データベース ID をクリップボードにコピーしました", DatabaseIDCopied: "データベースIDをクリップボードにコピーしました",
AddNewDatabaseModal: "新しいデータベースを追加", AddNewDatabaseModal: "新しいデータベースを追加",
}; };

View File

@@ -1,105 +1,112 @@
export const zh = { export const zh = {
databaseFormat: "数据库格式", databaseFormat: "数据库格式",
databaseFormatDesc: "选择你想要同步的数据库格式Next 或者 普通", databaseFormatDesc: "选择同步的目标数据库格式NotionNext 或 通用",
databaseNext: "NotionNext", databaseNext: "NotionNext",
databaseGeneral: "通", databaseGeneral: "通",
databaseCustom: "自定义", databaseCustom: "自定义",
databaseFullName: "数据库全", databaseFullName: "数据库全",
databaseFullNameDesc: "给你的数据库一个全", databaseFullNameDesc: "数据库设置一个全",
databaseFullNameText: "输入的数据库全", databaseFullNameText: "输入的数据库全",
databaseAbbreviateName: "数据库简称", databaseAbbreviateName: "数据库简称",
databaseAbbreviateNameDesc: "给你的数据库一个简称", databaseAbbreviateNameDesc: "数据库设置一个简称",
databaseAbbreviateNameText: "输入的数据库简称", databaseAbbreviateNameText: "输入的数据库简称",
ribbonIcon: "分享到 NotionNext", ribbonIcon: "同步到 NotionNext",
GeneralSetting: "通用设置", GeneralSetting: "通用设置",
CommandID: "share-to-notionnext", CommandID: "share-to-notionnext",
CommandName: "分享到 NotionNext", CommandName: "同步到 NotionNext",
CommandIDGeneral: "share-to-notion", CommandIDGeneral: "share-to-notion",
CommandNameGeneral: "分享到 Notion 普通数据库", CommandNameGeneral: "同步到通用数据库",
NotionNextButton: "NotionNext 同步", NotionNextButton: "NotionNext 同步",
NotionNextButtonDesc: "打开此选项NotionNext 同步将显示在命令面板中(默认开)", NotionNextButtonDesc: "启用后,命令面板中将显示“同步到 NotionNext”命令(默认开",
NotionNextSettingHeader: "NotionNext 数据库参数设置", NotionNextSettingHeader: "NotionNext 数据库设置",
NotionAPI: "Notion API 令牌", NotionAPI: "Notion API 令牌",
NotionAPIDesc: "从 https://www.notion.so/my-integrations 生成", NotionAPIDesc: "从 notion.so/my-integrations 获取",
NotionAPIText: "输入的 Notion API 令牌", NotionAPIText: "输入的 Notion API 令牌",
DatabaseID: "数据库 ID", DatabaseID: "数据库 ID",
DatabaseIDDesc: "右上角的分享 --> 发布中获取", DatabaseIDDesc: "可从 Notion 页面右上角的分享”菜单中获取",
DatabaseIDText: "输入的数据库 ID", DatabaseIDText: "输入的数据库 ID",
BannerUrl: "封面图片地址(可选)", BannerUrl: "封面图片地址(可选)",
BannerUrlDesc: BannerUrlDesc:
"默认为空,如果你想显示封面图片请输入图片地址例如https://abc.com/b.png", "留空则不显示。如需封面请输入图片地址例如https://abc.com/b.png",
BannerUrlText: "输入的封面图片地址", BannerUrlText: "输入的封面图片地址",
NotionUser: "Notion ID用户名可选)", NotionUser: "Notion 用户名可选)",
NotionUserDesc: NotionUserDesc:
"数据库分享链接类似https://username.notion.site/。你的 Notion ID 是 [username]", "分享链接username.notion.site你的 Notion 用户名即为 [username]",
NotionUserText: "输入的 Notion ID", NotionUserText: "输入的 Notion 用户名",
NotionLinkDisplay: "Notion 链接显示", NotionLinkDisplay: "显示 Notion 链接",
NotionLinkDisplayDesc: "默认开启,如果你不想在front matter中显示链接,请关闭", NotionLinkDisplayDesc: "默认开启。关闭后,同步成功时 frontmatter 中不会出现 Notion 链接",
AutoCopyNotionLink: "自动复制 Notion 链接",
AutoCopyNotionLinkDesc: "同步后自动将 Notion 链接复制到剪贴板(默认开启)",
AutoSync: "自动同步", AutoSync: "自动同步",
AutoSyncDesc: "当检测到文档的 frontmatter 或内容发生修改时,自动同步到 Notion需要文档已有 NotionID", AutoSyncDesc: "当文档的 frontmatter 或内容修改时,自动同步到 Notion支持新建和更新",
AutoSyncDelay: "自动同步延迟时间(秒)", AutoSyncFrontmatterKey: "自动同步 Frontmatter 键名",
AutoSyncDelayDesc: "文档修改后等待多少秒才触发自动同步避免频繁同步默认5秒最小2秒", AutoSyncFrontmatterKeyDesc: "设置用于指定自动同步数据库列表的 frontmatter 键名(默认为 autosync-database",
AutoSyncDelay: "自动同步延迟(秒)",
AutoSyncDelayDesc: "文档修改后,等待指定秒数再触发自动同步,以避免频繁操作(默认 5 秒,最少 2 秒)",
AutoSyncDelayText: "输入延迟秒数", AutoSyncDelayText: "输入延迟秒数",
NotionGeneralSettingHeader: "通 Notion 数据库设置", NotionGeneralSettingHeader: "通 Notion 数据库设置",
NotionGeneralButton: "通数据库同步", NotionGeneralButton: "通数据库同步",
NotionGeneralButtonDesc: "打开此选项,同步到通数据库命令将显示在命令面板中(默认开)", NotionGeneralButtonDesc: "启用后,命令面板中将显示“同步到通数据库命令(默认开",
NotionTagButton: "标签同步开关", NotionTagButton: "标签同步",
NotionTagButtonDesc: "将标签同步到普通数据库(默认开)", NotionTagButtonDesc: "将 Obsidian 标签同步到 Notion 数据库(默认开",
NotionCustomTitle: "修改 Notion 数据库表头开关", NotionCustomTitle: "自定义标题属性",
NotionCustomTitleDesc: "自定义Notion 数据库第一列表头名(默认关)", NotionCustomTitleDesc: "自定义 Notion 数据库中标题列的名称(默认关",
NotionCustomTitleName: "想要修改的表头名", NotionCustomTitleName: "自定义标题名称",
NotionCustomTitleNameDesc: "输入你想要修改的notion数据库的表头名(默认title", NotionCustomTitleNameDesc: "为 Notion 数据库的标题列设置一个自定义名称(默认title",
NotionCustomTitleText: "输入表头名", NotionCustomTitleText: "输入标题名称",
NotionCustomValues: "自定义Notion 数据库表头", NotionCustomValues: "自定义属性",
NotionCustomValuesDesc: "自定义Notion 数据库表头,每行一个", NotionCustomValuesDesc: "自定义同步到 Notion 数据库的属性,每行一个",
NotionCustomValuesText: "输入你想要同步的所有属性", NotionCustomValuesText: "输入所有你希望同步的属性",
NotYetFinish: "未完成。此功能将在之后版本中提供", NotYetFinish: "此功能将在未来版本中提供",
PlaceHolder: "输入数据库名称", PlaceHolder: "输入数据库名称",
"notion-logo": "分享到NotionNext", "notion-logo": "同步到 NotionNext",
"sync-success": "同步到NotionNext成功:\n", "sync-preffix": "📄",
"sync-fail": "同步到NotionNext失败: \n", "sync-success": "成功同步到 NotionNext\n",
"open-file": "请打开需要同步的文件", "sync-fail": "同步到 NotionNext 失败:\n",
"config-secrets-notion-api": "请在插件设置中添加notion API", "open-file": "请先打开要同步的文件。",
"config-secrets-database-id": "请在插件设置中添加database id", "config-secrets-notion-api": "请在插件设置中配置 Notion API 密钥。",
"config-secrets-database-id": "请在插件设置中配置数据库 ID。",
"set-tags-fail": "set-tags-fail":
"设置标签失败,请检查文件的frontmatter,或者在插件设置中关闭设置tags开关", "标签设置失败请检查 frontmatter 或在设置中关闭标签同步。",
NNonMissing: "未设置'NNon'属性请在插件设置中选择NotionNext数据库。", NNonMissing: "未设置 'NNon' 属性,请在插件设置中选择一个 NotionNext 数据库。",
"set-api-id": "请在插件设置中设置notion API和database ID", "set-api-id": "请在插件设置中配置 Notion API 和数据库 ID",
NotionCustomSettingHeader: "Notion 自定义数据库设置", NotionCustomSettingHeader: "Notion 自定义数据库设置",
NotionCustomButton: "Notion 自定义数据库同步命令开关", NotionCustomButton: "启用自定义数据库同步命令",
NotionCustomButtonDesc: "打开此选项,同步到自定义数据库命令将显示在命令面板中", NotionCustomButtonDesc: "启用后,“同步到自定义数据库”的命令将出现在命令面板中",
CustomPropertyName: "自定义属性名", CustomPropertyName: "自定义属性名",
CustomPropertyFirstColumn: "第一列属性", CustomPropertyFirstColumn: "标题属性",
CustomPropertyFirstColumnDesc: "第一列必须为标题属性", CustomPropertyFirstColumnDesc: "第一列必须为标题属性",
CustomProperty: "自定义属性", CustomProperty: "自定义属性",
AddCustomProperty: "添加自定义属性", AddCustomProperty: "添加自定义属性",
AddNewProperty: "添加新属性", AddNewProperty: "添加新属性",
AddNewPropertyDesc: "添加一个Notion数据库匹配的新属性", AddNewPropertyDesc: "添加一个与您 Notion 数据库中的属性相匹配的新属性",
CopyErrorMessage: "复制链接失败,请手动复制", CopyErrorMessage: "自动复制链接失败,请手动复制",
BlockUploaded: "所有内容已成功上传", BlockUploaded: "所有块已上传成功",
ExtraBlockUploaded: "额外内容已成功上传", ExtraBlockUploaded: "额外块已上传成功",
CheckConsole: "opt+cmd+i/ctrl+shift+i\n打开控制台查看更多信息", CheckConsole: "opt+cmd+i / ctrl+shift+i 打开控制台查看详情。",
SettingsMigrated: "✨ 插件设置已更新!新增自动同步功能,请在设置中查看", SettingsMigrated: "✨ 插件设置已更新!新增自动同步功能,详情请查看设置。",
AutoSyncNoNotionID: "⚠️ 自动同步跳过:此文档未同步到 Notion,请先手动上传", AutoSyncNoNotionID: "🆕 自动同步:首次上传到 Notion",
AutoSyncMissingDatabaseList: "⚠️ 自动同步已跳过:请在 frontmatter 中添加 \"{key}\" 以指定目标数据库。",
AutoSyncSkippedAttachments: "⚠️ 自动同步已跳过:检测到 {filename} 含有本地附件(图片/PDF请手动同步。",
AutoSyncMultipleSync: "🔄 自动同步:正在同步到 {count} 个数据库...", AutoSyncMultipleSync: "🔄 自动同步:正在同步到 {count} 个数据库...",
AutoSyncFailed: "自动同步到 {database} 失败:{error}", AutoSyncFailed: "同步到 {database} 失败:{error}",
AutoSyncError: "自动同步 {filename} 失败:{error}", AutoSyncError: "同步 {filename} 失败:{error}",
StartUpload: "开始上传 {filename}", StartUpload: "开始上传 {filename}...",
AddNewDatabase: "添加新数据库", AddNewDatabase: "添加新数据库",
AddNewDatabaseDesc: "添加新的数据库配置", AddNewDatabaseDesc: "添加新的数据库配置",
AddNewDatabaseTooltip: "添加新数据库", AddNewDatabaseTooltip: "添加新数据库",
EditDatabase: "编辑数据库", EditDatabase: "编辑数据库",
Preview: "预览", Preview: "预览",
DatabaseFormatLabel: "数据库格式", DatabaseFormatLabel: "数据库格式",
DatabaseFullNameLabel: "数据库全", DatabaseFullNameLabel: "数据库全",
DatabaseAbbreviateNameLabel: "数据库简称", DatabaseAbbreviateNameLabel: "数据库简称",
NotionAPILabel: "Notion API 密钥", NotionAPILabel: "Notion API 密钥",
DatabaseIDLabel: "数据库 ID", DatabaseIDLabel: "数据库 ID",
ToggleAPIKeyVisibility: "切换 API 密钥可见性", ToggleAPIKeyVisibility: "切换 API 密钥可见性",
CopyAPIKey: "复制 API 密钥", CopyAPIKey: "复制 API 密钥",
APIKeyCopied: "API 密钥已复制到剪贴板", APIKeyCopied: "API 密钥已复制到剪贴板",
ToggleDatabaseIDVisibility: "切换数据库 ID 可见性", ToggleDatabaseIDVisibility: "切换数据库 ID 可见性",
CopyDatabaseID: "复制数据库 ID", CopyDatabaseID: "复制数据库 ID",
DatabaseIDCopied: "数据库 ID 已复制到剪贴板", DatabaseIDCopied: "数据库 ID 已复制到剪贴板",
AddNewDatabaseModal: "添加新数据库", AddNewDatabaseModal: "添加新数据库",
} }

View File

@@ -4,6 +4,8 @@ import { i18nConfig } from "src/lang/I18n";
import ribbonCommands from "src/commands/NotionCommands"; import ribbonCommands from "src/commands/NotionCommands";
import { ObsidianSettingTab, PluginSettings, DEFAULT_SETTINGS, DatabaseDetails } from "src/ui/settingTabs"; import { ObsidianSettingTab, PluginSettings, DEFAULT_SETTINGS, DatabaseDetails } from "src/ui/settingTabs";
import { uploadCommandNext, uploadCommandGeneral, uploadCommandCustom } from "src/upload/uploadCommand"; 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! // Remember to rename these classes and interfaces!
@@ -17,6 +19,8 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
private syncingFiles: Set<string> = new Set(); private syncingFiles: Set<string> = new Set();
private lastFrontmatterCache: Map<string, any> = new Map(); private lastFrontmatterCache: Map<string, any> = new Map();
private lastContentHashCache: Map<string, string> = new Map(); private lastContentHashCache: Map<string, string> = new Map();
private autoSyncAttachmentBlocked: Set<string> = new Set();
async onload() { async onload() {
await this.loadSettings(); await this.loadSettings();
@@ -67,6 +71,8 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
} }
this.lastFrontmatterCache.clear(); this.lastFrontmatterCache.clear();
this.lastContentHashCache.clear(); this.lastContentHashCache.clear();
this.autoSyncAttachmentBlocked.clear();
} }
async loadSettings() { async loadSettings() {
@@ -89,6 +95,13 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
if (typeof this.settings.NotionLinkDisplay !== 'boolean') { if (typeof this.settings.NotionLinkDisplay !== 'boolean') {
this.settings.NotionLinkDisplay = DEFAULT_SETTINGS.NotionLinkDisplay; this.settings.NotionLinkDisplay = DEFAULT_SETTINGS.NotionLinkDisplay;
} }
if (typeof this.settings.autoCopyNotionLink !== 'boolean') {
this.settings.autoCopyNotionLink = DEFAULT_SETTINGS.autoCopyNotionLink;
}
if (typeof this.settings.autoSyncFrontmatterKey !== 'string') {
this.settings.autoSyncFrontmatterKey = DEFAULT_AUTO_SYNC_DATABASE_KEY;
}
this.settings.autoSyncFrontmatterKey = resolveAutoSyncKey(this.settings.autoSyncFrontmatterKey);
// Ensure databaseDetails exists // Ensure databaseDetails exists
if (!this.settings.databaseDetails || typeof this.settings.databaseDetails !== 'object') { if (!this.settings.databaseDetails || typeof this.settings.databaseDetails !== 'object') {
@@ -99,7 +112,9 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
const needsSave = !loadedData || const needsSave = !loadedData ||
loadedData.autoSync === undefined || loadedData.autoSync === undefined ||
loadedData.autoSyncDelay === undefined || loadedData.autoSyncDelay === undefined ||
loadedData.NotionLinkDisplay === undefined; loadedData.NotionLinkDisplay === undefined ||
loadedData.autoCopyNotionLink === undefined ||
loadedData.autoSyncFrontmatterKey === undefined;
if (needsSave) { if (needsSave) {
const migratedFields = []; const migratedFields = [];
@@ -109,6 +124,7 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
if (loadedData.autoSync === undefined) migratedFields.push('autoSync'); if (loadedData.autoSync === undefined) migratedFields.push('autoSync');
if (loadedData.autoSyncDelay === undefined) migratedFields.push('autoSyncDelay'); if (loadedData.autoSyncDelay === undefined) migratedFields.push('autoSyncDelay');
if (loadedData.NotionLinkDisplay === undefined) migratedFields.push('NotionLinkDisplay'); if (loadedData.NotionLinkDisplay === undefined) migratedFields.push('NotionLinkDisplay');
if (loadedData.autoCopyNotionLink === undefined) migratedFields.push('autoCopyNotionLink');
console.log('[Settings] Migrating settings, adding fields:', migratedFields.join(', ')); console.log('[Settings] Migrating settings, adding fields:', migratedFields.join(', '));
} }
@@ -131,6 +147,7 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
autoSync: this.settings.autoSync, autoSync: this.settings.autoSync,
autoSyncDelay: this.settings.autoSyncDelay, autoSyncDelay: this.settings.autoSyncDelay,
NotionLinkDisplay: this.settings.NotionLinkDisplay, NotionLinkDisplay: this.settings.NotionLinkDisplay,
autoSyncFrontmatterKey: this.settings.autoSyncFrontmatterKey,
databaseCount: Object.keys(this.settings.databaseDetails || {}).length databaseCount: Object.keys(this.settings.databaseDetails || {}).length
}); });
} }
@@ -149,10 +166,26 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
console.warn('[Settings] Invalid NotionLinkDisplay value, resetting to default'); console.warn('[Settings] Invalid NotionLinkDisplay value, resetting to default');
this.settings.NotionLinkDisplay = DEFAULT_SETTINGS.NotionLinkDisplay; this.settings.NotionLinkDisplay = DEFAULT_SETTINGS.NotionLinkDisplay;
} }
if (typeof this.settings.autoCopyNotionLink !== 'boolean') {
console.warn('[Settings] Invalid autoCopyNotionLink value, resetting to default');
this.settings.autoCopyNotionLink = DEFAULT_SETTINGS.autoCopyNotionLink;
}
if (!this.settings.databaseDetails || typeof this.settings.databaseDetails !== 'object') { if (!this.settings.databaseDetails || typeof this.settings.databaseDetails !== 'object') {
console.warn('[Settings] Invalid databaseDetails, resetting to empty object'); console.warn('[Settings] Invalid databaseDetails, resetting to empty object');
this.settings.databaseDetails = {}; this.settings.databaseDetails = {};
} }
if (typeof this.settings.autoSyncFrontmatterKey !== 'string' || this.settings.autoSyncFrontmatterKey.trim().length === 0) {
console.warn('[Settings] Invalid autoSyncFrontmatterKey, resetting to default');
this.settings.autoSyncFrontmatterKey = DEFAULT_AUTO_SYNC_DATABASE_KEY;
} else {
this.settings.autoSyncFrontmatterKey = resolveAutoSyncKey(this.settings.autoSyncFrontmatterKey);
}
}
getAutoSyncFrontmatterKey(): string {
return resolveAutoSyncKey(this.settings.autoSyncFrontmatterKey);
} }
async addDatabaseDetails(dbDetails: DatabaseDetails) { async addDatabaseDetails(dbDetails: DatabaseDetails) {
@@ -285,7 +318,14 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
// Get file's frontmatter // Get file's frontmatter
const frontMatter = this.app.metadataCache.getFileCache(file)?.frontmatter; const frontMatter = this.app.metadataCache.getFileCache(file)?.frontmatter;
if (!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; return;
} }
@@ -301,64 +341,76 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
const frontmatterOnlyNotionIDChanged = this.onlyNotionIDChanged(lastFrontmatter, frontMatter); const frontmatterOnlyNotionIDChanged = this.onlyNotionIDChanged(lastFrontmatter, frontMatter);
const contentUnchanged = contentHash === lastContentHash; 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) { 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.lastFrontmatterCache.set(file.path, { ...frontMatter });
this.lastContentHashCache.set(file.path, contentHash); this.lastContentHashCache.set(file.path, contentHash);
return; return;
} }
if (!contentUnchanged) {
console.log(`[AutoSync] Content changed - will sync`);
} else if (!frontmatterOnlyNotionIDChanged) {
console.log(`[AutoSync] Frontmatter changed - will sync`);
}
} }
// Find all databases this file belongs to by checking for NotionID-{abName}
const foundDatabases: Array<{ dbDetails: DatabaseDetails, notionId: string }> = [];
const dbByShortName = new Map<string, DatabaseDetails>();
for (const key in this.settings.databaseDetails) { for (const key in this.settings.databaseDetails) {
const dbDetails = this.settings.databaseDetails[key]; const dbDetails = this.settings.databaseDetails[key];
const notionIDKey = `NotionID-${dbDetails.abName}`; dbByShortName.set(dbDetails.abName.toLowerCase(), dbDetails);
if (frontMatter[notionIDKey]) {
foundDatabases.push({
dbDetails: dbDetails,
notionId: String(frontMatter[notionIDKey])
});
}
} }
// If no NotionID found, notify user to upload manually first const foundDatabases: Array<{ dbDetails: DatabaseDetails, notionId: string | undefined }> = [];
const unresolvedTargets: string[] = [];
for (const target of autoSyncTargets) {
const lookupKey = target.toLowerCase();
const dbDetails = dbByShortName.get(lookupKey);
if (!dbDetails) {
unresolvedTargets.push(target);
continue;
}
const notionIDKey = `NotionID-${dbDetails.abName}`;
// Include database even if no NotionID exists (for first-time upload)
foundDatabases.push({
dbDetails,
notionId: frontMatter[notionIDKey] ? String(frontMatter[notionIDKey]) : undefined
});
}
if (unresolvedTargets.length > 0) {
console.log(`[AutoSync] Frontmatter auto sync targets not found in settings: ${unresolvedTargets.join(", ")}`);
}
// If no valid databases found in settings, skip
if (foundDatabases.length === 0) { if (foundDatabases.length === 0) {
console.log(`[AutoSync] No NotionID found in ${file.path}, skipping auto sync`); console.log(`[AutoSync] No matching databases found in settings for ${file.path}, skipping auto sync`);
new Notice(i18nConfig.AutoSyncNoNotionID, 4000);
return; 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 // Notify user about multiple syncs if applicable
if (foundDatabases.length > 1) { if (foundDatabases.length > 1) {
const message = i18nConfig.AutoSyncMultipleSync.replace('{count}', String(foundDatabases.length)); const message = i18nConfig.AutoSyncMultipleSync.replace('{count}', String(foundDatabases.length));
new Notice(message, 3000); new Notice(message, 3000);
console.log(`[AutoSync] Found ${foundDatabases.length} NotionIDs in ${file.path}`); console.log(`[AutoSync] Found ${foundDatabases.length} database targets in ${file.path}`);
} }
// Sync to all found databases // Sync to all found databases
for (const { dbDetails, notionId } of foundDatabases) { for (const { dbDetails, notionId } of foundDatabases) {
console.log(`[AutoSync] ${new Date().toISOString()} Auto syncing ${file.basename} to ${dbDetails.fullName} (${dbDetails.abName})`); const isFirstSync = !notionId;
console.log(`[AutoSync] ${new Date().toISOString()} Auto syncing ${file.basename} to ${dbDetails.fullName} (${dbDetails.abName})${isFirstSync ? ' [First Upload]' : ''}`);
try { try {
// Trigger appropriate upload command based on database format // Trigger appropriate upload command based on database format
@@ -403,4 +455,3 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
} }
} }
} }

View File

@@ -5,6 +5,7 @@ import { SettingModal } from "./settingModal";
import { PreviewModal } from "./PreviewModal"; import { PreviewModal } from "./PreviewModal";
import { EditModal } from "./EditModal"; import { EditModal } from "./EditModal";
import { DeleteModal } from "./DeleteModal"; import { DeleteModal } from "./DeleteModal";
import { DEFAULT_AUTO_SYNC_DATABASE_KEY } from "src/utils/frontmatter";
export interface PluginSettings { export interface PluginSettings {
NextButton: boolean; NextButton: boolean;
@@ -13,8 +14,10 @@ export interface PluginSettings {
bannerUrl: string; bannerUrl: string;
notionUser: string; notionUser: string;
NotionLinkDisplay: boolean; NotionLinkDisplay: boolean;
autoCopyNotionLink: boolean;
autoSync: boolean; autoSync: boolean;
autoSyncDelay: number; autoSyncDelay: number;
autoSyncFrontmatterKey: string;
proxy: string; proxy: string;
GeneralButton: boolean; GeneralButton: boolean;
tagButton: boolean; tagButton: boolean;
@@ -51,8 +54,10 @@ export const DEFAULT_SETTINGS: PluginSettings = {
bannerUrl: "", bannerUrl: "",
notionUser: "", notionUser: "",
NotionLinkDisplay: true, NotionLinkDisplay: true,
autoCopyNotionLink: true,
autoSync: false, autoSync: false,
autoSyncDelay: 5, autoSyncDelay: 5,
autoSyncFrontmatterKey: DEFAULT_AUTO_SYNC_DATABASE_KEY,
proxy: "", proxy: "",
GeneralButton: true, GeneralButton: true,
tagButton: true, tagButton: true,
@@ -92,11 +97,26 @@ export class ObsidianSettingTab extends PluginSettingTab {
this.createSettingEl(containerEl, i18nConfig.NotionLinkDisplay, i18nConfig.NotionLinkDisplayDesc, 'toggle', i18nConfig.NotionLinkDisplay, this.plugin.settings.NotionLinkDisplay, 'NotionLinkDisplay') this.createSettingEl(containerEl, i18nConfig.NotionLinkDisplay, i18nConfig.NotionLinkDisplayDesc, 'toggle', i18nConfig.NotionLinkDisplay, this.plugin.settings.NotionLinkDisplay, 'NotionLinkDisplay')
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.AutoSync, i18nConfig.AutoSyncDesc, 'toggle', i18nConfig.AutoSync, this.plugin.settings.autoSync, 'autoSync')
new Setting(containerEl)
.setName(i18nConfig.AutoSyncFrontmatterKey)
.setDesc(i18nConfig.AutoSyncFrontmatterKeyDesc)
.addText((text) =>
text
.setPlaceholder(DEFAULT_AUTO_SYNC_DATABASE_KEY)
.setValue(this.plugin.settings.autoSyncFrontmatterKey ?? "")
.onChange(async (value) => {
this.plugin.settings.autoSyncFrontmatterKey = value;
await this.plugin.saveSettings();
})
);
// Auto Sync Delay setting - only visible when autoSync is enabled // Auto Sync Delay setting - only visible when autoSync is enabled
this.autoSyncDelayContainer = containerEl.createDiv(); this.autoSyncDelayContainer = containerEl.createDiv();
const delaySetting = new Setting(this.autoSyncDelayContainer) new Setting(this.autoSyncDelayContainer)
.setName(i18nConfig.AutoSyncDelay) .setName(i18nConfig.AutoSyncDelay)
.setDesc(i18nConfig.AutoSyncDelayDesc) .setDesc(i18nConfig.AutoSyncDelayDesc)
.addText((text) => .addText((text) =>
@@ -116,7 +136,7 @@ export class ObsidianSettingTab extends PluginSettingTab {
} }
}) })
); );
// Set initial visibility // Set initial visibility
this.updateAutoSyncDelayVisibility(); this.updateAutoSyncDelayVisibility();
@@ -218,7 +238,7 @@ export class ObsidianSettingTab extends PluginSettingTab {
this.plugin.settings[settingsKey] = value; // Update the plugin settings directly this.plugin.settings[settingsKey] = value; // Update the plugin settings directly
await this.plugin.saveSettings(); await this.plugin.saveSettings();
await this.plugin.commands.updateCommand(); await this.plugin.commands.updateCommand();
// If autoSync setting changed, update the listener and visibility // If autoSync setting changed, update the listener and visibility
if (settingsKey === 'autoSync') { if (settingsKey === 'autoSync') {
this.plugin.setupAutoSync(); this.plugin.setupAutoSync();
@@ -350,4 +370,3 @@ export class ObsidianSettingTab extends PluginSettingTab {
} }
} }

View File

@@ -6,6 +6,7 @@ import MyPlugin from "src/main";
import { DatabaseDetails } from "../ui/settingTabs"; import { DatabaseDetails } from "../ui/settingTabs";
import { updateYamlInfo } from "./updateYaml"; import { updateYamlInfo } from "./updateYaml";
import { UploadBase, NotionPageResponse } from "./common/UploadBase"; import { UploadBase, NotionPageResponse } from "./common/UploadBase";
import { AttachmentProcessor, applyBlockRewrites } from "./common/AttachmentProcessor";
export type DatasetType = "general" | "next" | "custom"; export type DatasetType = "general" | "next" | "custom";
@@ -128,7 +129,7 @@ export class Upload2Notion extends UploadBase {
cover: request.cover, cover: request.cover,
tags: request.tags, tags: request.tags,
}); });
const blocks = this.buildBlocks(request.markdown, { const blocks = await this.buildBlocks(request.markdown, request.nowFile, {
notionLimits: {truncate: false}, notionLimits: {truncate: false},
}); });
const notionId = this.getNotionId(request.app, request.nowFile); const notionId = this.getNotionId(request.app, request.nowFile);
@@ -153,7 +154,7 @@ export class Upload2Notion extends UploadBase {
slug: request.slug, slug: request.slug,
category: request.category, category: request.category,
}); });
const blocks = this.buildBlocks(request.markdown, { const blocks = await this.buildBlocks(request.markdown, request.nowFile, {
notionLimits: {truncate: false}, notionLimits: {truncate: false},
}); });
this.splitRichTextParagraphs(blocks); this.splitRichTextParagraphs(blocks);
@@ -187,7 +188,7 @@ export class Upload2Notion extends UploadBase {
console.log(`[Upload2Notion] Handling custom dataset`, { console.log(`[Upload2Notion] Handling custom dataset`, {
customKeys: Object.keys(request.customValues || {}), customKeys: Object.keys(request.customValues || {}),
}); });
const blocks = this.buildBlocks(request.markdown, { const blocks = await this.buildBlocks(request.markdown, request.nowFile, {
strictImageUrls: true, strictImageUrls: true,
notionLimits: {truncate: false}, notionLimits: {truncate: false},
}); });
@@ -206,10 +207,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 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); 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", { this.debugLog("Upload2Notion", "Converted markdown to blocks", {
blockCount: blocks.length, blockCount: blocks.length,
firstBlockTypes: blocks.slice(0, 5).map((block: any) => block?.type), 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 { DatabaseDetails } from "../../ui/settingTabs";
import { i18nConfig } from "../../lang/I18n"; import { i18nConfig } from "../../lang/I18n";
const NOTION_API_VERSION = "2025-09-03";
export interface NotionPageResponse { export interface NotionPageResponse {
response: any; response: any;
data: any; data: any;
@@ -30,7 +32,7 @@ export abstract class UploadBase {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: "Bearer " + notionAPI, Authorization: "Bearer " + notionAPI,
"Notion-Version": "2022-06-28", "Notion-Version": NOTION_API_VERSION,
}, },
body: "", body: "",
throw: false, throw: false,
@@ -116,7 +118,7 @@ export abstract class UploadBase {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: "Bearer " + notionAPI, Authorization: "Bearer " + notionAPI,
"Notion-Version": "2022-06-28", "Notion-Version": NOTION_API_VERSION,
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
throw: false, throw: false,
@@ -171,7 +173,7 @@ export abstract class UploadBase {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: "Bearer " + notionAPI, Authorization: "Bearer " + notionAPI,
"Notion-Version": "2022-06-28", "Notion-Version": NOTION_API_VERSION,
}, },
body: JSON.stringify(extraBlocks), body: JSON.stringify(extraBlocks),
throw: false, throw: false,
@@ -201,7 +203,7 @@ export abstract class UploadBase {
method: "GET", method: "GET",
headers: { headers: {
Authorization: "Bearer " + notionAPI, Authorization: "Bearer " + notionAPI,
"Notion-Version": "2022-06-28", "Notion-Version": NOTION_API_VERSION,
}, },
throw: false, throw: false,
}).catch((error) => }).catch((error) =>

View File

@@ -2,6 +2,7 @@ import { App, Notice, TFile } from "obsidian";
import ObsidianSyncNotionPlugin from "../main"; import ObsidianSyncNotionPlugin from "../main";
import { DatabaseDetails } from "../ui/settingTabs"; import { DatabaseDetails } from "../ui/settingTabs";
import { i18nConfig } from "src/lang/I18n"; import { i18nConfig } from "src/lang/I18n";
import { ensureAutoSyncDatabaseEntry } from "src/utils/frontmatter";
export async function updateYamlInfo( export async function updateYamlInfo(
yamlContent: string, yamlContent: string,
@@ -17,6 +18,7 @@ export async function updateYamlInfo(
const { abName } = dbDetails const { abName } = dbDetails
const notionIDKey = `NotionID-${abName}`; const notionIDKey = `NotionID-${abName}`;
const linkKey = `link-${abName}`; const linkKey = `link-${abName}`;
const autoSyncKey = plugin.getAutoSyncFrontmatterKey();
if (notionUser !== "") { if (notionUser !== "") {
// replace url str "www" to notionID // replace url str "www" to notionID
@@ -33,12 +35,21 @@ export async function updateYamlInfo(
// add new notionID and link // add new notionID and link
yamlContent[notionIDKey] = id; yamlContent[notionIDKey] = id;
(NotionLinkDisplay) ? yamlContent[linkKey] = url : null; (NotionLinkDisplay) ? yamlContent[linkKey] = url : null;
// ensure auto sync database list contains current short name
yamlContent[autoSyncKey] = ensureAutoSyncDatabaseEntry(
yamlContent[autoSyncKey],
abName
);
}); });
try { // copy url to clipboard only if autoCopyNotionLink is enabled
await navigator.clipboard.writeText(url) if (plugin.settings.autoCopyNotionLink) {
} catch (error) { try {
console.log(error) await navigator.clipboard.writeText(url);
new Notice(`${i18nConfig.CopyErrorMessage}`); } catch (error) {
console.log(error);
new Notice(`${i18nConfig.CopyErrorMessage}`);
}
} }
} }

63
src/utils/frontmatter.ts Normal file
View File

@@ -0,0 +1,63 @@
export const DEFAULT_AUTO_SYNC_DATABASE_KEY = "autosync-database";
export function resolveAutoSyncKey(rawKey: unknown): string {
if (typeof rawKey === "string") {
const trimmed = rawKey.trim();
if (trimmed.length > 0) {
return trimmed;
}
}
return DEFAULT_AUTO_SYNC_DATABASE_KEY;
}
function toCandidateList(value: unknown): string[] {
if (Array.isArray(value)) {
return value.map(item => String(item ?? "").trim());
}
if (typeof value === "string") {
if (value.includes(",")) {
return value.split(",").map(item => item.trim());
}
return [value.trim()];
}
return [];
}
export function parseAutoSyncDatabaseList(value: unknown): string[] {
const candidates = toCandidateList(value)
.map(name => name.replace(/^\[|\]$/g, "").trim()) // strip stray brackets
.filter(Boolean);
const seen = new Map<string, string>();
for (const name of candidates) {
const key = name.toLowerCase();
if (!seen.has(key)) {
seen.set(key, name);
}
}
return Array.from(seen.values());
}
export function ensureAutoSyncDatabaseEntry(value: unknown, abName: string): string[] {
const current = parseAutoSyncDatabaseList(value);
const lower = abName.toLowerCase();
let contains = false;
const updated = current.map(name => {
if (name.toLowerCase() === lower) {
contains = true;
return abName;
}
return name;
});
if (!contains) {
updated.push(abName);
}
return updated;
}

View File

@@ -1,4 +1,5 @@
{ {
"1.0.0": "0.9.7", "1.0.0": "0.9.7",
"1.0.1": "0.12.0" "1.0.1": "0.12.0",
"2.8.0": "0.0.1"
} }