Compare commits

..

24 Commits

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

View File

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

View File

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

View File

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

3
.gitignore vendored
View File

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

1
.npmrc
View File

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

View File

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

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

@@ -7,6 +7,35 @@ description: Release notes and updates for Obsidian to NotionNext
Welcome to the Changelog for Obsidian to NotionNext! Here you'll find a detailed list of all the updates, improvements, and bug fixes made to the plugin over time from the version `2.7.0` onwards. 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) ## v2.8.0-beta.2 (2025-11-05)
### Featured ### Featured

View File

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

View File

@@ -11,6 +11,73 @@ description: 如何使用 NotionNext 插件将你的 Obsidian 笔记同步到 No
要同步一篇笔记,只需打开你想要同步的笔记,然后从命令面板(`Ctrl/Cmd + P`)或笔记的右键菜单中选择 "Share to NotionNext" 命令。这会在你的 Notion 数据库中创建一个新页面,内容与你的 Obsidian 笔记完全一致。 要同步一篇笔记,只需打开你想要同步的笔记,然后从命令面板(`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

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

View File

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

View File

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

View File

@@ -1,19 +1,19 @@
{ {
"name": "share-to-notionnext", "name": "share-to-notionnext",
"version": "2.7.0", "version": "2.8.3",
"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",
"scripts": { "scripts": {
"dev": "node esbuild.config.mjs", "dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json" "version": "node version-bump.mjs && git add manifest.json versions.json CHANGELOG.md"
}, },
"keywords": [], "keywords": [],
"author": "Jiaxin PENG", "author": "Jiaxin PENG",
"license": "GNU GPLv3", "license": "GNU GPLv3",
"devDependencies": { "devDependencies": {
"@types/node": "^20.5.7", "@types/node": "^22.11.3",
"@types/yaml-front-matter": "^4.1.3", "@types/yaml-front-matter": "^4.1.3",
"@typescript-eslint/eslint-plugin": "^6.16.0", "@typescript-eslint/eslint-plugin": "^6.16.0",
"@typescript-eslint/parser": "^6.16.0", "@typescript-eslint/parser": "^6.16.0",

View File

@@ -44,6 +44,8 @@ export const en = {
AutoSyncDelay: "Auto Sync Delay (seconds)", AutoSyncDelay: "Auto Sync Delay (seconds)",
AutoSyncDelayDesc: "Delay in seconds to wait before syncing after a change. Prevents excessive syncs (default: 5s, min: 2s).", 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",
AutoSyncSuccessNotice: "Auto Sync Success Notice",
AutoSyncSuccessNoticeDesc: "Show a notification when auto-sync succeeds (default: off; failures are still notified).",
NotionGeneralSettingHeader: "General Notion Database Settings", NotionGeneralSettingHeader: "General Notion Database Settings",
NotionGeneralButton: "General Database Sync", NotionGeneralButton: "General Database Sync",
NotionGeneralButtonDesc: "Enables the 'Sync to General Database' command in the command palette (default: on).", NotionGeneralButtonDesc: "Enables the 'Sync to General Database' command in the command palette (default: on).",
@@ -91,6 +93,7 @@ export const en = {
SettingsMigrated: "✨ Settings updated! Auto-Sync is now available. Check the settings to learn more.", 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

@@ -42,6 +42,8 @@ export const ja = {
AutoSyncDelay: "自動同期の遅延(秒)", AutoSyncDelay: "自動同期の遅延(秒)",
AutoSyncDelayDesc: "変更が検知されてから同期を開始するまでの遅延時間。同期の頻発を防ぎますデフォルト5秒、最小2秒。", AutoSyncDelayDesc: "変更が検知されてから同期を開始するまでの遅延時間。同期の頻発を防ぎますデフォルト5秒、最小2秒。",
AutoSyncDelayText: "遅延秒数を入力", AutoSyncDelayText: "遅延秒数を入力",
AutoSyncSuccessNotice: "自動同期成功通知",
AutoSyncSuccessNoticeDesc: "自動同期が成功したときに通知を表示します(デフォルト:オフ。失敗時は通知されます)。",
NotionGeneralSettingHeader: "一般Notionデータベース設定", NotionGeneralSettingHeader: "一般Notionデータベース設定",
NotionGeneralButton: "一般データベース同期", NotionGeneralButton: "一般データベース同期",
NotionGeneralButtonDesc: "有効にすると、コマンドパレットに「一般データベースへ同期」が表示されます(デフォルト:オン)。", NotionGeneralButtonDesc: "有効にすると、コマンドパレットに「一般データベースへ同期」が表示されます(デフォルト:オン)。",
@@ -84,6 +86,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

@@ -44,6 +44,8 @@ export const zh = {
AutoSyncDelay: "自动同步延迟(秒)", AutoSyncDelay: "自动同步延迟(秒)",
AutoSyncDelayDesc: "文档修改后,等待指定秒数再触发自动同步,以避免频繁操作(默认 5 秒,最少 2 秒)", AutoSyncDelayDesc: "文档修改后,等待指定秒数再触发自动同步,以避免频繁操作(默认 5 秒,最少 2 秒)",
AutoSyncDelayText: "输入延迟秒数", AutoSyncDelayText: "输入延迟秒数",
AutoSyncSuccessNotice: "自动同步成功通知",
AutoSyncSuccessNoticeDesc: "是否在自动同步成功后弹出通知(默认关闭,仅在失败时通知)",
NotionGeneralSettingHeader: "通用 Notion 数据库设置", NotionGeneralSettingHeader: "通用 Notion 数据库设置",
NotionGeneralButton: "通用数据库同步", NotionGeneralButton: "通用数据库同步",
NotionGeneralButtonDesc: "启用后,命令面板中将显示“同步到通用数据库”命令(默认开启)", NotionGeneralButtonDesc: "启用后,命令面板中将显示“同步到通用数据库”命令(默认开启)",
@@ -87,6 +89,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();
} }
@@ -89,6 +92,9 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
if (typeof this.settings.autoSyncDelay !== 'number' || this.settings.autoSyncDelay < 2) { if (typeof this.settings.autoSyncDelay !== 'number' || this.settings.autoSyncDelay < 2) {
this.settings.autoSyncDelay = DEFAULT_SETTINGS.autoSyncDelay; this.settings.autoSyncDelay = DEFAULT_SETTINGS.autoSyncDelay;
} }
if (typeof this.settings.autoSyncSuccessNotice !== 'boolean') {
this.settings.autoSyncSuccessNotice = DEFAULT_SETTINGS.autoSyncSuccessNotice;
}
if (typeof this.settings.NotionLinkDisplay !== 'boolean') { if (typeof this.settings.NotionLinkDisplay !== 'boolean') {
this.settings.NotionLinkDisplay = DEFAULT_SETTINGS.NotionLinkDisplay; this.settings.NotionLinkDisplay = DEFAULT_SETTINGS.NotionLinkDisplay;
} }
@@ -109,6 +115,7 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
const needsSave = !loadedData || const needsSave = !loadedData ||
loadedData.autoSync === undefined || loadedData.autoSync === undefined ||
loadedData.autoSyncDelay === undefined || loadedData.autoSyncDelay === undefined ||
loadedData.autoSyncSuccessNotice === undefined ||
loadedData.NotionLinkDisplay === undefined || loadedData.NotionLinkDisplay === undefined ||
loadedData.autoCopyNotionLink === undefined || loadedData.autoCopyNotionLink === undefined ||
loadedData.autoSyncFrontmatterKey === undefined; loadedData.autoSyncFrontmatterKey === undefined;
@@ -120,6 +127,7 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
} else { } else {
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.autoSyncSuccessNotice === undefined) migratedFields.push('autoSyncSuccessNotice');
if (loadedData.NotionLinkDisplay === undefined) migratedFields.push('NotionLinkDisplay'); if (loadedData.NotionLinkDisplay === undefined) migratedFields.push('NotionLinkDisplay');
if (loadedData.autoCopyNotionLink === undefined) migratedFields.push('autoCopyNotionLink'); if (loadedData.autoCopyNotionLink === undefined) migratedFields.push('autoCopyNotionLink');
@@ -143,6 +151,7 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
console.log('[Settings] Settings saved successfully', { console.log('[Settings] Settings saved successfully', {
autoSync: this.settings.autoSync, autoSync: this.settings.autoSync,
autoSyncDelay: this.settings.autoSyncDelay, autoSyncDelay: this.settings.autoSyncDelay,
autoSyncSuccessNotice: this.settings.autoSyncSuccessNotice,
NotionLinkDisplay: this.settings.NotionLinkDisplay, NotionLinkDisplay: this.settings.NotionLinkDisplay,
autoSyncFrontmatterKey: this.settings.autoSyncFrontmatterKey, autoSyncFrontmatterKey: this.settings.autoSyncFrontmatterKey,
databaseCount: Object.keys(this.settings.databaseDetails || {}).length databaseCount: Object.keys(this.settings.databaseDetails || {}).length
@@ -159,6 +168,10 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
console.warn('[Settings] Invalid autoSyncDelay value, resetting to default'); console.warn('[Settings] Invalid autoSyncDelay value, resetting to default');
this.settings.autoSyncDelay = DEFAULT_SETTINGS.autoSyncDelay; this.settings.autoSyncDelay = DEFAULT_SETTINGS.autoSyncDelay;
} }
if (typeof this.settings.autoSyncSuccessNotice !== 'boolean') {
console.warn('[Settings] Invalid autoSyncSuccessNotice value, resetting to default');
this.settings.autoSyncSuccessNotice = DEFAULT_SETTINGS.autoSyncSuccessNotice;
}
if (typeof this.settings.NotionLinkDisplay !== 'boolean') { if (typeof this.settings.NotionLinkDisplay !== 'boolean') {
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;
@@ -315,7 +328,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 +351,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 +396,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));
@@ -430,11 +425,11 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
try { try {
// Trigger appropriate upload command based on database format // Trigger appropriate upload command based on database format
if (dbDetails.format === 'next') { if (dbDetails.format === 'next') {
await uploadCommandNext(this, this.settings, dbDetails, this.app); await uploadCommandNext(this, this.settings, dbDetails, this.app, { isAutoSync: true });
} else if (dbDetails.format === 'general') { } else if (dbDetails.format === 'general') {
await uploadCommandGeneral(this, this.settings, dbDetails, this.app); await uploadCommandGeneral(this, this.settings, dbDetails, this.app, { isAutoSync: true });
} else if (dbDetails.format === 'custom') { } else if (dbDetails.format === 'custom') {
await uploadCommandCustom(this, this.settings, dbDetails, this.app); await uploadCommandCustom(this, this.settings, dbDetails, this.app, { isAutoSync: true });
} }
} catch (error) { } catch (error) {
console.error(`[AutoSync] Error syncing to ${dbDetails.fullName}:`, error); console.error(`[AutoSync] Error syncing to ${dbDetails.fullName}:`, error);

View File

@@ -17,6 +17,7 @@ export interface PluginSettings {
autoCopyNotionLink: boolean; autoCopyNotionLink: boolean;
autoSync: boolean; autoSync: boolean;
autoSyncDelay: number; autoSyncDelay: number;
autoSyncSuccessNotice: boolean;
autoSyncFrontmatterKey: string; autoSyncFrontmatterKey: string;
proxy: string; proxy: string;
GeneralButton: boolean; GeneralButton: boolean;
@@ -57,6 +58,7 @@ export const DEFAULT_SETTINGS: PluginSettings = {
autoCopyNotionLink: true, autoCopyNotionLink: true,
autoSync: false, autoSync: false,
autoSyncDelay: 5, autoSyncDelay: 5,
autoSyncSuccessNotice: false,
autoSyncFrontmatterKey: DEFAULT_AUTO_SYNC_DATABASE_KEY, autoSyncFrontmatterKey: DEFAULT_AUTO_SYNC_DATABASE_KEY,
proxy: "", proxy: "",
GeneralButton: true, GeneralButton: true,
@@ -100,6 +102,15 @@ export class ObsidianSettingTab extends PluginSettingTab {
this.createSettingEl(containerEl, i18nConfig.AutoCopyNotionLink, i18nConfig.AutoCopyNotionLinkDesc, 'toggle', i18nConfig.AutoCopyNotionLink, this.plugin.settings.autoCopyNotionLink, 'autoCopyNotionLink') this.createSettingEl(containerEl, i18nConfig.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')
this.createSettingEl(
containerEl,
i18nConfig.AutoSyncSuccessNotice,
i18nConfig.AutoSyncSuccessNoticeDesc,
'toggle',
i18nConfig.AutoSyncSuccessNotice,
this.plugin.settings.autoSyncSuccessNotice,
'autoSyncSuccessNotice'
)
new Setting(containerEl) new Setting(containerEl)
.setName(i18nConfig.AutoSyncFrontmatterKey) .setName(i18nConfig.AutoSyncFrontmatterKey)
@@ -116,7 +127,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) =>
@@ -369,4 +380,3 @@ export class ObsidianSettingTab extends PluginSettingTab {
} }
} }
} }

View File

@@ -6,6 +6,7 @@ import MyPlugin from "src/main";
import { DatabaseDetails } from "../ui/settingTabs"; import { 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";
@@ -14,6 +15,7 @@ interface BaseSyncRequest {
markdown: string; markdown: string;
nowFile: TFile; nowFile: TFile;
app: App; app: App;
isAutoSync?: boolean;
} }
interface GeneralSyncRequest extends BaseSyncRequest { interface GeneralSyncRequest extends BaseSyncRequest {
@@ -90,6 +92,7 @@ export class Upload2Notion extends UploadBase {
async sync(request: SyncRequest): Promise<NotionPageResponse> { async sync(request: SyncRequest): Promise<NotionPageResponse> {
const startedAt = Date.now(); const startedAt = Date.now();
this.isAutoSync = !!request.isAutoSync;
let response: NotionPageResponse; let response: NotionPageResponse;
@@ -128,7 +131,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 +156,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 +190,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 +209,24 @@ export class Upload2Notion extends UploadBase {
}); });
} }
private buildBlocks(markdown: string, options: Record<string, unknown>): any[] { private async buildBlocks(markdown: string, nowFile: TFile, options: Record<string, unknown>): Promise<any[]> {
const yamlContent: any = yamlFrontMatter.loadFront(markdown); const 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;
@@ -16,12 +18,20 @@ interface PreparedBlocks {
export abstract class UploadBase { export abstract class UploadBase {
protected plugin: MyPlugin; protected plugin: MyPlugin;
protected dbDetails: DatabaseDetails; protected dbDetails: DatabaseDetails;
protected isAutoSync = false;
protected constructor(plugin: MyPlugin, dbDetails: DatabaseDetails) { protected constructor(plugin: MyPlugin, dbDetails: DatabaseDetails) {
this.plugin = plugin; this.plugin = plugin;
this.dbDetails = dbDetails; this.dbDetails = dbDetails;
} }
private shouldShowSuccessNotices(): boolean {
if (!this.isAutoSync) {
return true;
}
return !!this.plugin.settings.autoSyncSuccessNotice;
}
async deletePage(notionID: string) { async deletePage(notionID: string) {
const {notionAPI} = this.dbDetails; const {notionAPI} = this.dbDetails;
return requestUrl({ return requestUrl({
@@ -30,7 +40,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 +126,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 +181,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,
@@ -188,11 +198,13 @@ export abstract class UploadBase {
console.log(`${i18nConfig["ExtraBlockUploaded"]} to page: ${pageId}`); console.log(`${i18nConfig["ExtraBlockUploaded"]} to page: ${pageId}`);
if (i === extraChunks.length - 1) { if (i === extraChunks.length - 1) {
console.log(`${i18nConfig["BlockUploaded"]} to page: ${pageId}`); console.log(`${i18nConfig["BlockUploaded"]} to page: ${pageId}`);
if (this.shouldShowSuccessNotices()) {
new Notice(`${i18nConfig["BlockUploaded"]} page: ${pageId}`, 5000); new Notice(`${i18nConfig["BlockUploaded"]} page: ${pageId}`, 5000);
} }
} }
} }
} }
}
private async fetchDatabaseCover(): Promise<string | null> { private async fetchDatabaseCover(): Promise<string | null> {
const {notionAPI, databaseID} = this.dbDetails; const {notionAPI, databaseID} = this.dbDetails;
@@ -201,7 +213,7 @@ export abstract class UploadBase {
method: "GET", method: "GET",
headers: { headers: {
Authorization: "Bearer " + notionAPI, Authorization: "Bearer " + notionAPI,
"Notion-Version": "2022-06-28", "Notion-Version": NOTION_API_VERSION,
}, },
throw: false, throw: false,
}).catch((error) => }).catch((error) =>

View File

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

View File

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

View File

@@ -1,4 +1,8 @@
{ {
"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",
"2.8.1": "0.0.1",
"2.8.2": "0.0.1",
"2.8.3": "0.0.1"
} }