Compare commits

..

6 Commits

Author SHA1 Message Date
Jiaxin Peng
879cd66aed docs: Add attachment upload feature to README documentation 2026-01-04 19:11:49 +00:00
Jiaxin Peng
202385ac21 feat: Implement attachment processing and uploading for Notion integration
- Added AttachmentProcessor to handle local attachments in markdown content.
- Introduced AttachmentUploader for managing file uploads to Notion.
- Updated Upload2Notion to utilize new attachment processing and uploading logic.
- Enhanced buildBlocks method to process attachments and apply block rewrites.
- Updated Notion API version to 2025-09-03 for compatibility with new features.
2026-01-04 19:11:37 +00:00
Jiaxin Peng
9c6980d1c9 refactor: Simplify auto sync delay setting initialization 2026-01-04 19:11:23 +00:00
Jiaxin Peng
b61264a1f6 feat: Add message for skipped attachments during auto-sync in multiple languages 2026-01-04 19:11:07 +00:00
Jiaxin Peng
876b6233cb docs: Enhance attachment upload documentation with supported formats and auto sync details 2026-01-04 19:10:59 +00:00
Jiaxin Peng
ccfe40c1f0 fix: Refine release workflow tag patterns to exclude prerelease versions. 2025-12-10 23:18:03 +00:00
14 changed files with 1165 additions and 124 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -91,6 +91,7 @@ export const en = {
SettingsMigrated: "✨ Settings updated! Auto-Sync is now available. Check the settings to learn more.", SettingsMigrated: "✨ Settings updated! Auto-Sync is now available. Check the settings to learn more.",
AutoSyncNoNotionID: "🆕 Auto-sync: First upload to Notion", AutoSyncNoNotionID: "🆕 Auto-sync: First upload to Notion",
AutoSyncMissingDatabaseList: "⚠️ Auto-sync skipped: Add `{key}: [database_name]` to your frontmatter to specify target databases.", AutoSyncMissingDatabaseList: "⚠️ Auto-sync skipped: Add `{key}: [database_name]` to your frontmatter to specify target databases.",
AutoSyncSkippedAttachments: "⚠️ Auto-sync skipped: {filename} contains internal attachments (images/PDFs). Please sync manually.",
AutoSyncMultipleSync: "🔄 Auto-sync: Syncing to {count} database(s)...", AutoSyncMultipleSync: "🔄 Auto-sync: Syncing to {count} database(s)...",
AutoSyncFailed: "Auto-sync to {database} failed: {error}", AutoSyncFailed: "Auto-sync to {database} failed: {error}",
AutoSyncError: "Auto-sync for {filename} failed: {error}", AutoSyncError: "Auto-sync for {filename} failed: {error}",

View File

@@ -84,6 +84,7 @@ export const ja = {
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}",

View File

@@ -87,6 +87,7 @@ export const zh = {
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}",

View File

@@ -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,6 +19,7 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
private syncingFiles: Set<string> = new Set(); private syncingFiles: Set<string> = new Set();
private lastFrontmatterCache: Map<string, any> = new Map(); private lastFrontmatterCache: Map<string, any> = new Map();
private lastContentHashCache: Map<string, string> = new Map(); private lastContentHashCache: Map<string, string> = new Map();
private autoSyncAttachmentBlocked: Set<string> = new Set();
async onload() { async onload() {
@@ -69,6 +71,7 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
} }
this.lastFrontmatterCache.clear(); this.lastFrontmatterCache.clear();
this.lastContentHashCache.clear(); this.lastContentHashCache.clear();
this.autoSyncAttachmentBlocked.clear();
} }
@@ -315,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;
} }
@@ -331,50 +341,11 @@ 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 no autosync-database field specified
if (autoSyncTargets.length === 0) {
// Check if file has any existing NotionID (meaning it was synced before)
const hasExistingNotionID = Object.keys(frontMatter).some(key =>
key.startsWith('NotionID-') || key === 'NotionID'
);
if (hasExistingNotionID) {
// User likely forgot to add autosync-database - show a notice
const message = i18nConfig.AutoSyncMissingDatabaseList.replace('{key}', autoSyncKey);
new Notice(message, 8000);
console.log(`[AutoSync] File ${file.path} has NotionID but missing autosync-database - showing notice`);
}
// Silently skip files without NotionID (new files that don't need auto-sync)
return;
} }
@@ -415,6 +386,20 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
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));

View File

@@ -116,7 +116,7 @@ export class ObsidianSettingTab extends PluginSettingTab {
// 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) =>

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,8 @@ import MyPlugin from "src/main";
import { DatabaseDetails } from "../../ui/settingTabs"; import { DatabaseDetails } from "../../ui/settingTabs";
import { i18nConfig } from "../../lang/I18n"; import { i18nConfig } from "../../lang/I18n";
const NOTION_API_VERSION = "2025-09-03";
export interface NotionPageResponse { export interface NotionPageResponse {
response: any; response: any;
data: any; data: any;
@@ -30,7 +32,7 @@ export abstract class UploadBase {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: "Bearer " + notionAPI, Authorization: "Bearer " + notionAPI,
"Notion-Version": "2022-06-28", "Notion-Version": NOTION_API_VERSION,
}, },
body: "", body: "",
throw: false, throw: false,
@@ -116,7 +118,7 @@ export abstract class UploadBase {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: "Bearer " + notionAPI, Authorization: "Bearer " + notionAPI,
"Notion-Version": "2022-06-28", "Notion-Version": NOTION_API_VERSION,
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
throw: false, throw: false,
@@ -171,7 +173,7 @@ export abstract class UploadBase {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: "Bearer " + notionAPI, Authorization: "Bearer " + notionAPI,
"Notion-Version": "2022-06-28", "Notion-Version": NOTION_API_VERSION,
}, },
body: JSON.stringify(extraBlocks), body: JSON.stringify(extraBlocks),
throw: false, throw: false,
@@ -201,7 +203,7 @@ export abstract class UploadBase {
method: "GET", method: "GET",
headers: { headers: {
Authorization: "Bearer " + notionAPI, Authorization: "Bearer " + notionAPI,
"Notion-Version": "2022-06-28", "Notion-Version": NOTION_API_VERSION,
}, },
throw: false, throw: false,
}).catch((error) => }).catch((error) =>