mirror of
https://github.com/jxpeng98/obsidian-to-NotionNext
synced 2026-07-29 08:08:34 +08:00
Compare commits
26 Commits
v2.8.0-bet
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2200b68af | ||
|
|
95b22619b4 | ||
|
|
b08c46c233 | ||
|
|
8dfb82b4ce | ||
|
|
8502650033 | ||
|
|
30e34e3f8c | ||
|
|
ec3d3e85a2 | ||
|
|
fe04e8df7f | ||
|
|
d36deb7b30 | ||
|
|
e8295746e1 | ||
|
|
83df1111c4 | ||
|
|
6e8be42e3c | ||
|
|
6b02cb219e | ||
|
|
cd7eb78378 | ||
|
|
3e00f127e9 | ||
|
|
4fb3b99996 | ||
|
|
269d354734 | ||
|
|
a37bda1575 | ||
|
|
19d66917d2 | ||
|
|
a9acfbe956 | ||
|
|
879cd66aed | ||
|
|
202385ac21 | ||
|
|
9c6980d1c9 | ||
|
|
b61264a1f6 | ||
|
|
876b6233cb | ||
|
|
ccfe40c1f0 |
2
.github/workflows/prerelease.yml
vendored
2
.github/workflows/prerelease.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "18"
|
||||
node-version: "22"
|
||||
|
||||
- name: Build
|
||||
id: build
|
||||
|
||||
67
.github/workflows/release.yml
vendored
67
.github/workflows/release.yml
vendored
@@ -5,8 +5,14 @@ on:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
- "[0-9]+.[0-9]+.[0-9]+"
|
||||
# Only trigger for release tags (e.g., 1.0.0, 2.3.4)
|
||||
# Excludes prerelease tags (e.g., 2.8.0-beta.3, 1.0.0-rc.1)
|
||||
- "[0-9]+.[0-9]+.[0-9]"
|
||||
- "[0-9]+.[0-9]+.[0-9][0-9]"
|
||||
- "[0-9]+.[0-9]+.[0-9][0-9][0-9]"
|
||||
- "[0-9]+.[0-9][0-9].[0-9]"
|
||||
- "[0-9]+.[0-9][0-9].[0-9][0-9]"
|
||||
- "[0-9]+.[0-9][0-9].[0-9][0-9][0-9]"
|
||||
|
||||
env:
|
||||
PLUGIN_NAME: share-to-notionnext # Change this to match the id of your plugin.
|
||||
@@ -21,7 +27,7 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "18"
|
||||
node-version: "22"
|
||||
|
||||
# - name: Generate changelog
|
||||
# id: changelog
|
||||
@@ -42,21 +48,67 @@ jobs:
|
||||
ls
|
||||
echo "tag_name=$(git tag --sort version:refname | tail -n 1)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate release notes
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
run: |
|
||||
node - <<'NODE'
|
||||
const fs = require('fs');
|
||||
|
||||
const tag = process.env.GITHUB_REF_NAME;
|
||||
if (!tag) {
|
||||
throw new Error('GITHUB_REF_NAME is not set');
|
||||
}
|
||||
|
||||
const changelog = fs.readFileSync('CHANGELOG.md', 'utf8');
|
||||
const lines = changelog.split(/\r?\n/);
|
||||
|
||||
const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const headerRe = new RegExp(`^##\\s+${escapeRegex(tag)}\\b`);
|
||||
|
||||
const start = lines.findIndex((line) => headerRe.test(line));
|
||||
if (start === -1) {
|
||||
throw new Error(`Could not find changelog section for tag "${tag}"`);
|
||||
}
|
||||
|
||||
let end = lines.length;
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
if (/^##\s+/.test(lines[i])) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let section = lines.slice(start, end);
|
||||
while (section.length > 0 && section[section.length - 1].trim() === '') {
|
||||
section.pop();
|
||||
}
|
||||
if (section.length > 0 && section[section.length - 1].trim() === '---') {
|
||||
section.pop();
|
||||
while (section.length > 0 && section[section.length - 1].trim() === '') {
|
||||
section.pop();
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync('release-notes.md', `${section.join('\n')}\n`);
|
||||
NODE
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.REPO_ACCESS_TOKEN }}
|
||||
VERSION: ${{ github.ref }}
|
||||
with:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: ${{ github.ref }}
|
||||
body_path: ${{ env.CHANGELOG_FILENAME }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
release_name: ${{ github.ref_name }}
|
||||
body_path: release-notes.md
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
- name: Upload zip file
|
||||
id: upload-zip
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.REPO_ACCESS_TOKEN }}
|
||||
@@ -68,6 +120,7 @@ jobs:
|
||||
|
||||
- name: Upload main.js
|
||||
id: upload-main
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.REPO_ACCESS_TOKEN }}
|
||||
@@ -79,6 +132,7 @@ jobs:
|
||||
|
||||
- name: Upload manifest.json
|
||||
id: upload-manifest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.REPO_ACCESS_TOKEN }}
|
||||
@@ -90,6 +144,7 @@ jobs:
|
||||
|
||||
- name: Upload markdown template
|
||||
id: upload-md
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.REPO_ACCESS_TOKEN }}
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "18"
|
||||
node-version: "22"
|
||||
|
||||
- name: Build
|
||||
id: build
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -28,3 +28,6 @@ local-data
|
||||
# vitepress
|
||||
docs/.vitepress/dist
|
||||
docs/.vitepress/cache
|
||||
|
||||
# claude code
|
||||
.claude
|
||||
80
CHANGELOG.md
80
CHANGELOG.md
@@ -1,5 +1,85 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
## 2.8.4 (2026-03-04)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed version bump with `tag-version-prefix="v"` in `.npmrc` causing incorrect version format in `package.json` (e.g. `v2.8.1` instead of `2.8.1`)
|
||||
|
||||
## 2.8.1 (2026-03-04)
|
||||
|
||||
### Added
|
||||
|
||||
- **Auto-sync success notice setting**: Toggle whether to show success notifications for auto-sync (defaults to off)
|
||||
|
||||
### Changed
|
||||
|
||||
- Auto-sync is quieter by default: success and "start upload" notices are suppressed unless explicitly enabled
|
||||
|
||||
### Fixed
|
||||
|
||||
- Auto-sync no longer shows "All blocks has been uploaded" (`BlockUploaded`) notice when success notices are disabled
|
||||
|
||||
## 2.8.0 (2026-01-29)
|
||||
|
||||
### Added
|
||||
|
||||
- **Auto Sync**: Automatically sync notes on content or frontmatter changes, with configurable delay and multi-database support
|
||||
- **Attachment Upload**: Upload local images and PDFs to Notion via the File Upload API and insert them as `image`/`file` blocks
|
||||
- **Auto-copy Notion Link**: Option to copy the Notion page link to clipboard after syncing
|
||||
- **Auto-sync frontmatter key**: Customize the frontmatter key for auto-sync database lists (default: `autosync-database`)
|
||||
- Comprehensive i18n support for UI and notifications
|
||||
- Prerelease workflow for beta testing via GitHub Actions and BRAT
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved auto-sync behavior and notices for files without `autosync-database` or missing NotionID
|
||||
- Limited attachment link parsing to **Wikilinks** and **standard Markdown links** (Obsidian/App URL formats are now TODO/disabled)
|
||||
- Standardized Notion API request header `Notion-Version` to `2025-09-03`
|
||||
- Reduced per-file upload limit to **5MB** to maximize compatibility across Notion plans
|
||||
- Enhanced settings tab and documentation for auto-sync usage
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed mobile compatibility issues by using `window.setTimeout` instead of `NodeJS.Timeout`
|
||||
- Prevented sync loops and improved change detection for frontmatter/body updates
|
||||
- File placeholder tokens no longer break due to Markdown underscore parsing
|
||||
- Better block ordering when attachments are on standalone lines in Markdown
|
||||
- Preserve image captions when converting `external` images to `file_upload`
|
||||
- Avoid duplicate filename display on uploaded `file` blocks
|
||||
- Fixed `undefined` appearing in sync success notification by adding missing `sync-preffix` i18n key
|
||||
|
||||
---
|
||||
|
||||
## v2.8.0-beta.4 (2026-01-04)
|
||||
|
||||
### Added
|
||||
|
||||
- **Attachment Upload**: Upload local images and PDFs to Notion via the File Upload API and insert them as `image`/`file` blocks
|
||||
- **Auto-sync safeguard**: Auto-sync is skipped for notes containing internal attachments (manual sync required)
|
||||
|
||||
### Changed
|
||||
|
||||
- Limited attachment link parsing to **Wikilinks** and **standard Markdown links** (Obsidian/App URL formats are now TODO/disabled)
|
||||
- Standardized Notion API request header `Notion-Version` to `2025-09-03`
|
||||
- Reduced per-file upload limit to **5MB** to maximize compatibility across Notion plans
|
||||
|
||||
### Fixed
|
||||
|
||||
- File placeholder tokens no longer break due to Markdown underscore parsing
|
||||
- Better block ordering when attachments are on standalone lines in Markdown
|
||||
- Preserve image captions when converting `external` images to `file_upload`
|
||||
- Avoid duplicate filename display on uploaded `file` blocks
|
||||
|
||||
---
|
||||
|
||||
## v2.8.0-beta.3 (2025-12-10)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
- **多种数据库类型**:支持通用、NotionNext 和自定义数据库。
|
||||
- **自定义属性**:在自定义数据库中,可将任何 frontmatter 键映射到任何 Notion 属性。
|
||||
- **灵活同步**:即时选择要同步到哪个数据库。
|
||||
- **附件上传**:自动上传本地图片和 PDF 到 Notion,支持 Wikilink、Markdown 链接格式。
|
||||
|
||||
## 致谢
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ Share files from Obsidian to any Notion database using the Notion API. This plug
|
||||
- **Multiple Database Types**: Supports General, NotionNext, and Custom databases.
|
||||
- **Custom Properties**: Map any frontmatter key to any Notion property in custom databases.
|
||||
- **Flexible Syncing**: Choose which database to sync to on-the-fly.
|
||||
- **Attachment Upload**: Automatically uploads local images and PDFs to Notion, supporting Wikilinks and Markdown links.
|
||||
|
||||
## Acknowledgment
|
||||
|
||||
|
||||
@@ -11,6 +11,73 @@ After configuring your Notion database in the plugin settings, you can start syn
|
||||
|
||||
To sync a note, open the note you want to sync and use the "Share to NotionNext" command from the command palette or the note context menu. This will create a new page in your Notion database with the content of your Obsidian note.
|
||||
|
||||
## Attachment Upload
|
||||
|
||||
The plugin automatically detects and uploads local attachments (images, PDFs, etc.) from your notes to Notion.
|
||||
|
||||
### Supported Attachment Formats
|
||||
|
||||
**Image formats:**
|
||||
- PNG, JPG, JPEG, GIF, WebP, SVG, HEIC, TIF, TIFF, BMP
|
||||
|
||||
**Other formats:**
|
||||
- PDF
|
||||
|
||||
### Supported Link Formats
|
||||
|
||||
Currently supported:
|
||||
|
||||
#### Wikilink Format (Recommended)
|
||||
|
||||
```markdown
|
||||
![[image.png]]
|
||||
![[folder/image.png]]
|
||||
![[image.png|alt text]]
|
||||
|
||||
[[document.pdf]]
|
||||
[[folder/document.pdf]]
|
||||
```
|
||||
|
||||
#### Standard Markdown Format
|
||||
|
||||
```markdown
|
||||

|
||||

|
||||

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

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

|
||||
```
|
||||
|
||||
### How Attachment Upload Works
|
||||
|
||||
1. **Auto Detection**: During sync, the plugin scans your note content and identifies all local attachment references
|
||||
2. **Upload to Notion**: Detected attachments are uploaded via the Notion File Upload API
|
||||
3. **Link Replacement**: After successful upload, local links are replaced with Notion file references
|
||||
4. **Image Display**: Images are displayed as Notion image blocks
|
||||
5. **File Embedding**: Non-image files like PDFs are embedded as file blocks
|
||||
|
||||
### Notes
|
||||
|
||||
- Attachment references inside code blocks are not processed
|
||||
- External URLs (`http://` or `https://`) are not processed
|
||||
- Single file size limit is 5MB
|
||||
- Ensure attachment files exist in your Vault
|
||||
|
||||
## Auto Sync
|
||||
|
||||
The plugin supports automatic syncing that monitors your notes for changes and automatically syncs them to Notion.
|
||||
@@ -45,46 +112,17 @@ If you change the key name in the settings, update your frontmatter to match.
|
||||
|
||||
When auto sync is enabled:
|
||||
- The plugin monitors markdown files for changes
|
||||
- **Only files with the auto sync key in frontmatter will be processed**
|
||||
- Files without the auto sync key are silently skipped - no sync operations or notices
|
||||
- Files containing internal attachments (local images/PDFs) are skipped - sync them manually
|
||||
- After you stop editing for the configured delay period, auto sync is triggered
|
||||
- Files with the `autosync-database` key in frontmatter will be automatically synced
|
||||
- **First-time upload is supported**: No need to manually sync first - just add the frontmatter key and the plugin will handle the initial upload
|
||||
- If a file is linked to multiple databases, it will sync to all of them automatically
|
||||
- After the first sync, a `NotionID-{database}` will be added to the frontmatter for future updates
|
||||
|
||||
### Auto Sync Scenarios
|
||||
|
||||
#### Scenario A-1: New File Missing Auto Sync Entry
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: My New Article
|
||||
tags: [blog, tech]
|
||||
---
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- ✅ Detects that the auto sync key is missing
|
||||
- ✅ Detects no NotionID present (new file)
|
||||
- ✅ Silently skips - no notice shown
|
||||
- 📝 **To enable auto sync:** Add `autosync-database: [your-db-abbreviation]` to the frontmatter
|
||||
|
||||
#### Scenario A-2: Synced File Missing Auto Sync Entry
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: My Article
|
||||
NotionID-blog: abc123
|
||||
---
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- ✅ Detects that the auto sync key is missing
|
||||
- ✅ Detects existing NotionID (file was synced before)
|
||||
- ✅ Shows notice: "⚠️ Auto-sync skipped: Add autosync-database to your frontmatter to specify target databases"
|
||||
- ✅ No sync operation performed
|
||||
- 📝 **Action Required:** Add `autosync-database: [blog]` to the frontmatter
|
||||
|
||||
#### Scenario B: New Document (First-Time Auto Upload)
|
||||
#### Scenario A: New Document (First-Time Auto Upload)
|
||||
|
||||
```yaml
|
||||
---
|
||||
@@ -100,7 +138,7 @@ autosync-database: [blog]
|
||||
- ✅ Shows success/failure notification
|
||||
- 📝 **No Action Required:** The plugin handles the initial upload automatically
|
||||
|
||||
#### Scenario C: Synced to One Database
|
||||
#### Scenario B: Synced to One Database
|
||||
|
||||
```yaml
|
||||
---
|
||||
@@ -116,7 +154,7 @@ autosync-database: [blog]
|
||||
- ✅ Shows success/failure notification from the upload command
|
||||
- 📝 **No Action Required:** Changes are automatically synced
|
||||
|
||||
#### Scenario D: Synced to Multiple Databases
|
||||
#### Scenario C: Synced to Multiple Databases
|
||||
|
||||
```yaml
|
||||
---
|
||||
@@ -135,7 +173,7 @@ autosync-database: [blog, portfolio, notes]
|
||||
- ✅ Shows individual result notifications for each database
|
||||
- 📝 **No Action Required:** Changes are automatically synced to all linked databases
|
||||
|
||||
#### Scenario E: Custom Frontmatter Key
|
||||
#### Scenario D: Custom Frontmatter Key
|
||||
|
||||
```yaml
|
||||
---
|
||||
|
||||
@@ -7,6 +7,35 @@ description: Release notes and updates for Obsidian to NotionNext
|
||||
|
||||
Welcome to the Changelog for Obsidian to NotionNext! Here you'll find a detailed list of all the updates, improvements, and bug fixes made to the plugin over time from the version `2.7.0` onwards.
|
||||
|
||||
## v2.8.0 (2026-01-29)
|
||||
|
||||
### Added
|
||||
|
||||
- **Auto Sync**: Automatically sync notes on content or frontmatter changes, with configurable delay and multi-database support
|
||||
- **Attachment Upload**: Upload local images and PDFs to Notion via the File Upload API and insert them as `image`/`file` blocks
|
||||
- **Auto-copy Notion Link**: Option to copy the Notion page link to clipboard after syncing
|
||||
- **Auto-sync frontmatter key**: Customize the frontmatter key for auto-sync database lists (default: `autosync-database`)
|
||||
- Comprehensive i18n support for UI and notifications
|
||||
- Prerelease workflow for beta testing via GitHub Actions and BRAT
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved auto-sync behavior and notices for files without `autosync-database` or missing NotionID
|
||||
- Limited attachment link parsing to **Wikilinks** and **standard Markdown links** (Obsidian/App URL formats are now TODO/disabled)
|
||||
- Standardized Notion API request header `Notion-Version` to `2025-09-03`
|
||||
- Reduced per-file upload limit to **5MB** to maximize compatibility across Notion plans
|
||||
- Enhanced settings tab and documentation for auto-sync usage
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed mobile compatibility issues by using `window.setTimeout` instead of `NodeJS.Timeout`
|
||||
- Prevented sync loops and improved change detection for frontmatter/body updates
|
||||
- File placeholder tokens no longer break due to Markdown underscore parsing
|
||||
- Better block ordering when attachments are on standalone lines in Markdown
|
||||
- Preserve image captions when converting `external` images to `file_upload`
|
||||
- Avoid duplicate filename display on uploaded `file` blocks
|
||||
- Fixed `undefined` appearing in sync success notification by adding missing `sync-preffix` i18n key
|
||||
|
||||
## v2.8.0-beta.2 (2025-11-05)
|
||||
|
||||
### Featured
|
||||
|
||||
@@ -5,6 +5,6 @@ description: Planned features and improvements for Obsidian to NotionNext
|
||||
|
||||
# Roadmap
|
||||
|
||||
- [ ] Automatic Syncing: Implement automatic syncing of notes at regular intervals or upon changes.
|
||||
- [ ] File Attachments: Support for syncing file attachments from Obsidian to Notion.
|
||||
- [x] Automatic Syncing: Implement automatic syncing of notes at regular intervals or upon changes.
|
||||
- [x] File Attachments: Support for syncing file attachments from Obsidian to Notion.
|
||||
- [ ] Relation Property: Enable mapping of Obsidian links to Notion relation properties.
|
||||
|
||||
@@ -11,6 +11,73 @@ description: 如何使用 NotionNext 插件将你的 Obsidian 笔记同步到 No
|
||||
|
||||
要同步一篇笔记,只需打开你想要同步的笔记,然后从命令面板(`Ctrl/Cmd + P`)或笔记的右键菜单中选择 "Share to NotionNext" 命令。这会在你的 Notion 数据库中创建一个新页面,内容与你的 Obsidian 笔记完全一致。
|
||||
|
||||
## 附件上传
|
||||
|
||||
插件支持自动检测并上传笔记中的本地附件(图片、PDF 等)到 Notion。
|
||||
|
||||
### 支持的附件格式
|
||||
|
||||
**图片格式:**
|
||||
- PNG, JPG, JPEG, GIF, WebP, SVG, HEIC, TIF, TIFF, BMP
|
||||
|
||||
**其他格式:**
|
||||
- PDF
|
||||
|
||||
### 支持的链接格式
|
||||
|
||||
当前支持:
|
||||
|
||||
#### Wikilink 格式(推荐)
|
||||
|
||||
```markdown
|
||||
![[image.png]]
|
||||
![[folder/image.png]]
|
||||
![[image.png|alt text]]
|
||||
|
||||
[[document.pdf]]
|
||||
[[folder/document.pdf]]
|
||||
```
|
||||
|
||||
#### 标准 Markdown 格式
|
||||
|
||||
```markdown
|
||||

|
||||

|
||||

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

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

|
||||
```
|
||||
|
||||
### 附件上传工作原理
|
||||
|
||||
1. **自动检测**:同步时,插件会自动扫描笔记内容,识别所有本地附件引用
|
||||
2. **上传到 Notion**:检测到的附件会通过 Notion File Upload API 上传
|
||||
3. **链接替换**:上传成功后,笔记中的本地链接会被替换为 Notion 的文件引用
|
||||
4. **图片显示**:图片会作为 Notion 的图片块显示
|
||||
5. **文件嵌入**:PDF 等非图片文件会作为文件块嵌入
|
||||
|
||||
### 注意事项
|
||||
|
||||
- 代码块中的附件引用不会被处理
|
||||
- 外部 URL(`http://` 或 `https://`)不会被处理
|
||||
- 单个文件大小限制为 5MB
|
||||
- 确保附件文件存在于 Vault 中
|
||||
|
||||
## 自动同步
|
||||
|
||||
插件支持自动同步功能,可以监控你的笔记变化并自动同步到 Notion。
|
||||
@@ -46,6 +113,9 @@ autosync-database: [blog, portfolio]
|
||||
当自动同步启用后:
|
||||
|
||||
- 插件会监控 Markdown 文件的变化
|
||||
- **只有 frontmatter 中包含自动同步配置键的文件才会被处理**
|
||||
- 没有配置自动同步键的文件会被静默跳过,不会触发任何同步操作或提示
|
||||
- 含有本地附件(图片/PDF)的文件会跳过自动同步,请手动同步
|
||||
- 在你停止编辑达到配置的延迟时间后,自动触发同步
|
||||
- **支持首次自动上传**:无需先手动同步,只要添加 frontmatter 键名,插件会自动处理首次上传
|
||||
- 如果文件关联了多个数据库,会自动同步到所有数据库
|
||||
@@ -53,40 +123,7 @@ autosync-database: [blog, portfolio]
|
||||
|
||||
### 自动同步场景示例
|
||||
|
||||
#### 场景 A-1:新文件缺少自动同步配置
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: 我的新文章
|
||||
tags: [博客, 技术]
|
||||
---
|
||||
```
|
||||
|
||||
**行为:**
|
||||
|
||||
- ✅ 检测到缺少自动同步配置键
|
||||
- ✅ 检测到没有 NotionID(新文件)
|
||||
- ✅ 静默跳过,不显示任何提示
|
||||
- 📝 **如需启用自动同步:** 在 frontmatter 中添加 `autosync-database: [你的数据库简称]`
|
||||
|
||||
#### 场景 A-2:已同步文件缺少自动同步配置
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: 我的文章
|
||||
NotionID-blog: abc123
|
||||
---
|
||||
```
|
||||
|
||||
**行为:**
|
||||
|
||||
- ✅ 检测到缺少自动同步配置键
|
||||
- ✅ 检测到已存在 NotionID(说明之前同步过)
|
||||
- ✅ 显示提示:"⚠️ 自动同步已跳过:请在 frontmatter 中添加 autosync-database 以指定目标数据库"
|
||||
- ✅ 不执行同步操作
|
||||
- 📝 **需要操作:** 在 frontmatter 中添加 `autosync-database: [blog]`
|
||||
|
||||
#### 场景 B:新文档(首次自动上传)
|
||||
#### 场景 A:新文档(首次自动上传)
|
||||
|
||||
```yaml
|
||||
---
|
||||
@@ -103,7 +140,7 @@ autosync-database: [blog]
|
||||
- ✅ 显示成功/失败通知
|
||||
- 📝 **无需操作:** 插件会自动处理首次上传
|
||||
|
||||
#### 场景 C:已同步到一个数据库(更新)
|
||||
#### 场景 B:已同步到一个数据库(更新)
|
||||
|
||||
```yaml
|
||||
---
|
||||
@@ -120,7 +157,7 @@ autosync-database: [blog]
|
||||
- ✅ 显示上传命令返回的成功/失败通知
|
||||
- 📝 **无需操作:** 变更会自动同步
|
||||
|
||||
#### 场景 D:同步到多个数据库
|
||||
#### 场景 C:同步到多个数据库
|
||||
|
||||
```yaml
|
||||
---
|
||||
|
||||
@@ -4,3 +4,32 @@ description: Obsidian to NotionNext 的版本更新与变更记录
|
||||
---
|
||||
|
||||
欢迎来到 Obsidian to NotionNext 的更新日志!这里会记录自 `2.7.0` 版本起的所有更新、改进以及问题修复,方便你快速了解插件的演进情况。
|
||||
|
||||
## v2.8.0 (2026-01-29)
|
||||
|
||||
### 新增
|
||||
|
||||
- **自动同步**:当内容或 frontmatter 变化时自动同步,支持可配置延迟与多数据库
|
||||
- **附件上传**:通过 File Upload API 上传本地图片与 PDF,并插入为 `image`/`file` 块
|
||||
- **自动复制 Notion 链接**:同步后自动复制页面链接到剪贴板
|
||||
- **自动同步 frontmatter key**:可自定义自动同步数据库列表的 frontmatter key(默认 `autosync-database`)
|
||||
- 完整的 UI 与通知 i18n 支持
|
||||
- 通过 GitHub Actions + BRAT 的预发布测试流程
|
||||
|
||||
### 变更
|
||||
|
||||
- 优化自动同步行为与提示:无 `autosync-database` 或缺少 NotionID 的文件处理更智能
|
||||
- 限制附件链接解析为 **Wikilinks** 与 **标准 Markdown 链接**(Obsidian/App URL 暂停/待办)
|
||||
- 统一 Notion API 请求头 `Notion-Version` 为 `2025-09-03`
|
||||
- 单文件上传限制降为 **5MB**,提升兼容性
|
||||
- 强化设置面板与自动同步相关文档
|
||||
|
||||
### 修复
|
||||
|
||||
- 移动端兼容性修复:使用 `window.setTimeout` 替代 `NodeJS.Timeout`
|
||||
- 防止同步循环,改进 frontmatter/正文变更检测
|
||||
- 修复 Markdown 下划线导致的占位符解析问题
|
||||
- 修复附件独占行时的块顺序
|
||||
- 保留 `external` 图片转为 `file_upload` 时的图片标题
|
||||
- 避免上传 `file` 块时重复显示文件名
|
||||
- 修复同步成功通知出现 `undefined`(补充 `sync-preffix` i18n key)
|
||||
|
||||
@@ -3,6 +3,6 @@ title: 路线图
|
||||
description: Obsidian to NotionNext 的规划功能与未来改进
|
||||
---
|
||||
|
||||
- [ ] 自动同步:支持按时间间隔或内容变更自动同步笔记。
|
||||
- [ ] 附件支持:允许将 Obsidian 笔记中的附件一同上传到 Notion。
|
||||
- [x] 自动同步:支持按时间间隔或内容变更自动同步笔记。
|
||||
- [x] 附件支持:允许将 Obsidian 笔记中的附件一同上传到 Notion。
|
||||
- [ ] 关联属性:实现将 Obsidian 链接映射到 Notion 的 relation 属性。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "share-to-notionnext",
|
||||
"name": "Share to NotionNext",
|
||||
"version": "2.7.0",
|
||||
"version": "2.8.4",
|
||||
"minAppVersion": "0.0.1",
|
||||
"description": "Shares obsidian md file to notion with notion api for NotionNext web deploy, originally created by EasyChris/obsidian-to-notion.",
|
||||
"author": "EasyChris, jxpeng98",
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "share-to-notionnext",
|
||||
"version": "2.7.0",
|
||||
"version": "2.8.4",
|
||||
"type": "module",
|
||||
"description": "Share files to any Notion database using the Notion API, originally created by EasyChris/obsidian-to-notion.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json CHANGELOG.md"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Jiaxin PENG",
|
||||
"license": "GNU GPLv3",
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.5.7",
|
||||
"@types/node": "^22.11.3",
|
||||
"@types/yaml-front-matter": "^4.1.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.16.0",
|
||||
"@typescript-eslint/parser": "^6.16.0",
|
||||
|
||||
@@ -44,6 +44,8 @@ export const en = {
|
||||
AutoSyncDelay: "Auto Sync Delay (seconds)",
|
||||
AutoSyncDelayDesc: "Delay in seconds to wait before syncing after a change. Prevents excessive syncs (default: 5s, min: 2s).",
|
||||
AutoSyncDelayText: "Enter delay in seconds",
|
||||
AutoSyncSuccessNotice: "Auto Sync Success Notice",
|
||||
AutoSyncSuccessNoticeDesc: "Show a notification when auto-sync succeeds (default: off; failures are still notified).",
|
||||
NotionGeneralSettingHeader: "General Notion Database Settings",
|
||||
NotionGeneralButton: "General Database Sync",
|
||||
NotionGeneralButtonDesc: "Enables the 'Sync to General Database' command in the command palette (default: on).",
|
||||
@@ -91,6 +93,7 @@ export const en = {
|
||||
SettingsMigrated: "✨ Settings updated! Auto-Sync is now available. Check the settings to learn more.",
|
||||
AutoSyncNoNotionID: "🆕 Auto-sync: First upload to Notion",
|
||||
AutoSyncMissingDatabaseList: "⚠️ Auto-sync skipped: Add `{key}: [database_name]` to your frontmatter to specify target databases.",
|
||||
AutoSyncSkippedAttachments: "⚠️ Auto-sync skipped: {filename} contains internal attachments (images/PDFs). Please sync manually.",
|
||||
AutoSyncMultipleSync: "🔄 Auto-sync: Syncing to {count} database(s)...",
|
||||
AutoSyncFailed: "Auto-sync to {database} failed: {error}",
|
||||
AutoSyncError: "Auto-sync for {filename} failed: {error}",
|
||||
|
||||
@@ -42,6 +42,8 @@ export const ja = {
|
||||
AutoSyncDelay: "自動同期の遅延(秒)",
|
||||
AutoSyncDelayDesc: "変更が検知されてから同期を開始するまでの遅延時間(秒)。同期の頻発を防ぎます(デフォルト:5秒、最小:2秒)。",
|
||||
AutoSyncDelayText: "遅延秒数を入力",
|
||||
AutoSyncSuccessNotice: "自動同期成功通知",
|
||||
AutoSyncSuccessNoticeDesc: "自動同期が成功したときに通知を表示します(デフォルト:オフ。失敗時は通知されます)。",
|
||||
NotionGeneralSettingHeader: "一般Notionデータベース設定",
|
||||
NotionGeneralButton: "一般データベース同期",
|
||||
NotionGeneralButtonDesc: "有効にすると、コマンドパレットに「一般データベースへ同期」が表示されます(デフォルト:オン)。",
|
||||
@@ -84,6 +86,7 @@ export const ja = {
|
||||
SettingsMigrated: "✨ 設定が更新されました!自動同期が利用可能です。詳細は設定画面をご確認ください。",
|
||||
AutoSyncNoNotionID: "🆕 自動同期:Notionへ初めてアップロードします",
|
||||
AutoSyncMissingDatabaseList: "⚠️ 自動同期をスキップ:frontmatterに `{key}: [データベース名]` を追加して同期先を指定してください。",
|
||||
AutoSyncSkippedAttachments: "⚠️ 自動同期をスキップ:{filename} に内部添付(画像/PDF)が含まれています。手動で同期してください。",
|
||||
AutoSyncMultipleSync: "🔄 自動同期:{count}個のデータベースに同期しています...",
|
||||
AutoSyncFailed: "{database}への自動同期に失敗しました:{error}",
|
||||
AutoSyncError: "{filename}の自動同期に失敗しました:{error}",
|
||||
|
||||
@@ -44,6 +44,8 @@ export const zh = {
|
||||
AutoSyncDelay: "自动同步延迟(秒)",
|
||||
AutoSyncDelayDesc: "文档修改后,等待指定秒数再触发自动同步,以避免频繁操作(默认 5 秒,最少 2 秒)",
|
||||
AutoSyncDelayText: "输入延迟秒数",
|
||||
AutoSyncSuccessNotice: "自动同步成功通知",
|
||||
AutoSyncSuccessNoticeDesc: "是否在自动同步成功后弹出通知(默认关闭,仅在失败时通知)",
|
||||
NotionGeneralSettingHeader: "通用 Notion 数据库设置",
|
||||
NotionGeneralButton: "通用数据库同步",
|
||||
NotionGeneralButtonDesc: "启用后,命令面板中将显示“同步到通用数据库”命令(默认开启)",
|
||||
@@ -87,6 +89,7 @@ export const zh = {
|
||||
SettingsMigrated: "✨ 插件设置已更新!新增自动同步功能,详情请查看设置。",
|
||||
AutoSyncNoNotionID: "🆕 自动同步:首次上传到 Notion",
|
||||
AutoSyncMissingDatabaseList: "⚠️ 自动同步已跳过:请在 frontmatter 中添加 \"{key}\" 以指定目标数据库。",
|
||||
AutoSyncSkippedAttachments: "⚠️ 自动同步已跳过:检测到 {filename} 含有本地附件(图片/PDF),请手动同步。",
|
||||
AutoSyncMultipleSync: "🔄 自动同步:正在同步到 {count} 个数据库...",
|
||||
AutoSyncFailed: "同步到 {database} 失败:{error}",
|
||||
AutoSyncError: "同步 {filename} 失败:{error}",
|
||||
|
||||
81
src/main.ts
81
src/main.ts
@@ -4,6 +4,7 @@ import { i18nConfig } from "src/lang/I18n";
|
||||
import ribbonCommands from "src/commands/NotionCommands";
|
||||
import { ObsidianSettingTab, PluginSettings, DEFAULT_SETTINGS, DatabaseDetails } from "src/ui/settingTabs";
|
||||
import { uploadCommandNext, uploadCommandGeneral, uploadCommandCustom } from "src/upload/uploadCommand";
|
||||
import { AttachmentProcessor } from "src/upload/common/AttachmentProcessor";
|
||||
import { DEFAULT_AUTO_SYNC_DATABASE_KEY, parseAutoSyncDatabaseList, resolveAutoSyncKey } from "src/utils/frontmatter";
|
||||
|
||||
// Remember to rename these classes and interfaces!
|
||||
@@ -18,6 +19,7 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
|
||||
private syncingFiles: Set<string> = new Set();
|
||||
private lastFrontmatterCache: Map<string, any> = new Map();
|
||||
private lastContentHashCache: Map<string, string> = new Map();
|
||||
private autoSyncAttachmentBlocked: Set<string> = new Set();
|
||||
|
||||
|
||||
async onload() {
|
||||
@@ -69,6 +71,7 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
|
||||
}
|
||||
this.lastFrontmatterCache.clear();
|
||||
this.lastContentHashCache.clear();
|
||||
this.autoSyncAttachmentBlocked.clear();
|
||||
|
||||
}
|
||||
|
||||
@@ -89,6 +92,9 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
|
||||
if (typeof this.settings.autoSyncDelay !== 'number' || this.settings.autoSyncDelay < 2) {
|
||||
this.settings.autoSyncDelay = DEFAULT_SETTINGS.autoSyncDelay;
|
||||
}
|
||||
if (typeof this.settings.autoSyncSuccessNotice !== 'boolean') {
|
||||
this.settings.autoSyncSuccessNotice = DEFAULT_SETTINGS.autoSyncSuccessNotice;
|
||||
}
|
||||
if (typeof this.settings.NotionLinkDisplay !== 'boolean') {
|
||||
this.settings.NotionLinkDisplay = DEFAULT_SETTINGS.NotionLinkDisplay;
|
||||
}
|
||||
@@ -109,6 +115,7 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
|
||||
const needsSave = !loadedData ||
|
||||
loadedData.autoSync === undefined ||
|
||||
loadedData.autoSyncDelay === undefined ||
|
||||
loadedData.autoSyncSuccessNotice === undefined ||
|
||||
loadedData.NotionLinkDisplay === undefined ||
|
||||
loadedData.autoCopyNotionLink === undefined ||
|
||||
loadedData.autoSyncFrontmatterKey === undefined;
|
||||
@@ -120,6 +127,7 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
|
||||
} else {
|
||||
if (loadedData.autoSync === undefined) migratedFields.push('autoSync');
|
||||
if (loadedData.autoSyncDelay === undefined) migratedFields.push('autoSyncDelay');
|
||||
if (loadedData.autoSyncSuccessNotice === undefined) migratedFields.push('autoSyncSuccessNotice');
|
||||
if (loadedData.NotionLinkDisplay === undefined) migratedFields.push('NotionLinkDisplay');
|
||||
if (loadedData.autoCopyNotionLink === undefined) migratedFields.push('autoCopyNotionLink');
|
||||
|
||||
@@ -143,6 +151,7 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
|
||||
console.log('[Settings] Settings saved successfully', {
|
||||
autoSync: this.settings.autoSync,
|
||||
autoSyncDelay: this.settings.autoSyncDelay,
|
||||
autoSyncSuccessNotice: this.settings.autoSyncSuccessNotice,
|
||||
NotionLinkDisplay: this.settings.NotionLinkDisplay,
|
||||
autoSyncFrontmatterKey: this.settings.autoSyncFrontmatterKey,
|
||||
databaseCount: Object.keys(this.settings.databaseDetails || {}).length
|
||||
@@ -159,6 +168,10 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
|
||||
console.warn('[Settings] Invalid autoSyncDelay value, resetting to default');
|
||||
this.settings.autoSyncDelay = DEFAULT_SETTINGS.autoSyncDelay;
|
||||
}
|
||||
if (typeof this.settings.autoSyncSuccessNotice !== 'boolean') {
|
||||
console.warn('[Settings] Invalid autoSyncSuccessNotice value, resetting to default');
|
||||
this.settings.autoSyncSuccessNotice = DEFAULT_SETTINGS.autoSyncSuccessNotice;
|
||||
}
|
||||
if (typeof this.settings.NotionLinkDisplay !== 'boolean') {
|
||||
console.warn('[Settings] Invalid NotionLinkDisplay value, resetting to default');
|
||||
this.settings.NotionLinkDisplay = DEFAULT_SETTINGS.NotionLinkDisplay;
|
||||
@@ -315,7 +328,14 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
|
||||
// Get file's frontmatter
|
||||
const frontMatter = this.app.metadataCache.getFileCache(file)?.frontmatter;
|
||||
if (!frontMatter) {
|
||||
console.log(`[AutoSync] No frontmatter found in ${file.path}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check autosync property first - only proceed if it exists
|
||||
const autoSyncKey = this.getAutoSyncFrontmatterKey();
|
||||
const autoSyncTargets = parseAutoSyncDatabaseList(frontMatter[autoSyncKey]);
|
||||
|
||||
if (autoSyncTargets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -331,50 +351,11 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
|
||||
const frontmatterOnlyNotionIDChanged = this.onlyNotionIDChanged(lastFrontmatter, frontMatter);
|
||||
const contentUnchanged = contentHash === lastContentHash;
|
||||
|
||||
console.log(`[AutoSync] Change analysis for ${file.basename}:`, {
|
||||
frontmatterOnlyNotionIDChanged,
|
||||
contentUnchanged,
|
||||
frontmatterHasRealChanges: !frontmatterOnlyNotionIDChanged,
|
||||
contentChanged: !contentUnchanged,
|
||||
willSync: !(frontmatterOnlyNotionIDChanged && contentUnchanged)
|
||||
});
|
||||
|
||||
// Only skip sync if BOTH conditions are true:
|
||||
// 1. Frontmatter only has NotionID changes (no real user changes)
|
||||
// 2. Content is completely unchanged
|
||||
if (frontmatterOnlyNotionIDChanged && contentUnchanged) {
|
||||
console.log(`[AutoSync] Only NotionID updated (from sync), content unchanged - skipping auto sync`);
|
||||
// Update cache even when skipping, so next comparison uses the current state
|
||||
this.lastFrontmatterCache.set(file.path, { ...frontMatter });
|
||||
this.lastContentHashCache.set(file.path, contentHash);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!contentUnchanged) {
|
||||
console.log(`[AutoSync] Content changed - will sync`);
|
||||
} else if (!frontmatterOnlyNotionIDChanged) {
|
||||
console.log(`[AutoSync] Frontmatter changed - will sync`);
|
||||
}
|
||||
}
|
||||
|
||||
const autoSyncKey = this.getAutoSyncFrontmatterKey();
|
||||
const autoSyncTargets = parseAutoSyncDatabaseList(frontMatter[autoSyncKey]);
|
||||
|
||||
// If no autosync-database field specified
|
||||
if (autoSyncTargets.length === 0) {
|
||||
// Check if file has any existing NotionID (meaning it was synced before)
|
||||
const hasExistingNotionID = Object.keys(frontMatter).some(key =>
|
||||
key.startsWith('NotionID-') || key === 'NotionID'
|
||||
);
|
||||
|
||||
if (hasExistingNotionID) {
|
||||
// User likely forgot to add autosync-database - show a notice
|
||||
const message = i18nConfig.AutoSyncMissingDatabaseList.replace('{key}', autoSyncKey);
|
||||
new Notice(message, 8000);
|
||||
console.log(`[AutoSync] File ${file.path} has NotionID but missing autosync-database - showing notice`);
|
||||
}
|
||||
// Silently skip files without NotionID (new files that don't need auto-sync)
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -415,6 +396,20 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
|
||||
return;
|
||||
}
|
||||
|
||||
// Temporarily disable auto-sync for files containing internal attachments (local images/PDFs)
|
||||
const attachmentProcessor = new AttachmentProcessor(this, foundDatabases[0].dbDetails);
|
||||
if (attachmentProcessor.hasInternalAttachments(content, file)) {
|
||||
if (!this.autoSyncAttachmentBlocked.has(file.path)) {
|
||||
const message = i18nConfig.AutoSyncSkippedAttachments
|
||||
.replace('{filename}', file.basename);
|
||||
new Notice(message, 6000);
|
||||
this.autoSyncAttachmentBlocked.add(file.path);
|
||||
}
|
||||
console.log(`[AutoSync] Internal attachments detected in ${file.path}, auto-sync skipped`);
|
||||
return;
|
||||
}
|
||||
this.autoSyncAttachmentBlocked.delete(file.path);
|
||||
|
||||
// Notify user about multiple syncs if applicable
|
||||
if (foundDatabases.length > 1) {
|
||||
const message = i18nConfig.AutoSyncMultipleSync.replace('{count}', String(foundDatabases.length));
|
||||
@@ -430,11 +425,11 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
|
||||
try {
|
||||
// Trigger appropriate upload command based on database format
|
||||
if (dbDetails.format === 'next') {
|
||||
await uploadCommandNext(this, this.settings, dbDetails, this.app);
|
||||
await uploadCommandNext(this, this.settings, dbDetails, this.app, { isAutoSync: true });
|
||||
} else if (dbDetails.format === 'general') {
|
||||
await uploadCommandGeneral(this, this.settings, dbDetails, this.app);
|
||||
await uploadCommandGeneral(this, this.settings, dbDetails, this.app, { isAutoSync: true });
|
||||
} else if (dbDetails.format === 'custom') {
|
||||
await uploadCommandCustom(this, this.settings, dbDetails, this.app);
|
||||
await uploadCommandCustom(this, this.settings, dbDetails, this.app, { isAutoSync: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[AutoSync] Error syncing to ${dbDetails.fullName}:`, error);
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface PluginSettings {
|
||||
autoCopyNotionLink: boolean;
|
||||
autoSync: boolean;
|
||||
autoSyncDelay: number;
|
||||
autoSyncSuccessNotice: boolean;
|
||||
autoSyncFrontmatterKey: string;
|
||||
proxy: string;
|
||||
GeneralButton: boolean;
|
||||
@@ -57,6 +58,7 @@ export const DEFAULT_SETTINGS: PluginSettings = {
|
||||
autoCopyNotionLink: true,
|
||||
autoSync: false,
|
||||
autoSyncDelay: 5,
|
||||
autoSyncSuccessNotice: false,
|
||||
autoSyncFrontmatterKey: DEFAULT_AUTO_SYNC_DATABASE_KEY,
|
||||
proxy: "",
|
||||
GeneralButton: true,
|
||||
@@ -100,6 +102,15 @@ export class ObsidianSettingTab extends PluginSettingTab {
|
||||
this.createSettingEl(containerEl, i18nConfig.AutoCopyNotionLink, i18nConfig.AutoCopyNotionLinkDesc, 'toggle', i18nConfig.AutoCopyNotionLink, this.plugin.settings.autoCopyNotionLink, 'autoCopyNotionLink')
|
||||
|
||||
this.createSettingEl(containerEl, i18nConfig.AutoSync, i18nConfig.AutoSyncDesc, 'toggle', i18nConfig.AutoSync, this.plugin.settings.autoSync, 'autoSync')
|
||||
this.createSettingEl(
|
||||
containerEl,
|
||||
i18nConfig.AutoSyncSuccessNotice,
|
||||
i18nConfig.AutoSyncSuccessNoticeDesc,
|
||||
'toggle',
|
||||
i18nConfig.AutoSyncSuccessNotice,
|
||||
this.plugin.settings.autoSyncSuccessNotice,
|
||||
'autoSyncSuccessNotice'
|
||||
)
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(i18nConfig.AutoSyncFrontmatterKey)
|
||||
@@ -116,7 +127,7 @@ export class ObsidianSettingTab extends PluginSettingTab {
|
||||
|
||||
// Auto Sync Delay setting - only visible when autoSync is enabled
|
||||
this.autoSyncDelayContainer = containerEl.createDiv();
|
||||
const delaySetting = new Setting(this.autoSyncDelayContainer)
|
||||
new Setting(this.autoSyncDelayContainer)
|
||||
.setName(i18nConfig.AutoSyncDelay)
|
||||
.setDesc(i18nConfig.AutoSyncDelayDesc)
|
||||
.addText((text) =>
|
||||
@@ -369,4 +380,3 @@ export class ObsidianSettingTab extends PluginSettingTab {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import MyPlugin from "src/main";
|
||||
import { DatabaseDetails } from "../ui/settingTabs";
|
||||
import { updateYamlInfo } from "./updateYaml";
|
||||
import { UploadBase, NotionPageResponse } from "./common/UploadBase";
|
||||
import { AttachmentProcessor, applyBlockRewrites } from "./common/AttachmentProcessor";
|
||||
|
||||
export type DatasetType = "general" | "next" | "custom";
|
||||
|
||||
@@ -14,6 +15,7 @@ interface BaseSyncRequest {
|
||||
markdown: string;
|
||||
nowFile: TFile;
|
||||
app: App;
|
||||
isAutoSync?: boolean;
|
||||
}
|
||||
|
||||
interface GeneralSyncRequest extends BaseSyncRequest {
|
||||
@@ -90,6 +92,7 @@ export class Upload2Notion extends UploadBase {
|
||||
|
||||
async sync(request: SyncRequest): Promise<NotionPageResponse> {
|
||||
const startedAt = Date.now();
|
||||
this.isAutoSync = !!request.isAutoSync;
|
||||
|
||||
let response: NotionPageResponse;
|
||||
|
||||
@@ -128,7 +131,7 @@ export class Upload2Notion extends UploadBase {
|
||||
cover: request.cover,
|
||||
tags: request.tags,
|
||||
});
|
||||
const blocks = this.buildBlocks(request.markdown, {
|
||||
const blocks = await this.buildBlocks(request.markdown, request.nowFile, {
|
||||
notionLimits: {truncate: false},
|
||||
});
|
||||
const notionId = this.getNotionId(request.app, request.nowFile);
|
||||
@@ -153,7 +156,7 @@ export class Upload2Notion extends UploadBase {
|
||||
slug: request.slug,
|
||||
category: request.category,
|
||||
});
|
||||
const blocks = this.buildBlocks(request.markdown, {
|
||||
const blocks = await this.buildBlocks(request.markdown, request.nowFile, {
|
||||
notionLimits: {truncate: false},
|
||||
});
|
||||
this.splitRichTextParagraphs(blocks);
|
||||
@@ -187,7 +190,7 @@ export class Upload2Notion extends UploadBase {
|
||||
console.log(`[Upload2Notion] Handling custom dataset`, {
|
||||
customKeys: Object.keys(request.customValues || {}),
|
||||
});
|
||||
const blocks = this.buildBlocks(request.markdown, {
|
||||
const blocks = await this.buildBlocks(request.markdown, request.nowFile, {
|
||||
strictImageUrls: true,
|
||||
notionLimits: {truncate: false},
|
||||
});
|
||||
@@ -206,10 +209,24 @@ export class Upload2Notion extends UploadBase {
|
||||
});
|
||||
}
|
||||
|
||||
private buildBlocks(markdown: string, options: Record<string, unknown>): any[] {
|
||||
private async buildBlocks(markdown: string, nowFile: TFile, options: Record<string, unknown>): Promise<any[]> {
|
||||
const yamlContent: any = yamlFrontMatter.loadFront(markdown);
|
||||
const content = yamlContent.__content;
|
||||
let content: string = yamlContent.__content ?? "";
|
||||
|
||||
// Process local attachments
|
||||
const processor = new AttachmentProcessor(this.plugin, this.dbDetails);
|
||||
const result = await processor.processContent(content, nowFile);
|
||||
content = result.content;
|
||||
const imageUrlToUploadId = result.imageUrlToUploadId;
|
||||
const filePlaceholderToUpload = result.filePlaceholderToUpload;
|
||||
|
||||
const blocks = markdownToBlocks(content, options);
|
||||
|
||||
// Apply block rewrites for uploaded files
|
||||
if (Object.keys(imageUrlToUploadId).length > 0 || Object.keys(filePlaceholderToUpload).length > 0) {
|
||||
applyBlockRewrites(blocks, { imageUrlToUploadId, filePlaceholderToUpload });
|
||||
}
|
||||
|
||||
this.debugLog("Upload2Notion", "Converted markdown to blocks", {
|
||||
blockCount: blocks.length,
|
||||
firstBlockTypes: blocks.slice(0, 5).map((block: any) => block?.type),
|
||||
|
||||
697
src/upload/common/AttachmentProcessor.ts
Normal file
697
src/upload/common/AttachmentProcessor.ts
Normal file
@@ -0,0 +1,697 @@
|
||||
import { App, TFile, normalizePath } from "obsidian";
|
||||
import { AttachmentUploader } from "./AttachmentUploader";
|
||||
import type MyPlugin from "src/main";
|
||||
import type { DatabaseDetails } from "../../ui/settingTabs";
|
||||
|
||||
export interface AttachmentPrepareResult {
|
||||
content: string;
|
||||
imageUrlToUploadId: Record<string, string>;
|
||||
filePlaceholderToUpload: Record<string, { id: string; name: string }>;
|
||||
}
|
||||
|
||||
interface LocalAttachment {
|
||||
file: TFile;
|
||||
originalRef: string;
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
"png", "jpg", "jpeg", "gif", "webp", "svg", "heic", "tif", "tiff", "bmp"
|
||||
]);
|
||||
|
||||
const SUPPORTED_EXTENSIONS = new Set([
|
||||
...IMAGE_EXTENSIONS, "pdf"
|
||||
]);
|
||||
|
||||
export class AttachmentProcessor {
|
||||
private plugin: MyPlugin;
|
||||
private dbDetails: DatabaseDetails;
|
||||
private uploader: AttachmentUploader;
|
||||
|
||||
constructor(plugin: MyPlugin, dbDetails: DatabaseDetails) {
|
||||
this.plugin = plugin;
|
||||
this.dbDetails = dbDetails;
|
||||
this.uploader = new AttachmentUploader(plugin, dbDetails);
|
||||
}
|
||||
|
||||
private isStandaloneOnLine(input: string, offset: number, match: string): boolean {
|
||||
const lineStart = input.lastIndexOf("\n", Math.max(0, offset - 1)) + 1;
|
||||
const lineEndIdx = input.indexOf("\n", offset + match.length);
|
||||
const lineEnd = lineEndIdx === -1 ? input.length : lineEndIdx;
|
||||
const before = input.slice(lineStart, offset).trim();
|
||||
const after = input.slice(offset + match.length, lineEnd).trim();
|
||||
return before.length === 0 && after.length === 0;
|
||||
}
|
||||
|
||||
hasInternalAttachments(content: string, sourceFile: TFile): boolean {
|
||||
const app = this.plugin.app;
|
||||
const contentWithoutCode = content.replace(/```[\s\S]*?```|`[^`\n]+`/g, "");
|
||||
|
||||
const embedImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
const embedWikilinkRegex = /!\[\[([^\]]+)\]\]/g;
|
||||
const linkMarkdownRegex = /(?<!!)\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
const linkWikilinkRegex = /(?<!!)\[\[([^\]]+)\]\]/g;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = embedImageRegex.exec(contentWithoutCode)) !== null) {
|
||||
const rawPath = this.parseDestination(match[2]);
|
||||
if (this.shouldSkipLinkDestination(rawPath)) continue;
|
||||
const file = this.resolveFile(app, sourceFile, rawPath, { log: false });
|
||||
if (file && this.isSupported(file)) return true;
|
||||
}
|
||||
|
||||
while ((match = embedWikilinkRegex.exec(contentWithoutCode)) !== null) {
|
||||
const linkPath = this.parseWikilink(match[1]);
|
||||
if (this.shouldSkipLinkDestination(linkPath)) continue;
|
||||
const file = this.resolveFile(app, sourceFile, linkPath, { log: false });
|
||||
if (file && this.isSupported(file)) return true;
|
||||
}
|
||||
|
||||
while ((match = linkMarkdownRegex.exec(contentWithoutCode)) !== null) {
|
||||
const rawPath = this.parseDestination(match[2]);
|
||||
if (this.shouldSkipLinkDestination(rawPath)) continue;
|
||||
const file = this.resolveFile(app, sourceFile, rawPath, { log: false });
|
||||
if (file && this.isSupported(file)) return true;
|
||||
}
|
||||
|
||||
while ((match = linkWikilinkRegex.exec(contentWithoutCode)) !== null) {
|
||||
const linkPath = this.parseWikilink(match[1]);
|
||||
if (this.shouldSkipLinkDestination(linkPath)) continue;
|
||||
const file = this.resolveFile(app, sourceFile, linkPath, { log: false });
|
||||
if (file && this.isSupported(file)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async processContent(content: string, sourceFile: TFile): Promise<AttachmentPrepareResult> {
|
||||
console.log(`[AttachmentProcessor] Starting attachment processing for file: ${sourceFile.path}`);
|
||||
|
||||
// Strip code blocks before processing to avoid matching inside them
|
||||
const codeBlockPlaceholders: string[] = [];
|
||||
const contentWithoutCode = content.replace(/```[\s\S]*?```|`[^`\n]+`/g, (match) => {
|
||||
const placeholder = `__CODE_BLOCK_${codeBlockPlaceholders.length}__`;
|
||||
codeBlockPlaceholders.push(match);
|
||||
return placeholder;
|
||||
});
|
||||
console.log(`[AttachmentProcessor] Stripped ${codeBlockPlaceholders.length} code blocks`);
|
||||
|
||||
const { internal, external } = this.collectAttachments(contentWithoutCode, sourceFile);
|
||||
|
||||
if (external.length > 0) {
|
||||
console.log(`[AttachmentProcessor] Found ${external.length} external reference(s) (will be skipped):`);
|
||||
external.forEach((ref, idx) => {
|
||||
console.log(` ${idx + 1}. [EXTERNAL] ${ref}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (internal.length === 0) {
|
||||
console.log(`[AttachmentProcessor] No internal attachments found in ${sourceFile.path}`);
|
||||
return {
|
||||
content,
|
||||
imageUrlToUploadId: {},
|
||||
filePlaceholderToUpload: {},
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[AttachmentProcessor] Found ${internal.length} internal attachment(s) to upload:`);
|
||||
internal.forEach((attachment, idx) => {
|
||||
const typeLabel = this.isImage(attachment.file) ? 'IMAGE' : 'FILE';
|
||||
const sizeKB = (attachment.file.stat.size / 1024).toFixed(2);
|
||||
console.log(` ${idx + 1}. [${typeLabel}] ${attachment.file.path} (${sizeKB} KB) - Ref: "${attachment.originalRef}"`);
|
||||
});
|
||||
|
||||
const uploadedMap = new Map<string, { id: string; file: TFile }>();
|
||||
|
||||
for (const attachment of internal) {
|
||||
try {
|
||||
console.log(`[AttachmentProcessor] Uploading: ${attachment.file.path} (${(attachment.file.stat.size / 1024).toFixed(2)} KB)`);
|
||||
const result = await this.uploader.uploadFile(attachment.file);
|
||||
uploadedMap.set(attachment.file.path, { id: result.id, file: attachment.file });
|
||||
console.log(`[AttachmentProcessor] ✓ Uploaded successfully: ${attachment.file.name} -> ${result.id}`);
|
||||
} catch (error) {
|
||||
console.error(`[AttachmentProcessor] ✗ Failed to upload ${attachment.file.path}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[AttachmentProcessor] Upload complete: ${uploadedMap.size}/${internal.length} successful`);
|
||||
|
||||
const rewriteResult = this.rewriteContent(contentWithoutCode, sourceFile, uploadedMap);
|
||||
|
||||
// Restore code blocks
|
||||
let restoredContent = rewriteResult.content;
|
||||
codeBlockPlaceholders.forEach((code, idx) => {
|
||||
restoredContent = restoredContent.replace(`__CODE_BLOCK_${idx}__`, code);
|
||||
});
|
||||
|
||||
console.log(`[AttachmentProcessor] Content rewrite complete:`, {
|
||||
imageReplacements: Object.keys(rewriteResult.imageUrlToUploadId).length,
|
||||
fileReplacements: Object.keys(rewriteResult.filePlaceholderToUpload).length,
|
||||
});
|
||||
|
||||
return {
|
||||
content: restoredContent,
|
||||
imageUrlToUploadId: rewriteResult.imageUrlToUploadId,
|
||||
filePlaceholderToUpload: rewriteResult.filePlaceholderToUpload,
|
||||
};
|
||||
}
|
||||
|
||||
private collectAttachments(content: string, sourceFile: TFile): { internal: LocalAttachment[]; external: string[] } {
|
||||
const internal: LocalAttachment[] = [];
|
||||
const external: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const app = this.plugin.app;
|
||||
|
||||
console.log(`[AttachmentProcessor] Scanning for attachments in content (${content.length} chars)`);
|
||||
|
||||
// Match all types of references:  ![[...]] [...](...) [[...]]
|
||||
const embedImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
const embedWikilinkRegex = /!\[\[([^\]]+)\]\]/g;
|
||||
const linkMarkdownRegex = /(?<!!)\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
const linkWikilinkRegex = /(?<!!)\[\[([^\]]+)\]\]/g;
|
||||
|
||||
let match;
|
||||
|
||||
// Process embedded images: 
|
||||
let embedCount = 0;
|
||||
while ((match = embedImageRegex.exec(content)) !== null) {
|
||||
embedCount++;
|
||||
const rawPath = this.parseDestination(match[2]);
|
||||
console.log(`[AttachmentProcessor] Found embedded image #${embedCount}: "${match[0]}" -> parsed path: "${rawPath}"`);
|
||||
|
||||
if (this.shouldSkipLinkDestination(rawPath)) {
|
||||
if (this.isTodoUrl(rawPath)) {
|
||||
console.log(`[AttachmentProcessor] ⊘ Unsupported URL scheme (TODO, skipped): ${rawPath}`);
|
||||
} else {
|
||||
console.log(`[AttachmentProcessor] ⊘ External URL (skipped): ${rawPath}`);
|
||||
}
|
||||
external.push(match[0]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const file = this.resolveFile(app, sourceFile, rawPath);
|
||||
if (!file) {
|
||||
console.log(`[AttachmentProcessor] ✗ Could not resolve file for path: "${rawPath}"`);
|
||||
} else if (!this.isSupported(file)) {
|
||||
console.log(`[AttachmentProcessor] ✗ Unsupported file type: ${file.extension} (${file.path})`);
|
||||
} else if (seen.has(file.path)) {
|
||||
console.log(`[AttachmentProcessor] ⊚ Duplicate (already added): ${file.path}`);
|
||||
} else {
|
||||
seen.add(file.path);
|
||||
internal.push({ file, originalRef: match[0] });
|
||||
console.log(`[AttachmentProcessor] ✓ Added: ${file.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Process embedded wikilinks: ![[path]]
|
||||
let wikiEmbedCount = 0;
|
||||
while ((match = embedWikilinkRegex.exec(content)) !== null) {
|
||||
wikiEmbedCount++;
|
||||
const linkPath = this.parseWikilink(match[1]);
|
||||
console.log(`[AttachmentProcessor] Found embedded wikilink #${wikiEmbedCount}: "${match[0]}" -> parsed path: "${linkPath}"`);
|
||||
|
||||
if (this.shouldSkipLinkDestination(linkPath)) {
|
||||
if (this.isTodoUrl(linkPath)) {
|
||||
console.log(`[AttachmentProcessor] ⊘ Unsupported URL scheme (TODO, skipped): ${linkPath}`);
|
||||
} else {
|
||||
console.log(`[AttachmentProcessor] ⊘ External URL (skipped): ${linkPath}`);
|
||||
}
|
||||
external.push(match[0]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const file = this.resolveFile(app, sourceFile, linkPath);
|
||||
if (!file) {
|
||||
console.log(`[AttachmentProcessor] ✗ Could not resolve file for path: "${linkPath}"`);
|
||||
} else if (!this.isSupported(file)) {
|
||||
console.log(`[AttachmentProcessor] ✗ Unsupported file type: ${file.extension} (${file.path})`);
|
||||
} else if (seen.has(file.path)) {
|
||||
console.log(`[AttachmentProcessor] ⊚ Duplicate (already added): ${file.path}`);
|
||||
} else {
|
||||
seen.add(file.path);
|
||||
internal.push({ file, originalRef: match[0] });
|
||||
console.log(`[AttachmentProcessor] ✓ Added: ${file.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Process markdown links: [text](path)
|
||||
let linkCount = 0;
|
||||
while ((match = linkMarkdownRegex.exec(content)) !== null) {
|
||||
linkCount++;
|
||||
const rawPath = this.parseDestination(match[2]);
|
||||
console.log(`[AttachmentProcessor] Found markdown link #${linkCount}: "${match[0]}" -> parsed path: "${rawPath}"`);
|
||||
|
||||
if (this.shouldSkipLinkDestination(rawPath)) {
|
||||
if (this.isTodoUrl(rawPath)) {
|
||||
console.log(`[AttachmentProcessor] ⊘ Unsupported URL scheme (TODO, skipped): ${rawPath}`);
|
||||
} else {
|
||||
console.log(`[AttachmentProcessor] ⊘ External URL (skipped): ${rawPath}`);
|
||||
}
|
||||
external.push(match[0]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const file = this.resolveFile(app, sourceFile, rawPath);
|
||||
if (!file) {
|
||||
console.log(`[AttachmentProcessor] ✗ Could not resolve file for path: "${rawPath}"`);
|
||||
} else if (!this.isSupported(file)) {
|
||||
console.log(`[AttachmentProcessor] ✗ Unsupported file type: ${file.extension} (${file.path})`);
|
||||
} else if (seen.has(file.path)) {
|
||||
console.log(`[AttachmentProcessor] ⊚ Duplicate (already added): ${file.path}`);
|
||||
} else {
|
||||
seen.add(file.path);
|
||||
internal.push({ file, originalRef: match[0] });
|
||||
console.log(`[AttachmentProcessor] ✓ Added: ${file.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Process wikilink references: [[path]]
|
||||
let wikilinkCount = 0;
|
||||
while ((match = linkWikilinkRegex.exec(content)) !== null) {
|
||||
wikilinkCount++;
|
||||
const linkPath = this.parseWikilink(match[1]);
|
||||
console.log(`[AttachmentProcessor] Found wikilink reference #${wikilinkCount}: "${match[0]}" -> parsed path: "${linkPath}"`);
|
||||
|
||||
if (this.shouldSkipLinkDestination(linkPath)) {
|
||||
if (this.isTodoUrl(linkPath)) {
|
||||
console.log(`[AttachmentProcessor] ⊘ Unsupported URL scheme (TODO, skipped): ${linkPath}`);
|
||||
} else {
|
||||
console.log(`[AttachmentProcessor] ⊘ External URL (skipped): ${linkPath}`);
|
||||
}
|
||||
external.push(match[0]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const file = this.resolveFile(app, sourceFile, linkPath);
|
||||
if (!file) {
|
||||
console.log(`[AttachmentProcessor] ✗ Could not resolve file for path: "${linkPath}"`);
|
||||
} else if (!this.isSupported(file)) {
|
||||
console.log(`[AttachmentProcessor] ✗ Unsupported file type: ${file.extension} (${file.path})`);
|
||||
} else if (seen.has(file.path)) {
|
||||
console.log(`[AttachmentProcessor] ⊚ Duplicate (already added): ${file.path}`);
|
||||
} else {
|
||||
seen.add(file.path);
|
||||
internal.push({ file, originalRef: match[0] });
|
||||
console.log(`[AttachmentProcessor] ✓ Added: ${file.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[AttachmentProcessor] Scan complete: ${embedCount} embeds, ${wikiEmbedCount} wiki-embeds, ${linkCount} links, ${wikilinkCount} wikilinks -> ${internal.length} internal attachments, ${external.length} external references`);
|
||||
return { internal, external };
|
||||
}
|
||||
|
||||
private rewriteContent(
|
||||
content: string,
|
||||
sourceFile: TFile,
|
||||
uploadedMap: Map<string, { id: string; file: TFile }>
|
||||
): AttachmentPrepareResult {
|
||||
const imageUrlToUploadId: Record<string, string> = {};
|
||||
const filePlaceholderToUpload: Record<string, { id: string; name: string }> = {};
|
||||
const app = this.plugin.app;
|
||||
|
||||
let rewritten = content;
|
||||
|
||||
// Rewrite embedded images: 
|
||||
rewritten = rewritten.replace(
|
||||
/!\[([^\]]*)\]\(([^)]+)\)/g,
|
||||
(fullMatch, altText, rawDest, offset, input) => {
|
||||
const path = this.parseDestination(rawDest);
|
||||
if (this.shouldSkipLinkDestination(path)) return fullMatch;
|
||||
|
||||
const file = this.resolveFile(app, sourceFile, path);
|
||||
if (!file) return fullMatch;
|
||||
|
||||
const uploaded = uploadedMap.get(file.path);
|
||||
if (!uploaded) return fullMatch;
|
||||
|
||||
if (this.isImage(file)) {
|
||||
const sentinelUrl = this.buildSentinelUrl(uploaded.id, file, altText);
|
||||
imageUrlToUploadId[sentinelUrl] = uploaded.id;
|
||||
const markdown = ``;
|
||||
return typeof offset === "number" && typeof input === "string" && this.isStandaloneOnLine(input, offset, fullMatch)
|
||||
? `\n\n${markdown}\n\n`
|
||||
: markdown;
|
||||
} else {
|
||||
const token = `__NOTION_FILE_UPLOAD__:${uploaded.id}`;
|
||||
filePlaceholderToUpload[token] = { id: uploaded.id, name: file.name };
|
||||
return `\n\n\`${token}\`\n\n`;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Rewrite embedded wikilinks: ![[path]]
|
||||
rewritten = rewritten.replace(
|
||||
/!\[\[([^\]]+)\]\]/g,
|
||||
(fullMatch, inner, offset, input) => {
|
||||
const linkPath = this.parseWikilink(inner);
|
||||
if (this.shouldSkipLinkDestination(linkPath)) return fullMatch;
|
||||
|
||||
const file = this.resolveFile(app, sourceFile, linkPath);
|
||||
if (!file) return fullMatch;
|
||||
|
||||
const uploaded = uploadedMap.get(file.path);
|
||||
if (!uploaded) return fullMatch;
|
||||
|
||||
if (this.isImage(file)) {
|
||||
const sentinelUrl = this.buildSentinelUrl(uploaded.id, file);
|
||||
imageUrlToUploadId[sentinelUrl] = uploaded.id;
|
||||
const markdown = ``;
|
||||
return typeof offset === "number" && typeof input === "string" && this.isStandaloneOnLine(input, offset, fullMatch)
|
||||
? `\n\n${markdown}\n\n`
|
||||
: markdown;
|
||||
} else {
|
||||
const token = `__NOTION_FILE_UPLOAD__:${uploaded.id}`;
|
||||
filePlaceholderToUpload[token] = { id: uploaded.id, name: file.name };
|
||||
return `\n\n\`${token}\`\n\n`;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Rewrite markdown links: [text](path)
|
||||
rewritten = rewritten.replace(
|
||||
/(?<!!)\[([^\]]+)\]\(([^)]+)\)/g,
|
||||
(fullMatch, _linkText, rawDest) => {
|
||||
const path = this.parseDestination(rawDest);
|
||||
if (this.shouldSkipLinkDestination(path)) return fullMatch;
|
||||
|
||||
const file = this.resolveFile(app, sourceFile, path);
|
||||
if (!file) return fullMatch;
|
||||
|
||||
const uploaded = uploadedMap.get(file.path);
|
||||
if (!uploaded) return fullMatch;
|
||||
|
||||
// For markdown links, always use file placeholder (non-image treatment)
|
||||
const token = `__NOTION_FILE_UPLOAD__:${uploaded.id}`;
|
||||
filePlaceholderToUpload[token] = { id: uploaded.id, name: file.name };
|
||||
return `\n\n\`${token}\`\n\n`;
|
||||
}
|
||||
);
|
||||
|
||||
// Rewrite wikilink references: [[path]]
|
||||
rewritten = rewritten.replace(
|
||||
/(?<!!)\[\[([^\]]+)\]\]/g,
|
||||
(fullMatch, inner) => {
|
||||
const linkPath = this.parseWikilink(inner);
|
||||
if (this.shouldSkipLinkDestination(linkPath)) return fullMatch;
|
||||
|
||||
const file = this.resolveFile(app, sourceFile, linkPath);
|
||||
if (!file) return fullMatch;
|
||||
|
||||
const uploaded = uploadedMap.get(file.path);
|
||||
if (!uploaded) return fullMatch;
|
||||
|
||||
// For wikilink references, always use file placeholder (non-image treatment)
|
||||
const token = `__NOTION_FILE_UPLOAD__:${uploaded.id}`;
|
||||
filePlaceholderToUpload[token] = { id: uploaded.id, name: file.name };
|
||||
return `\n\n\`${token}\`\n\n`;
|
||||
}
|
||||
);
|
||||
|
||||
return { content: rewritten, imageUrlToUploadId, filePlaceholderToUpload };
|
||||
}
|
||||
|
||||
private parseDestination(rawDest: string): string {
|
||||
const trimmed = rawDest.trim();
|
||||
// Handle angle-bracket wrapped URLs: <path>
|
||||
if (trimmed.startsWith("<") && trimmed.includes(">")) {
|
||||
const end = trimmed.indexOf(">");
|
||||
return this.decodePathOrUrl(trimmed.slice(1, end));
|
||||
}
|
||||
// Take first non-space segment
|
||||
const match = trimmed.match(/^(\S+)/);
|
||||
return this.decodePathOrUrl(match ? match[1] : trimmed);
|
||||
}
|
||||
|
||||
private parseWikilink(inner: string): string {
|
||||
const trimmed = inner.trim();
|
||||
// Remove alias: [[path|alias]]
|
||||
const beforeAlias = trimmed.split("|")[0]?.trim() ?? trimmed;
|
||||
// Remove heading: [[path#heading]]
|
||||
const beforeHeading = beforeAlias.split("#")[0]?.trim() ?? beforeAlias;
|
||||
return this.decodePathOrUrl(beforeHeading);
|
||||
}
|
||||
|
||||
private decodePathOrUrl(value: string): string {
|
||||
/*
|
||||
// TODO: Support `obsidian://` and `app://` URL destinations.
|
||||
// For now we only support wikilink + standard markdown formats with vault paths.
|
||||
if (value.startsWith("obsidian://") || value.startsWith("app://")) {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
*/
|
||||
// For regular paths, strip query/hash and decode
|
||||
const stripped = value.split(/[?#]/)[0] ?? value;
|
||||
try {
|
||||
return decodeURIComponent(stripped);
|
||||
} catch {
|
||||
return stripped;
|
||||
}
|
||||
}
|
||||
|
||||
private isExternalUrl(link: string): boolean {
|
||||
return link.startsWith("http://") || link.startsWith("https://");
|
||||
}
|
||||
|
||||
private isTodoUrl(link: string): boolean {
|
||||
return link.startsWith("obsidian://") || link.startsWith("app://");
|
||||
}
|
||||
|
||||
private shouldSkipLinkDestination(link: string): boolean {
|
||||
return this.isExternalUrl(link) || this.isTodoUrl(link);
|
||||
}
|
||||
|
||||
private resolveFile(app: App, sourceFile: TFile, link: string, options?: { log?: boolean }): TFile | null {
|
||||
const shouldLog = options?.log !== false;
|
||||
const log = (...args: any[]) => {
|
||||
if (shouldLog) console.log(...args);
|
||||
};
|
||||
|
||||
if (!link.trim()) {
|
||||
log(`[AttachmentProcessor] resolveFile: empty link`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: Support `obsidian://` and `app://` URL destinations.
|
||||
if (this.isTodoUrl(link)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/*
|
||||
// Handle obsidian:// URLs
|
||||
if (link.startsWith("obsidian://")) {
|
||||
const filePath = this.parseObsidianUrl(link);
|
||||
log(`[AttachmentProcessor] resolveFile: obsidian:// URL -> extracted path: "${filePath}"`);
|
||||
if (!filePath) return null;
|
||||
const file = app.vault.getAbstractFileByPath(normalizePath(filePath));
|
||||
if (file instanceof TFile) {
|
||||
log(`[AttachmentProcessor] resolveFile: ✓ Resolved obsidian:// to: ${file.path}`);
|
||||
return file;
|
||||
}
|
||||
log(`[AttachmentProcessor] resolveFile: ✗ Failed to resolve obsidian:// path: ${filePath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle app://local/ URLs (Obsidian internal)
|
||||
if (link.startsWith("app://")) {
|
||||
const filePath = this.parseAppUrl(link);
|
||||
log(`[AttachmentProcessor] resolveFile: app:// URL -> extracted path: "${filePath}"`);
|
||||
if (!filePath) return null;
|
||||
|
||||
const vaultCandidate = filePath.startsWith("/") ? filePath.slice(1) : filePath;
|
||||
let file = app.vault.getAbstractFileByPath(normalizePath(vaultCandidate));
|
||||
if (file instanceof TFile) {
|
||||
log(`[AttachmentProcessor] resolveFile: ✓ Resolved app:// to: ${file.path}`);
|
||||
return file;
|
||||
}
|
||||
|
||||
const mapped = this.mapAbsolutePathToVault(app, filePath);
|
||||
if (mapped) {
|
||||
file = app.vault.getAbstractFileByPath(normalizePath(mapped));
|
||||
if (file instanceof TFile) {
|
||||
log(`[AttachmentProcessor] resolveFile: ✓ Resolved app:// absolute path to: ${file.path}`);
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
log(`[AttachmentProcessor] resolveFile: ✗ Failed to resolve app:// path: ${filePath}`);
|
||||
return null;
|
||||
}
|
||||
*/
|
||||
|
||||
// Try metadata cache first
|
||||
const cached = app.metadataCache.getFirstLinkpathDest(link, sourceFile.path);
|
||||
if (cached instanceof TFile) {
|
||||
log(`[AttachmentProcessor] resolveFile: ✓ Resolved via metadata cache: ${link} -> ${cached.path}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Try absolute path
|
||||
const byPath = app.vault.getAbstractFileByPath(normalizePath(link));
|
||||
if (byPath instanceof TFile) {
|
||||
log(`[AttachmentProcessor] resolveFile: ✓ Resolved via absolute path: ${link} -> ${byPath.path}`);
|
||||
return byPath;
|
||||
}
|
||||
|
||||
// Try relative to source file
|
||||
const sourceDir = sourceFile.path.includes("/")
|
||||
? sourceFile.path.slice(0, sourceFile.path.lastIndexOf("/"))
|
||||
: "";
|
||||
const relPath = normalizePath(sourceDir ? `${sourceDir}/${link}` : link);
|
||||
const byRel = app.vault.getAbstractFileByPath(relPath);
|
||||
if (byRel instanceof TFile) {
|
||||
log(`[AttachmentProcessor] resolveFile: ✓ Resolved via relative path: ${link} -> ${byRel.path}`);
|
||||
return byRel;
|
||||
}
|
||||
|
||||
log(`[AttachmentProcessor] resolveFile: ✗ Failed to resolve: ${link} (tried cache, absolute, relative)`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/*
|
||||
// TODO: Support `obsidian://` URL destinations.
|
||||
private parseObsidianUrl(url: string): string | null {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
// obsidian://open?vault=VaultName&file=path/to/file.png
|
||||
const filePath = urlObj.searchParams.get("file");
|
||||
if (filePath) {
|
||||
return decodeURIComponent(filePath);
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Support `app://` URL destinations.
|
||||
private parseAppUrl(url: string): string | null {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const pathname = urlObj.pathname;
|
||||
if (!pathname || pathname === "/") return null;
|
||||
return decodeURIComponent(pathname);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Support mapping absolute paths to vault paths for `app://local/...`.
|
||||
private mapAbsolutePathToVault(app: App, absolutePath: string): string | null {
|
||||
const adapter: any = app.vault.adapter;
|
||||
if (typeof adapter?.getBasePath !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let normalizedAbsolute = absolutePath.replace(/\\/g, "/");
|
||||
if (/^\/[A-Za-z]:\//.test(normalizedAbsolute)) {
|
||||
normalizedAbsolute = normalizedAbsolute.slice(1);
|
||||
}
|
||||
|
||||
let basePath = String(adapter.getBasePath()).replace(/\\/g, "/");
|
||||
if (basePath.endsWith("/")) {
|
||||
basePath = basePath.slice(0, -1);
|
||||
}
|
||||
if (/^\/[A-Za-z]:\//.test(basePath)) {
|
||||
basePath = basePath.slice(1);
|
||||
}
|
||||
|
||||
const windowsStyle = /^[A-Za-z]:\//.test(basePath);
|
||||
const compareAbsolute = windowsStyle ? normalizedAbsolute.toLowerCase() : normalizedAbsolute;
|
||||
const compareBase = windowsStyle ? basePath.toLowerCase() : basePath;
|
||||
|
||||
if (!compareAbsolute.startsWith(compareBase)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let relative = normalizedAbsolute.slice(basePath.length);
|
||||
if (relative.startsWith("/")) {
|
||||
relative = relative.slice(1);
|
||||
}
|
||||
return relative || null;
|
||||
}
|
||||
*/
|
||||
|
||||
private isSupported(file: TFile): boolean {
|
||||
const ext = file.extension?.toLowerCase() ?? "";
|
||||
return SUPPORTED_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
private isImage(file: TFile): boolean {
|
||||
const ext = file.extension?.toLowerCase() ?? "";
|
||||
return IMAGE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
private buildSentinelUrl(uploadId: string, file: TFile, _altText?: string): string {
|
||||
const ext = file.extension?.toLowerCase() ?? "";
|
||||
const suffix = ext ? `.${ext}` : "";
|
||||
return `https://notion-file-upload.local/${uploadId}${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyBlockRewrites(
|
||||
blocks: any[],
|
||||
rewrites: Pick<AttachmentPrepareResult, "imageUrlToUploadId" | "filePlaceholderToUpload">
|
||||
): void {
|
||||
transformBlocksInPlace(blocks, rewrites);
|
||||
}
|
||||
|
||||
function transformBlocksInPlace(
|
||||
blocks: any[],
|
||||
rewrites: Pick<AttachmentPrepareResult, "imageUrlToUploadId" | "filePlaceholderToUpload">
|
||||
): void {
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const block = blocks[i];
|
||||
|
||||
// Transform image blocks with sentinel URLs
|
||||
if (block?.type === "image" && block?.image?.type === "external") {
|
||||
const url = block.image?.external?.url;
|
||||
if (url && rewrites.imageUrlToUploadId[url]) {
|
||||
const caption = block.image?.caption;
|
||||
block.image = {
|
||||
type: "file_upload",
|
||||
file_upload: { id: rewrites.imageUrlToUploadId[url] },
|
||||
...(caption ? { caption } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Transform paragraph placeholders to file blocks
|
||||
if (block?.type === "paragraph") {
|
||||
const token = extractParagraphText(block);
|
||||
if (token && rewrites.filePlaceholderToUpload[token]) {
|
||||
const { id, name } = rewrites.filePlaceholderToUpload[token];
|
||||
blocks[i] = buildFileBlock(id, name);
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into children
|
||||
const inner = block?.[block?.type];
|
||||
if (inner?.children && Array.isArray(inner.children)) {
|
||||
transformBlocksInPlace(inner.children, rewrites);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractParagraphText(block: any): string | undefined {
|
||||
const richText = block?.paragraph?.rich_text;
|
||||
if (!Array.isArray(richText) || richText.length === 0) return undefined;
|
||||
return richText
|
||||
.map((item: any) => item?.plain_text ?? item?.text?.content ?? "")
|
||||
.join("")
|
||||
.trim() || undefined;
|
||||
}
|
||||
|
||||
function buildFileBlock(uploadId: string, name: string): any {
|
||||
return {
|
||||
object: "block",
|
||||
type: "file",
|
||||
file: {
|
||||
type: "file_upload",
|
||||
file_upload: { id: uploadId },
|
||||
},
|
||||
};
|
||||
}
|
||||
256
src/upload/common/AttachmentUploader.ts
Normal file
256
src/upload/common/AttachmentUploader.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { TFile, requestUrl } from "obsidian";
|
||||
import type MyPlugin from "src/main";
|
||||
import type { DatabaseDetails } from "../../ui/settingTabs";
|
||||
|
||||
const NOTION_API_VERSION = "2025-09-03";
|
||||
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
interface FileUploadSession {
|
||||
id: string;
|
||||
status: string;
|
||||
upload_url?: string;
|
||||
}
|
||||
|
||||
interface UploadResult {
|
||||
id: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export class AttachmentUploader {
|
||||
private plugin: MyPlugin;
|
||||
private dbDetails: DatabaseDetails;
|
||||
private textEncoder = new TextEncoder();
|
||||
|
||||
constructor(plugin: MyPlugin, dbDetails: DatabaseDetails) {
|
||||
this.plugin = plugin;
|
||||
this.dbDetails = dbDetails;
|
||||
}
|
||||
|
||||
async uploadFile(file: TFile): Promise<UploadResult> {
|
||||
const { notionAPI } = this.dbDetails;
|
||||
const fileSizeBytes = file.stat?.size ?? 0;
|
||||
const contentType = this.getContentType(file.extension);
|
||||
|
||||
if (fileSizeBytes > MAX_UPLOAD_BYTES) {
|
||||
throw new Error(
|
||||
`File too large for Notion upload (max 5MB): ${file.path} (${(fileSizeBytes / 1024 / 1024).toFixed(2)} MB)`,
|
||||
);
|
||||
}
|
||||
const mode = "single_part";
|
||||
|
||||
console.log(`[AttachmentUploader] uploadFile: ${file.name}`, {
|
||||
path: file.path,
|
||||
size: `${(fileSizeBytes / 1024).toFixed(2)} KB`,
|
||||
contentType,
|
||||
mode,
|
||||
});
|
||||
|
||||
const session = await this.createUploadSession({
|
||||
mode,
|
||||
notionAPI,
|
||||
});
|
||||
|
||||
console.log(`[AttachmentUploader] Upload session created:`, {
|
||||
sessionId: session.id,
|
||||
status: session.status,
|
||||
});
|
||||
|
||||
const binary = await this.plugin.app.vault.readBinary(file);
|
||||
console.log(`[AttachmentUploader] Read binary data: ${binary.byteLength} bytes`);
|
||||
|
||||
if (binary.byteLength > MAX_UPLOAD_BYTES) {
|
||||
throw new Error(
|
||||
`File too large for Notion upload (max 5MB): ${file.path} (${(binary.byteLength / 1024 / 1024).toFixed(2)} MB)`,
|
||||
);
|
||||
}
|
||||
|
||||
const uploadUrl =
|
||||
session.upload_url ??
|
||||
`https://api.notion.com/v1/file_uploads/${encodeURIComponent(session.id)}/send`;
|
||||
await this.sendFileData(session.id, uploadUrl, binary, notionAPI, file.name, contentType);
|
||||
|
||||
console.log(`[AttachmentUploader] Upload complete: ${file.name} -> ${session.id}`);
|
||||
return { id: session.id, filename: file.name };
|
||||
}
|
||||
|
||||
private async createUploadSession(params: {
|
||||
mode: string;
|
||||
notionAPI: string;
|
||||
}): Promise<FileUploadSession> {
|
||||
console.log(`[AttachmentUploader] Creating upload session:`, {
|
||||
mode: params.mode,
|
||||
});
|
||||
|
||||
const response = await this.requestWithRetry({
|
||||
url: "https://api.notion.com/v1/file_uploads",
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${params.notionAPI}`,
|
||||
"Notion-Version": NOTION_API_VERSION,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
mode: params.mode,
|
||||
}),
|
||||
throw: false,
|
||||
});
|
||||
|
||||
const data = response.json;
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
console.error(`[AttachmentUploader] Failed to create upload session:`, {
|
||||
status: response.status,
|
||||
message: data?.message,
|
||||
response: data,
|
||||
});
|
||||
throw new Error(`Failed to create upload session: ${data?.message ?? response.status}`);
|
||||
}
|
||||
|
||||
const id = data?.id ?? data?.file_upload?.id;
|
||||
if (!id) {
|
||||
throw new Error("Upload session response missing id");
|
||||
}
|
||||
|
||||
return { id, status: data.status, upload_url: data.upload_url };
|
||||
}
|
||||
|
||||
private async sendFileData(
|
||||
fileUploadId: string,
|
||||
uploadUrl: string,
|
||||
binary: ArrayBuffer,
|
||||
notionAPI: string,
|
||||
filename: string,
|
||||
contentType: string,
|
||||
): Promise<void> {
|
||||
console.log(`[AttachmentUploader] Sending file data for session: ${fileUploadId} (${binary.byteLength} bytes)`);
|
||||
|
||||
const { body, boundary } = this.buildMultipartBody({
|
||||
fieldName: "file",
|
||||
filename,
|
||||
contentType: contentType || "application/octet-stream",
|
||||
binary,
|
||||
});
|
||||
|
||||
const response = await this.requestWithRetry({
|
||||
url: uploadUrl,
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||||
Authorization: `Bearer ${notionAPI}`,
|
||||
"Notion-Version": NOTION_API_VERSION,
|
||||
},
|
||||
body,
|
||||
throw: false,
|
||||
});
|
||||
|
||||
const data = response.json;
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
console.error(`[AttachmentUploader] Failed to send file data:`, {
|
||||
sessionId: fileUploadId,
|
||||
status: response.status,
|
||||
message: data?.message,
|
||||
response: data,
|
||||
});
|
||||
throw new Error(`Failed to send file data: ${data?.message ?? response.status}`);
|
||||
}
|
||||
|
||||
console.log(`[AttachmentUploader] File data sent successfully for session: ${fileUploadId}`);
|
||||
}
|
||||
|
||||
private buildMultipartBody(params: {
|
||||
fieldName: string;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
binary: ArrayBuffer;
|
||||
}): { body: ArrayBuffer; boundary: string } {
|
||||
const boundary = `----NotionFormBoundary${Math.random().toString(16).slice(2)}${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
const safeFilename = params.filename.replace(/"/g, '\\"');
|
||||
const prefix = [
|
||||
`--${boundary}\r\n`,
|
||||
`Content-Disposition: form-data; name="${params.fieldName}"; filename="${safeFilename}"\r\n`,
|
||||
`Content-Type: ${params.contentType}\r\n`,
|
||||
`\r\n`,
|
||||
].join("");
|
||||
const suffix = `\r\n--${boundary}--\r\n`;
|
||||
|
||||
const prefixBytes = this.textEncoder.encode(prefix);
|
||||
const fileBytes = new Uint8Array(params.binary);
|
||||
const suffixBytes = this.textEncoder.encode(suffix);
|
||||
|
||||
const out = new Uint8Array(prefixBytes.length + fileBytes.length + suffixBytes.length);
|
||||
out.set(prefixBytes, 0);
|
||||
out.set(fileBytes, prefixBytes.length);
|
||||
out.set(suffixBytes, prefixBytes.length + fileBytes.length);
|
||||
return { body: out.buffer, boundary };
|
||||
}
|
||||
|
||||
private async requestWithRetry(params: any, maxAttempts = 4): Promise<any> {
|
||||
let attempt = 0;
|
||||
let lastError: unknown;
|
||||
|
||||
while (attempt < maxAttempts) {
|
||||
attempt++;
|
||||
try {
|
||||
const response = await requestUrl(params);
|
||||
if (this.shouldRetry(response.status) && attempt < maxAttempts) {
|
||||
const delayMs = this.getRetryDelay(response, attempt);
|
||||
console.warn(`[AttachmentUploader] Retryable status ${response.status}, attempt ${attempt}/${maxAttempts}, retrying in ${delayMs}ms`);
|
||||
await this.sleep(delayMs);
|
||||
continue;
|
||||
}
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
lastError = error;
|
||||
console.error(`[AttachmentUploader] Request failed, attempt ${attempt}/${maxAttempts}:`, error);
|
||||
if (attempt >= maxAttempts) break;
|
||||
const delayMs = this.getRetryDelay(undefined, attempt);
|
||||
console.warn(`[AttachmentUploader] Retrying in ${delayMs}ms`);
|
||||
await this.sleep(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`[AttachmentUploader] Request failed after ${maxAttempts} attempts`);
|
||||
throw lastError ?? new Error("Request failed after retries");
|
||||
}
|
||||
|
||||
private shouldRetry(status: number): boolean {
|
||||
return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
|
||||
}
|
||||
|
||||
private getRetryDelay(response: any, attempt: number): number {
|
||||
const retryAfter = response?.headers?.["retry-after"] ?? response?.headers?.["Retry-After"];
|
||||
if (retryAfter) {
|
||||
const seconds = parseInt(retryAfter, 10);
|
||||
if (!isNaN(seconds)) return seconds * 1000;
|
||||
}
|
||||
const base = 500;
|
||||
const max = 8000;
|
||||
const expo = Math.min(max, base * Math.pow(2, attempt - 1));
|
||||
const jitter = Math.floor(Math.random() * 250);
|
||||
return expo + jitter;
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private getContentType(extension: string): string {
|
||||
const ext = extension.toLowerCase();
|
||||
const mimeTypes: Record<string, string> = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
svg: "image/svg+xml",
|
||||
heic: "image/heic",
|
||||
tif: "image/tiff",
|
||||
tiff: "image/tiff",
|
||||
bmp: "image/bmp",
|
||||
pdf: "application/pdf",
|
||||
};
|
||||
return mimeTypes[ext] ?? "application/octet-stream";
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import MyPlugin from "src/main";
|
||||
import { DatabaseDetails } from "../../ui/settingTabs";
|
||||
import { i18nConfig } from "../../lang/I18n";
|
||||
|
||||
const NOTION_API_VERSION = "2025-09-03";
|
||||
|
||||
export interface NotionPageResponse {
|
||||
response: any;
|
||||
data: any;
|
||||
@@ -16,12 +18,20 @@ interface PreparedBlocks {
|
||||
export abstract class UploadBase {
|
||||
protected plugin: MyPlugin;
|
||||
protected dbDetails: DatabaseDetails;
|
||||
protected isAutoSync = false;
|
||||
|
||||
protected constructor(plugin: MyPlugin, dbDetails: DatabaseDetails) {
|
||||
this.plugin = plugin;
|
||||
this.dbDetails = dbDetails;
|
||||
}
|
||||
|
||||
private shouldShowSuccessNotices(): boolean {
|
||||
if (!this.isAutoSync) {
|
||||
return true;
|
||||
}
|
||||
return !!this.plugin.settings.autoSyncSuccessNotice;
|
||||
}
|
||||
|
||||
async deletePage(notionID: string) {
|
||||
const {notionAPI} = this.dbDetails;
|
||||
return requestUrl({
|
||||
@@ -30,7 +40,7 @@ export abstract class UploadBase {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + notionAPI,
|
||||
"Notion-Version": "2022-06-28",
|
||||
"Notion-Version": NOTION_API_VERSION,
|
||||
},
|
||||
body: "",
|
||||
throw: false,
|
||||
@@ -116,7 +126,7 @@ export abstract class UploadBase {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + notionAPI,
|
||||
"Notion-Version": "2022-06-28",
|
||||
"Notion-Version": NOTION_API_VERSION,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
throw: false,
|
||||
@@ -171,7 +181,7 @@ export abstract class UploadBase {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + notionAPI,
|
||||
"Notion-Version": "2022-06-28",
|
||||
"Notion-Version": NOTION_API_VERSION,
|
||||
},
|
||||
body: JSON.stringify(extraBlocks),
|
||||
throw: false,
|
||||
@@ -188,11 +198,13 @@ export abstract class UploadBase {
|
||||
console.log(`${i18nConfig["ExtraBlockUploaded"]} to page: ${pageId}`);
|
||||
if (i === extraChunks.length - 1) {
|
||||
console.log(`${i18nConfig["BlockUploaded"]} to page: ${pageId}`);
|
||||
if (this.shouldShowSuccessNotices()) {
|
||||
new Notice(`${i18nConfig["BlockUploaded"]} page: ${pageId}`, 5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchDatabaseCover(): Promise<string | null> {
|
||||
const {notionAPI, databaseID} = this.dbDetails;
|
||||
@@ -201,7 +213,7 @@ export abstract class UploadBase {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: "Bearer " + notionAPI,
|
||||
"Notion-Version": "2022-06-28",
|
||||
"Notion-Version": NOTION_API_VERSION,
|
||||
},
|
||||
throw: false,
|
||||
}).catch((error) =>
|
||||
|
||||
@@ -10,6 +10,10 @@ import {getNowFileMarkdownContentCustom} from "./common/getMarkdownCustom";
|
||||
|
||||
const SYNC_ERROR_NOTICE_DURATION = 8000;
|
||||
|
||||
interface UploadCommandOptions {
|
||||
isAutoSync?: boolean;
|
||||
}
|
||||
|
||||
function extractErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
@@ -17,6 +21,16 @@ function extractErrorMessage(error: unknown): string {
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function shouldShowAutoSyncSuccessNotice(
|
||||
plugin: ObsidianSyncNotionPlugin,
|
||||
options?: UploadCommandOptions,
|
||||
): boolean {
|
||||
if (!options?.isAutoSync) {
|
||||
return true;
|
||||
}
|
||||
return !!plugin.settings.autoSyncSuccessNotice;
|
||||
}
|
||||
|
||||
function notifySyncError(prefix: string, basename: string, error: unknown): void {
|
||||
const errorMessage = extractErrorMessage(error);
|
||||
console.error(`${prefix} Sync failed`, error);
|
||||
@@ -39,6 +53,7 @@ export async function uploadCommandNext(
|
||||
settings: PluginSettings,
|
||||
dbDetails: DatabaseDetails,
|
||||
app: App,
|
||||
options?: UploadCommandOptions,
|
||||
) {
|
||||
|
||||
const {notionAPI, databaseID} = dbDetails;
|
||||
@@ -91,6 +106,7 @@ export async function uploadCommandNext(
|
||||
try {
|
||||
res = await upload.sync({
|
||||
dataset: "next",
|
||||
isAutoSync: options?.isAutoSync,
|
||||
title: basename,
|
||||
emoji: emoji || "",
|
||||
cover: cover || "",
|
||||
@@ -118,7 +134,9 @@ export async function uploadCommandNext(
|
||||
|
||||
const {response} = res;
|
||||
if (response.status === 200) {
|
||||
if (shouldShowAutoSyncSuccessNotice(plugin, options)) {
|
||||
new Notice(`${i18nConfig["sync-preffix"]} ${basename} ${i18nConfig["sync-success"]}`).noticeEl.style.color = "green";
|
||||
}
|
||||
|
||||
logCommandDebug("uploadCommandNext", "Sync succeeded", {
|
||||
filename: basename,
|
||||
@@ -146,6 +164,7 @@ export async function uploadCommandGeneral(
|
||||
settings: PluginSettings,
|
||||
dbDetails: DatabaseDetails,
|
||||
app: App,
|
||||
options?: UploadCommandOptions,
|
||||
) {
|
||||
|
||||
const {notionAPI, databaseID} = dbDetails;
|
||||
@@ -160,7 +179,9 @@ export async function uploadCommandGeneral(
|
||||
|
||||
const {markDownData, nowFile, cover, tags} = await getNowFileMarkdownContentGeneral(app, settings)
|
||||
|
||||
if (!options?.isAutoSync) {
|
||||
new Notice(i18nConfig.StartUpload.replace('{filename}', nowFile.basename));
|
||||
}
|
||||
console.log(`Start upload ${nowFile.basename}`);
|
||||
|
||||
if (markDownData) {
|
||||
@@ -177,6 +198,7 @@ export async function uploadCommandGeneral(
|
||||
try {
|
||||
res = await upload.sync({
|
||||
dataset: "general",
|
||||
isAutoSync: options?.isAutoSync,
|
||||
title: basename,
|
||||
cover: cover || "",
|
||||
tags: tags || [],
|
||||
@@ -195,7 +217,9 @@ export async function uploadCommandGeneral(
|
||||
|
||||
const {response} = res;
|
||||
if (response.status === 200) {
|
||||
if (shouldShowAutoSyncSuccessNotice(plugin, options)) {
|
||||
new Notice(`${i18nConfig["sync-preffix"]} ${basename} ${i18nConfig["sync-success"]}`).noticeEl.style.color = "green";
|
||||
}
|
||||
|
||||
logCommandDebug("uploadCommandGeneral", "Sync succeeded", {
|
||||
filename: basename,
|
||||
@@ -223,6 +247,7 @@ export async function uploadCommandCustom(
|
||||
settings: PluginSettings,
|
||||
dbDetails: DatabaseDetails,
|
||||
app: App,
|
||||
options?: UploadCommandOptions,
|
||||
) {
|
||||
|
||||
const {notionAPI, databaseID} = dbDetails;
|
||||
@@ -237,7 +262,9 @@ export async function uploadCommandCustom(
|
||||
|
||||
const {markDownData, nowFile, cover, customValues} = await getNowFileMarkdownContentCustom(app, dbDetails)
|
||||
|
||||
if (!options?.isAutoSync) {
|
||||
new Notice(i18nConfig.StartUpload.replace('{filename}', nowFile.basename));
|
||||
}
|
||||
console.log(`Start upload ${nowFile.basename}`);
|
||||
|
||||
if (markDownData) {
|
||||
@@ -254,6 +281,7 @@ export async function uploadCommandCustom(
|
||||
try {
|
||||
res = await upload.sync({
|
||||
dataset: "custom",
|
||||
isAutoSync: options?.isAutoSync,
|
||||
cover: cover || "",
|
||||
customValues,
|
||||
markdown: markDownData,
|
||||
@@ -272,7 +300,9 @@ export async function uploadCommandCustom(
|
||||
const {response} = res;
|
||||
|
||||
if (response.status === 200) {
|
||||
if (shouldShowAutoSyncSuccessNotice(plugin, options)) {
|
||||
new Notice(`${i18nConfig["sync-preffix"]} ${basename} ${i18nConfig["sync-success"]}`).noticeEl.style.color = "green";
|
||||
}
|
||||
|
||||
logCommandDebug("uploadCommandCustom", "Sync succeeded", {
|
||||
filename: basename,
|
||||
|
||||
@@ -12,3 +12,40 @@ writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
|
||||
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
|
||||
versions[targetVersion] = minAppVersion;
|
||||
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));
|
||||
|
||||
// tag the changelog by converting "Unreleased" to the new version section
|
||||
function bumpChangelog(version) {
|
||||
const changelogPath = "CHANGELOG.md";
|
||||
const content = readFileSync(changelogPath, "utf8");
|
||||
|
||||
const unreleasedHeader = "## Unreleased";
|
||||
if (!content.includes(unreleasedHeader)) {
|
||||
console.warn(`[version-bump] ${unreleasedHeader} not found in ${changelogPath}, skipping changelog tagging`);
|
||||
return;
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const releaseHeader = `## ${version} (${today})`;
|
||||
const updated = content.replace(unreleasedHeader, releaseHeader);
|
||||
|
||||
const marker = "# Changelog\n\n";
|
||||
if (!updated.startsWith(marker)) {
|
||||
writeFileSync(changelogPath, updated);
|
||||
return;
|
||||
}
|
||||
|
||||
const newUnreleased = [
|
||||
"## Unreleased",
|
||||
"",
|
||||
"### Added",
|
||||
"",
|
||||
"### Changed",
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
writeFileSync(changelogPath, marker + newUnreleased + updated.slice(marker.length));
|
||||
}
|
||||
|
||||
bumpChangelog(targetVersion);
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"1.0.0": "0.9.7",
|
||||
"1.0.1": "0.12.0"
|
||||
"1.0.1": "0.12.0",
|
||||
"2.8.0": "0.0.1",
|
||||
"2.8.1": "0.0.1",
|
||||
"2.8.2": "0.0.1",
|
||||
"2.8.3": "0.0.1",
|
||||
"2.8.4": "0.0.1"
|
||||
}
|
||||
Reference in New Issue
Block a user