mirror of
https://github.com/jxpeng98/obsidian-to-NotionNext
synced 2026-07-29 16:35:57 +08:00
Compare commits
18 Commits
v2.8.0-bet
...
v2.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a37bda1575 | ||
|
|
19d66917d2 | ||
|
|
a9acfbe956 | ||
|
|
879cd66aed | ||
|
|
202385ac21 | ||
|
|
9c6980d1c9 | ||
|
|
b61264a1f6 | ||
|
|
876b6233cb | ||
|
|
ccfe40c1f0 | ||
|
|
90dcc1aef0 | ||
|
|
7053a74e24 | ||
|
|
5144f10e77 | ||
|
|
5806a2831b | ||
|
|
eeaf7c036d | ||
|
|
3de92d3f54 | ||
|
|
e6b13e5eee | ||
|
|
f4def623bb | ||
|
|
3620505b56 |
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
@@ -5,8 +5,14 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
tags:
|
tags:
|
||||||
- "v*.*.*"
|
# Only trigger for release tags (e.g., v1.0.0, v2.3.4)
|
||||||
- "[0-9]+.[0-9]+.[0-9]+"
|
# 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
3
.gitignore
vendored
@@ -28,3 +28,6 @@ local-data
|
|||||||
# vitepress
|
# vitepress
|
||||||
docs/.vitepress/dist
|
docs/.vitepress/dist
|
||||||
docs/.vitepress/cache
|
docs/.vitepress/cache
|
||||||
|
|
||||||
|
# claude code
|
||||||
|
.claude
|
||||||
80
CHANGELOG.md
80
CHANGELOG.md
@@ -1,5 +1,84 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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)
|
## 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
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
- **多种数据库类型**:支持通用、NotionNext 和自定义数据库。
|
- **多种数据库类型**:支持通用、NotionNext 和自定义数据库。
|
||||||
- **自定义属性**:在自定义数据库中,可将任何 frontmatter 键映射到任何 Notion 属性。
|
- **自定义属性**:在自定义数据库中,可将任何 frontmatter 键映射到任何 Notion 属性。
|
||||||
- **灵活同步**:即时选择要同步到哪个数据库。
|
- **灵活同步**:即时选择要同步到哪个数据库。
|
||||||
|
- **附件上传**:自动上传本地图片和 PDF 到 Notion,支持 Wikilink、Markdown 链接格式。
|
||||||
|
|
||||||
## 致谢
|
## 致谢
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
[document.pdf](document.pdf)
|
||||||
|
[document.pdf](folder/document.pdf)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### TODO
|
||||||
|
|
||||||
|
- [ ] Obsidian URL Format
|
||||||
|
|
||||||
|
```markdown
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] App URL Format
|
||||||
|
|
||||||
|
```markdown
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|
### 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.
|
||||||
@@ -39,49 +106,39 @@ autosync-database: [blog, portfolio]
|
|||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
If you change the key name in the settings, update your frontmatter to match. The plugin will prompt you when the entry is missing.
|
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: Missing Auto Sync Entry
|
#### 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 that the configured auto sync key is missing
|
- ✅ Detects no NotionID present but `autosync-database` is configured
|
||||||
- ✅ Shows notice: `⚠️ Auto sync skipped: Add a "{key}" entry in the frontmatter to choose databases`
|
- ✅ 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:** Add the configured key (default `autosync-database`) to the frontmatter with the database abbreviations
|
- ✅ Shows success/failure notification
|
||||||
|
- 📝 **No Action Required:** The plugin handles the initial upload automatically
|
||||||
|
|
||||||
#### Scenario B: New Document (Not Yet Synced)
|
#### Scenario B: Synced to One Database
|
||||||
|
|
||||||
```yaml
|
|
||||||
---
|
|
||||||
title: My New Article
|
|
||||||
aytosync-database: [blog]
|
|
||||||
---
|
|
||||||
```
|
|
||||||
|
|
||||||
**Behavior:**
|
|
||||||
- ✅ Detects no NotionID present
|
|
||||||
- ✅ Shows notice: "⚠️ Auto sync skipped: This document has not been synced to Notion, please upload manually first"
|
|
||||||
- ✅ No sync operation performed
|
|
||||||
- 📝 **Action Required:** Manually sync the document first using the command palette
|
|
||||||
|
|
||||||
#### Scenario C: Synced to One Database
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
@@ -97,7 +154,7 @@ autosync-database: [blog]
|
|||||||
- ✅ Shows success/failure notification from the upload command
|
- ✅ Shows success/failure notification from the upload command
|
||||||
- 📝 **No Action Required:** Changes are automatically synced
|
- 📝 **No Action Required:** Changes are automatically synced
|
||||||
|
|
||||||
#### Scenario D: Synced to Multiple Databases
|
#### Scenario C: Synced to Multiple Databases
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
@@ -105,17 +162,18 @@ 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 E: Custom Frontmatter Key
|
#### Scenario D: Custom Frontmatter Key
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
@@ -132,7 +190,7 @@ NotionID-portfolio: def456
|
|||||||
- 📝 **Remember:** Update both the setting and your frontmatter if you rename the key
|
- 📝 **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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
[document.pdf](document.pdf)
|
||||||
|
[document.pdf](folder/document.pdf)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### TODO
|
||||||
|
|
||||||
|
- [ ] Obsidian URL 格式
|
||||||
|
|
||||||
|
```markdown
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] App URL 格式
|
||||||
|
|
||||||
|
```markdown
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|
### 附件上传工作原理
|
||||||
|
|
||||||
|
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)查看详细的同步日志
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,99 +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",
|
AutoSyncFrontmatterKey: "Auto Sync Frontmatter Key",
|
||||||
AutoSyncFrontmatterKeyDesc: "Set the frontmatter property name that lists which databases should auto sync (defaults to autosync-database).",
|
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 database,one 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",
|
||||||
AutoSyncMissingDatabaseList: "⚠️ Auto sync skipped: Add a \"{key}\" entry in the frontmatter to choose databases",
|
AutoSyncMissingDatabaseList: "⚠️ Auto-sync skipped: Add `{key}: [database_name]` to your frontmatter to specify target databases.",
|
||||||
AutoSyncMultipleSync: "🔄 Auto sync: Syncing to {count} database(s)...",
|
AutoSyncSkippedAttachments: "⚠️ Auto-sync skipped: {filename} contains internal attachments (images/PDFs). Please sync manually.",
|
||||||
AutoSyncFailed: "Auto sync to {database} failed: {error}",
|
AutoSyncMultipleSync: "🔄 Auto-sync: Syncing to {count} database(s)...",
|
||||||
AutoSyncError: "Auto sync failed for {filename}: {error}",
|
AutoSyncFailed: "Auto-sync to {database} failed: {error}",
|
||||||
"reach-mobile-limit": "The number of blocks exceeds the limit of 100, please use the desktop plugin",
|
AutoSyncError: "Auto-sync for {filename} failed: {error}",
|
||||||
StartUpload: "Start upload {filename}",
|
"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",
|
||||||
@@ -101,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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,91 +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リンクが追加されません。",
|
||||||
AutoSync: "自動同期",
|
AutoCopyNotionLink: "Notionリンクを自動コピー",
|
||||||
AutoSyncDesc: "frontmatter またはコンテンツが変更されたときに自動的に Notion に同期します(NotionID が必要)",
|
AutoCopyNotionLinkDesc: "同期完了後、Notionページのリンクをクリップボードに自動コピーします(デフォルト:オン)。",
|
||||||
AutoSyncFrontmatterKey: "自動同期 frontmatter キー",
|
AutoSync: "自動同期",
|
||||||
AutoSyncFrontmatterKeyDesc: "自動同期するデータベースを列挙する frontmatter のプロパティ名を設定します(デフォルトは autosync-database)。",
|
AutoSyncDesc: "ファイルの内容(frontmatterまたは本文)が変更されると、自動でNotionに同期します。新規作成と更新の両方に対応。",
|
||||||
AutoSyncDelay: "自動同期遅延時間(秒)",
|
AutoSyncFrontmatterKey: "自動同期 frontmatter キー",
|
||||||
AutoSyncDelayDesc: "ドキュメントの変更後、自動同期をトリガーするまでの待機時間(デフォルト:5秒、最小:2秒)",
|
AutoSyncFrontmatterKeyDesc: "自動同期の対象となるデータベースをリストアップするための frontmatterキーを設定します(デフォルト:autosync-database)。",
|
||||||
AutoSyncDelayText: "遅延秒数を入力",
|
AutoSyncDelay: "自動同期の遅延(秒)",
|
||||||
NotionGeneralSettingHeader: "一般的なNotionデータベース設定",
|
AutoSyncDelayDesc: "変更が検知されてから同期を開始するまでの遅延時間(秒)。同期の頻発を防ぎます(デフォルト:5秒、最小:2秒)。",
|
||||||
NotionGeneralButton: "一般的なNotion同期",
|
AutoSyncDelayText: "遅延秒数を入力",
|
||||||
NotionGeneralButtonDesc: "このオプションを開くと、一般的なNotionデータベース同期コマンドがコマンドパレットに表示されます(デフォルト:ON)",
|
NotionGeneralSettingHeader: "一般Notionデータベース設定",
|
||||||
NotionTagButton: "Notionタグ同期",
|
NotionGeneralButton: "一般データベース同期",
|
||||||
NotionTagButtonDesc: "タグを一般的なNotionデータベースに同期(デフォルト:ON)",
|
NotionGeneralButtonDesc: "有効にすると、コマンドパレットに「一般データベースへ同期」が表示されます(デフォルト:オン)。",
|
||||||
NotionCustomTitle: "タイトルのカスタマイズ",
|
NotionTagButton: "タグを同期",
|
||||||
NotionCustomTitleDesc: "Notionデータベースの列名を変更(デフォルト:OFF)",
|
NotionTagButtonDesc: "ObsidianのタグをNotionデータベースに同期します(デフォルト:オン)。",
|
||||||
NotionCustomTitleName: "希望のタイトル名",
|
NotionCustomTitle: "タイトルプロパティをカスタム",
|
||||||
NotionCustomTitleNameDesc: "Notionデータベースの最初の列のための希望のタイトル名を入力(デフォルト:title)",
|
NotionCustomTitleDesc: "Notionデータベースのタイトル列の名前をカスタマイズします(デフォルト:オフ)。",
|
||||||
NotionCustomTitleText: "名前を入力",
|
NotionCustomTitleName: "カスタムタイトル名",
|
||||||
NotionCustomValues: "値のカスタマイズ",
|
NotionCustomTitleNameDesc: "Notionデータベースのタイトル列に使用するカスタム名を入力してください(デフォルト:「title」)。",
|
||||||
NotionCustomValuesDesc: "Notionデータベースの列名を変更、1行に1つ",
|
NotionCustomTitleText: "プロパティ名を入力",
|
||||||
NotionCustomValuesText: "同期したいすべてのプロパティを入力",
|
NotionCustomValues: "カスタムプロパティ",
|
||||||
NotYetFinish: "未完了。この機能は次のバージョンで利用可能になります",
|
NotionCustomValuesDesc: "Notionデータベースに同期するカスタムプロパティを1行に1つずつ定義します。",
|
||||||
PlaceHolder: "データベース名を入力",
|
NotionCustomValuesText: "同期したいすべてのプロパティを入力",
|
||||||
"notion-logo": "NotionNextで共有",
|
NotYetFinish: "この機能は将来のバージョンで利用可能になります。",
|
||||||
"sync-success": "NotionNextへの同期に成功:\n",
|
PlaceHolder: "データベース名を入力",
|
||||||
"sync-fail": "NotionNextへの同期に失敗:\n",
|
"notion-logo": "NotionNextへ同期",
|
||||||
"open-notion": "同期が必要なファイルを開いてください",
|
"sync-preffix": "📄",
|
||||||
"config-secrets-notion-api": "設定タブでNotion APIを設定してください",
|
"sync-success": "NotionNextへの同期が成功しました。\n",
|
||||||
"config-secrets-database-id": "設定タブでデータベースIDを設定してください",
|
"sync-fail": "NotionNextへの同期に失敗しました。\n",
|
||||||
"set-tags-fail": "タグの設定に失敗。ファイルのfrontmatterを確認するか、設定タブでタグのスイッチをオフにしてください",
|
"open-notion": "同期するファイルを先に開いてください。",
|
||||||
NNonMissing: "設定に 'NNon' プロパティがありません。設定してください",
|
"config-secrets-notion-api": "プラグイン設定でNotion APIキーを設定してください。",
|
||||||
"set-api-id": "設定タブでNotion APIおよびデータベースIDを設定してください",
|
"config-secrets-database-id": "プラグイン設定でデータベースIDを設定してください。",
|
||||||
NotionCustomSettingHeader: "Notionカスタムデータベース設定",
|
"set-tags-fail": "タグの設定に失敗しました。frontmatterを確認するか、設定でタグ同期を無効にしてください。",
|
||||||
NotionCustomButton: "Notionカスタマイズコマンドの切り替え",
|
NNonMissing: "'NNon'プロパティが設定されていません。設定でNotionNextデータベースを選択してください。",
|
||||||
NotionCustomButtonDesc: "このオプションを開くと、Notionカスタムデータベース同期コマンドがコマンドパレットに表示されます",
|
"set-api-id": "プラグイン設定でNotion APIキーとデータベースIDを設定してください。",
|
||||||
CustomPropertyName: "カスタムプロパティ名",
|
NotionCustomSettingHeader: "Notionカスタムデータベース設定",
|
||||||
CustomPropertyFirstColumn: "最初の列のカスタムプロパティ名",
|
NotionCustomButton: "カスタムデータベースコマンドを有効化",
|
||||||
CustomPropertyFirstColumnDesc: "最初の列のカスタムプロパティ名を入力してください",
|
NotionCustomButtonDesc: "有効にすると、「カスタムデータベースへ同期」コマンドがコマンドパレットに表示されます。",
|
||||||
CustomProperty: "カスタムプロパティ",
|
CustomPropertyName: "プロパティ名",
|
||||||
|
CustomPropertyFirstColumn: "タイトルプロパティ名",
|
||||||
|
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へ初めてアップロードします",
|
||||||
AutoSyncMissingDatabaseList: "⚠️ 自動同期をスキップ:frontmatter に \"{key}\" 項目を追加してください",
|
AutoSyncMissingDatabaseList: "⚠️ 自動同期をスキップ:frontmatterに `{key}: [データベース名]` を追加して同期先を指定してください。",
|
||||||
AutoSyncMultipleSync: "🔄 自動同期:{count} 個のデータベースに同期中...",
|
AutoSyncSkippedAttachments: "⚠️ 自動同期をスキップ:{filename} に内部添付(画像/PDF)が含まれています。手動で同期してください。",
|
||||||
AutoSyncFailed: "{database} への自動同期に失敗しました:{error}",
|
AutoSyncMultipleSync: "🔄 自動同期:{count}個のデータベースに同期しています...",
|
||||||
AutoSyncError: "{filename} の自動同期に失敗しました:{error}",
|
AutoSyncFailed: "{database}への自動同期に失敗しました:{error}",
|
||||||
"reach-mobile-limit": "ブロック数が100の制限を超えています。デスクトップ版プラグインを使用してください",
|
AutoSyncError: "{filename}の自動同期に失敗しました:{error}",
|
||||||
StartUpload: "アップロード開始 {filename}",
|
"reach-mobile-limit": "ブロック上限(100)に達しました。ブロック数に制限のないデスクトップ版をご利用ください。",
|
||||||
|
StartUpload: "{filename} のアップロードを開始します...",
|
||||||
AddNewDatabase: "新しいデータベースを追加",
|
AddNewDatabase: "新しいデータベースを追加",
|
||||||
AddNewDatabaseDesc: "新しいデータベース構成を追加",
|
AddNewDatabaseDesc: "新しいデータベース構成を追加",
|
||||||
AddNewDatabaseTooltip: "新しいデータベースを追加",
|
AddNewDatabaseTooltip: "新しいデータベースを追加",
|
||||||
@@ -98,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: "新しいデータベースを追加",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,108 +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(支持新建和更新)",
|
||||||
AutoSyncFrontmatterKey: "自动同步 Frontmatter 键名",
|
AutoSyncFrontmatterKey: "自动同步 Frontmatter 键名",
|
||||||
AutoSyncFrontmatterKeyDesc: "设置用于列出自动同步数据库的 frontmatter 属性名称(默认 autosync-database)。",
|
AutoSyncFrontmatterKeyDesc: "设置用于指定自动同步数据库列表的 frontmatter 键名(默认为 autosync-database)。",
|
||||||
AutoSyncDelay: "自动同步延迟时间(秒)",
|
AutoSyncDelay: "自动同步延迟(秒)",
|
||||||
AutoSyncDelayDesc: "文档修改后等待多少秒才触发自动同步,避免频繁同步(默认:5秒,最小:2秒)",
|
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}\" 项来指定数据库",
|
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: "添加新数据库",
|
||||||
}
|
}
|
||||||
|
|||||||
98
src/main.ts
98
src/main.ts
@@ -4,6 +4,7 @@ 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";
|
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!
|
||||||
@@ -18,7 +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 missingAutoSyncNoticeShown: Set<string> = new Set();
|
private autoSyncAttachmentBlocked: Set<string> = new Set();
|
||||||
|
|
||||||
|
|
||||||
async onload() {
|
async onload() {
|
||||||
await this.loadSettings();
|
await this.loadSettings();
|
||||||
@@ -69,7 +71,8 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
this.lastFrontmatterCache.clear();
|
this.lastFrontmatterCache.clear();
|
||||||
this.lastContentHashCache.clear();
|
this.lastContentHashCache.clear();
|
||||||
this.missingAutoSyncNoticeShown.clear();
|
this.autoSyncAttachmentBlocked.clear();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadSettings() {
|
async loadSettings() {
|
||||||
@@ -92,6 +95,9 @@ 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') {
|
if (typeof this.settings.autoSyncFrontmatterKey !== 'string') {
|
||||||
this.settings.autoSyncFrontmatterKey = DEFAULT_AUTO_SYNC_DATABASE_KEY;
|
this.settings.autoSyncFrontmatterKey = DEFAULT_AUTO_SYNC_DATABASE_KEY;
|
||||||
}
|
}
|
||||||
@@ -107,6 +113,7 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
|
|||||||
loadedData.autoSync === undefined ||
|
loadedData.autoSync === undefined ||
|
||||||
loadedData.autoSyncDelay === undefined ||
|
loadedData.autoSyncDelay === undefined ||
|
||||||
loadedData.NotionLinkDisplay === undefined ||
|
loadedData.NotionLinkDisplay === undefined ||
|
||||||
|
loadedData.autoCopyNotionLink === undefined ||
|
||||||
loadedData.autoSyncFrontmatterKey === undefined;
|
loadedData.autoSyncFrontmatterKey === undefined;
|
||||||
|
|
||||||
if (needsSave) {
|
if (needsSave) {
|
||||||
@@ -117,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(', '));
|
||||||
}
|
}
|
||||||
@@ -158,6 +166,10 @@ 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 = {};
|
||||||
@@ -170,9 +182,7 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resetAutoSyncNoticeCache() {
|
|
||||||
this.missingAutoSyncNoticeShown.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
getAutoSyncFrontmatterKey(): string {
|
getAutoSyncFrontmatterKey(): string {
|
||||||
return resolveAutoSyncKey(this.settings.autoSyncFrontmatterKey);
|
return resolveAutoSyncKey(this.settings.autoSyncFrontmatterKey);
|
||||||
@@ -308,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,46 +341,14 @@ 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`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const autoSyncKey = this.getAutoSyncFrontmatterKey();
|
|
||||||
const autoSyncTargets = parseAutoSyncDatabaseList(frontMatter[autoSyncKey]);
|
|
||||||
|
|
||||||
if (autoSyncTargets.length === 0) {
|
|
||||||
if (!this.missingAutoSyncNoticeShown.has(file.path)) {
|
|
||||||
const message = i18nConfig.AutoSyncMissingDatabaseList.replace('{key}', autoSyncKey);
|
|
||||||
new Notice(message, 10000);
|
|
||||||
this.missingAutoSyncNoticeShown.add(file.path);
|
|
||||||
}
|
|
||||||
console.log(`[AutoSync] No auto sync database list found in ${file.path} using key "${autoSyncKey}"`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.missingAutoSyncNoticeShown.delete(file.path);
|
|
||||||
|
|
||||||
const dbByShortName = new Map<string, DatabaseDetails>();
|
const dbByShortName = new Map<string, DatabaseDetails>();
|
||||||
for (const key in this.settings.databaseDetails) {
|
for (const key in this.settings.databaseDetails) {
|
||||||
@@ -371,7 +356,7 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
|
|||||||
dbByShortName.set(dbDetails.abName.toLowerCase(), dbDetails);
|
dbByShortName.set(dbDetails.abName.toLowerCase(), dbDetails);
|
||||||
}
|
}
|
||||||
|
|
||||||
const foundDatabases: Array<{ dbDetails: DatabaseDetails, notionId: string }> = [];
|
const foundDatabases: Array<{ dbDetails: DatabaseDetails, notionId: string | undefined }> = [];
|
||||||
const unresolvedTargets: string[] = [];
|
const unresolvedTargets: string[] = [];
|
||||||
|
|
||||||
for (const target of autoSyncTargets) {
|
for (const target of autoSyncTargets) {
|
||||||
@@ -384,35 +369,48 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const notionIDKey = `NotionID-${dbDetails.abName}`;
|
const notionIDKey = `NotionID-${dbDetails.abName}`;
|
||||||
if (frontMatter[notionIDKey]) {
|
// Include database even if no NotionID exists (for first-time upload)
|
||||||
foundDatabases.push({
|
foundDatabases.push({
|
||||||
dbDetails,
|
dbDetails,
|
||||||
notionId: String(frontMatter[notionIDKey])
|
notionId: frontMatter[notionIDKey] ? String(frontMatter[notionIDKey]) : undefined
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (unresolvedTargets.length > 0) {
|
if (unresolvedTargets.length > 0) {
|
||||||
console.log(`[AutoSync] Frontmatter auto sync targets not found in settings: ${unresolvedTargets.join(", ")}`);
|
console.log(`[AutoSync] Frontmatter auto sync targets not found in settings: ${unresolvedTargets.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no NotionID found, notify user to upload manually first
|
// 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
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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;
|
autoSyncFrontmatterKey: string;
|
||||||
@@ -53,6 +54,7 @@ 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,
|
autoSyncFrontmatterKey: DEFAULT_AUTO_SYNC_DATABASE_KEY,
|
||||||
@@ -95,6 +97,8 @@ 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)
|
new Setting(containerEl)
|
||||||
@@ -107,13 +111,12 @@ export class ObsidianSettingTab extends PluginSettingTab {
|
|||||||
.onChange(async (value) => {
|
.onChange(async (value) => {
|
||||||
this.plugin.settings.autoSyncFrontmatterKey = value;
|
this.plugin.settings.autoSyncFrontmatterKey = value;
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
this.plugin.resetAutoSyncNoticeCache();
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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) =>
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
697
src/upload/common/AttachmentProcessor.ts
Normal file
697
src/upload/common/AttachmentProcessor.ts
Normal 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: 
|
||||||
|
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: 
|
||||||
|
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 = ``;
|
||||||
|
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 = ``;
|
||||||
|
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 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
256
src/upload/common/AttachmentUploader.ts
Normal file
256
src/upload/common/AttachmentUploader.ts
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -43,10 +43,13 @@ export async function updateYamlInfo(
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user