mirror of
https://github.com/jxpeng98/obsidian-to-NotionNext
synced 2026-07-29 08:08:34 +08:00
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.
This commit is contained in:
65
src/main.ts
65
src/main.ts
@@ -4,6 +4,7 @@ import { i18nConfig } from "src/lang/I18n";
|
|||||||
import ribbonCommands from "src/commands/NotionCommands";
|
import ribbonCommands from "src/commands/NotionCommands";
|
||||||
import { ObsidianSettingTab, PluginSettings, DEFAULT_SETTINGS, DatabaseDetails } from "src/ui/settingTabs";
|
import { ObsidianSettingTab, PluginSettings, DEFAULT_SETTINGS, DatabaseDetails } from "src/ui/settingTabs";
|
||||||
import { uploadCommandNext, uploadCommandGeneral, uploadCommandCustom } from "src/upload/uploadCommand";
|
import { uploadCommandNext, uploadCommandGeneral, uploadCommandCustom } from "src/upload/uploadCommand";
|
||||||
|
import { AttachmentProcessor } from "src/upload/common/AttachmentProcessor";
|
||||||
import { DEFAULT_AUTO_SYNC_DATABASE_KEY, parseAutoSyncDatabaseList, resolveAutoSyncKey } from "src/utils/frontmatter";
|
import { DEFAULT_AUTO_SYNC_DATABASE_KEY, parseAutoSyncDatabaseList, resolveAutoSyncKey } from "src/utils/frontmatter";
|
||||||
|
|
||||||
// Remember to rename these classes and interfaces!
|
// Remember to rename these classes and interfaces!
|
||||||
@@ -18,6 +19,7 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
|
|||||||
private syncingFiles: Set<string> = new Set();
|
private syncingFiles: Set<string> = new Set();
|
||||||
private lastFrontmatterCache: Map<string, any> = new Map();
|
private lastFrontmatterCache: Map<string, any> = new Map();
|
||||||
private lastContentHashCache: Map<string, string> = new Map();
|
private lastContentHashCache: Map<string, string> = new Map();
|
||||||
|
private autoSyncAttachmentBlocked: Set<string> = new Set();
|
||||||
|
|
||||||
|
|
||||||
async onload() {
|
async onload() {
|
||||||
@@ -69,6 +71,7 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
this.lastFrontmatterCache.clear();
|
this.lastFrontmatterCache.clear();
|
||||||
this.lastContentHashCache.clear();
|
this.lastContentHashCache.clear();
|
||||||
|
this.autoSyncAttachmentBlocked.clear();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,7 +318,14 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
|
|||||||
// Get file's frontmatter
|
// Get file's frontmatter
|
||||||
const frontMatter = this.app.metadataCache.getFileCache(file)?.frontmatter;
|
const frontMatter = this.app.metadataCache.getFileCache(file)?.frontmatter;
|
||||||
if (!frontMatter) {
|
if (!frontMatter) {
|
||||||
console.log(`[AutoSync] No frontmatter found in ${file.path}`);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check autosync property first - only proceed if it exists
|
||||||
|
const autoSyncKey = this.getAutoSyncFrontmatterKey();
|
||||||
|
const autoSyncTargets = parseAutoSyncDatabaseList(frontMatter[autoSyncKey]);
|
||||||
|
|
||||||
|
if (autoSyncTargets.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,50 +341,11 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
|
|||||||
const frontmatterOnlyNotionIDChanged = this.onlyNotionIDChanged(lastFrontmatter, frontMatter);
|
const frontmatterOnlyNotionIDChanged = this.onlyNotionIDChanged(lastFrontmatter, frontMatter);
|
||||||
const contentUnchanged = contentHash === lastContentHash;
|
const contentUnchanged = contentHash === lastContentHash;
|
||||||
|
|
||||||
console.log(`[AutoSync] Change analysis for ${file.basename}:`, {
|
|
||||||
frontmatterOnlyNotionIDChanged,
|
|
||||||
contentUnchanged,
|
|
||||||
frontmatterHasRealChanges: !frontmatterOnlyNotionIDChanged,
|
|
||||||
contentChanged: !contentUnchanged,
|
|
||||||
willSync: !(frontmatterOnlyNotionIDChanged && contentUnchanged)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Only skip sync if BOTH conditions are true:
|
|
||||||
// 1. Frontmatter only has NotionID changes (no real user changes)
|
|
||||||
// 2. Content is completely unchanged
|
|
||||||
if (frontmatterOnlyNotionIDChanged && contentUnchanged) {
|
if (frontmatterOnlyNotionIDChanged && contentUnchanged) {
|
||||||
console.log(`[AutoSync] Only NotionID updated (from sync), content unchanged - skipping auto sync`);
|
|
||||||
// Update cache even when skipping, so next comparison uses the current state
|
|
||||||
this.lastFrontmatterCache.set(file.path, { ...frontMatter });
|
this.lastFrontmatterCache.set(file.path, { ...frontMatter });
|
||||||
this.lastContentHashCache.set(file.path, contentHash);
|
this.lastContentHashCache.set(file.path, contentHash);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!contentUnchanged) {
|
|
||||||
console.log(`[AutoSync] Content changed - will sync`);
|
|
||||||
} else if (!frontmatterOnlyNotionIDChanged) {
|
|
||||||
console.log(`[AutoSync] Frontmatter changed - will sync`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const autoSyncKey = this.getAutoSyncFrontmatterKey();
|
|
||||||
const autoSyncTargets = parseAutoSyncDatabaseList(frontMatter[autoSyncKey]);
|
|
||||||
|
|
||||||
// If no autosync-database field specified
|
|
||||||
if (autoSyncTargets.length === 0) {
|
|
||||||
// Check if file has any existing NotionID (meaning it was synced before)
|
|
||||||
const hasExistingNotionID = Object.keys(frontMatter).some(key =>
|
|
||||||
key.startsWith('NotionID-') || key === 'NotionID'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasExistingNotionID) {
|
|
||||||
// User likely forgot to add autosync-database - show a notice
|
|
||||||
const message = i18nConfig.AutoSyncMissingDatabaseList.replace('{key}', autoSyncKey);
|
|
||||||
new Notice(message, 8000);
|
|
||||||
console.log(`[AutoSync] File ${file.path} has NotionID but missing autosync-database - showing notice`);
|
|
||||||
}
|
|
||||||
// Silently skip files without NotionID (new files that don't need auto-sync)
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -415,6 +386,20 @@ export default class ObsidianSyncNotionPlugin extends Plugin {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Temporarily disable auto-sync for files containing internal attachments (local images/PDFs)
|
||||||
|
const attachmentProcessor = new AttachmentProcessor(this, foundDatabases[0].dbDetails);
|
||||||
|
if (attachmentProcessor.hasInternalAttachments(content, file)) {
|
||||||
|
if (!this.autoSyncAttachmentBlocked.has(file.path)) {
|
||||||
|
const message = i18nConfig.AutoSyncSkippedAttachments
|
||||||
|
.replace('{filename}', file.basename);
|
||||||
|
new Notice(message, 6000);
|
||||||
|
this.autoSyncAttachmentBlocked.add(file.path);
|
||||||
|
}
|
||||||
|
console.log(`[AutoSync] Internal attachments detected in ${file.path}, auto-sync skipped`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.autoSyncAttachmentBlocked.delete(file.path);
|
||||||
|
|
||||||
// Notify user about multiple syncs if applicable
|
// Notify user about multiple syncs if applicable
|
||||||
if (foundDatabases.length > 1) {
|
if (foundDatabases.length > 1) {
|
||||||
const message = i18nConfig.AutoSyncMultipleSync.replace('{count}', String(foundDatabases.length));
|
const message = i18nConfig.AutoSyncMultipleSync.replace('{count}', String(foundDatabases.length));
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import MyPlugin from "src/main";
|
|||||||
import { DatabaseDetails } from "../ui/settingTabs";
|
import { DatabaseDetails } from "../ui/settingTabs";
|
||||||
import { updateYamlInfo } from "./updateYaml";
|
import { updateYamlInfo } from "./updateYaml";
|
||||||
import { UploadBase, NotionPageResponse } from "./common/UploadBase";
|
import { UploadBase, NotionPageResponse } from "./common/UploadBase";
|
||||||
|
import { AttachmentProcessor, applyBlockRewrites } from "./common/AttachmentProcessor";
|
||||||
|
|
||||||
export type DatasetType = "general" | "next" | "custom";
|
export type DatasetType = "general" | "next" | "custom";
|
||||||
|
|
||||||
@@ -128,7 +129,7 @@ export class Upload2Notion extends UploadBase {
|
|||||||
cover: request.cover,
|
cover: request.cover,
|
||||||
tags: request.tags,
|
tags: request.tags,
|
||||||
});
|
});
|
||||||
const blocks = this.buildBlocks(request.markdown, {
|
const blocks = await this.buildBlocks(request.markdown, request.nowFile, {
|
||||||
notionLimits: {truncate: false},
|
notionLimits: {truncate: false},
|
||||||
});
|
});
|
||||||
const notionId = this.getNotionId(request.app, request.nowFile);
|
const notionId = this.getNotionId(request.app, request.nowFile);
|
||||||
@@ -153,7 +154,7 @@ export class Upload2Notion extends UploadBase {
|
|||||||
slug: request.slug,
|
slug: request.slug,
|
||||||
category: request.category,
|
category: request.category,
|
||||||
});
|
});
|
||||||
const blocks = this.buildBlocks(request.markdown, {
|
const blocks = await this.buildBlocks(request.markdown, request.nowFile, {
|
||||||
notionLimits: {truncate: false},
|
notionLimits: {truncate: false},
|
||||||
});
|
});
|
||||||
this.splitRichTextParagraphs(blocks);
|
this.splitRichTextParagraphs(blocks);
|
||||||
@@ -187,7 +188,7 @@ export class Upload2Notion extends UploadBase {
|
|||||||
console.log(`[Upload2Notion] Handling custom dataset`, {
|
console.log(`[Upload2Notion] Handling custom dataset`, {
|
||||||
customKeys: Object.keys(request.customValues || {}),
|
customKeys: Object.keys(request.customValues || {}),
|
||||||
});
|
});
|
||||||
const blocks = this.buildBlocks(request.markdown, {
|
const blocks = await this.buildBlocks(request.markdown, request.nowFile, {
|
||||||
strictImageUrls: true,
|
strictImageUrls: true,
|
||||||
notionLimits: {truncate: false},
|
notionLimits: {truncate: false},
|
||||||
});
|
});
|
||||||
@@ -206,10 +207,24 @@ export class Upload2Notion extends UploadBase {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildBlocks(markdown: string, options: Record<string, unknown>): any[] {
|
private async buildBlocks(markdown: string, nowFile: TFile, options: Record<string, unknown>): Promise<any[]> {
|
||||||
const yamlContent: any = yamlFrontMatter.loadFront(markdown);
|
const yamlContent: any = yamlFrontMatter.loadFront(markdown);
|
||||||
const content = yamlContent.__content;
|
let content: string = yamlContent.__content ?? "";
|
||||||
|
|
||||||
|
// Process local attachments
|
||||||
|
const processor = new AttachmentProcessor(this.plugin, this.dbDetails);
|
||||||
|
const result = await processor.processContent(content, nowFile);
|
||||||
|
content = result.content;
|
||||||
|
const imageUrlToUploadId = result.imageUrlToUploadId;
|
||||||
|
const filePlaceholderToUpload = result.filePlaceholderToUpload;
|
||||||
|
|
||||||
const blocks = markdownToBlocks(content, options);
|
const blocks = markdownToBlocks(content, options);
|
||||||
|
|
||||||
|
// Apply block rewrites for uploaded files
|
||||||
|
if (Object.keys(imageUrlToUploadId).length > 0 || Object.keys(filePlaceholderToUpload).length > 0) {
|
||||||
|
applyBlockRewrites(blocks, { imageUrlToUploadId, filePlaceholderToUpload });
|
||||||
|
}
|
||||||
|
|
||||||
this.debugLog("Upload2Notion", "Converted markdown to blocks", {
|
this.debugLog("Upload2Notion", "Converted markdown to blocks", {
|
||||||
blockCount: blocks.length,
|
blockCount: blocks.length,
|
||||||
firstBlockTypes: blocks.slice(0, 5).map((block: any) => block?.type),
|
firstBlockTypes: blocks.slice(0, 5).map((block: any) => block?.type),
|
||||||
|
|||||||
697
src/upload/common/AttachmentProcessor.ts
Normal file
697
src/upload/common/AttachmentProcessor.ts
Normal file
@@ -0,0 +1,697 @@
|
|||||||
|
import { App, TFile, normalizePath } from "obsidian";
|
||||||
|
import { AttachmentUploader } from "./AttachmentUploader";
|
||||||
|
import type MyPlugin from "src/main";
|
||||||
|
import type { DatabaseDetails } from "../../ui/settingTabs";
|
||||||
|
|
||||||
|
export interface AttachmentPrepareResult {
|
||||||
|
content: string;
|
||||||
|
imageUrlToUploadId: Record<string, string>;
|
||||||
|
filePlaceholderToUpload: Record<string, { id: string; name: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocalAttachment {
|
||||||
|
file: TFile;
|
||||||
|
originalRef: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMAGE_EXTENSIONS = new Set([
|
||||||
|
"png", "jpg", "jpeg", "gif", "webp", "svg", "heic", "tif", "tiff", "bmp"
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SUPPORTED_EXTENSIONS = new Set([
|
||||||
|
...IMAGE_EXTENSIONS, "pdf"
|
||||||
|
]);
|
||||||
|
|
||||||
|
export class AttachmentProcessor {
|
||||||
|
private plugin: MyPlugin;
|
||||||
|
private dbDetails: DatabaseDetails;
|
||||||
|
private uploader: AttachmentUploader;
|
||||||
|
|
||||||
|
constructor(plugin: MyPlugin, dbDetails: DatabaseDetails) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.dbDetails = dbDetails;
|
||||||
|
this.uploader = new AttachmentUploader(plugin, dbDetails);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isStandaloneOnLine(input: string, offset: number, match: string): boolean {
|
||||||
|
const lineStart = input.lastIndexOf("\n", Math.max(0, offset - 1)) + 1;
|
||||||
|
const lineEndIdx = input.indexOf("\n", offset + match.length);
|
||||||
|
const lineEnd = lineEndIdx === -1 ? input.length : lineEndIdx;
|
||||||
|
const before = input.slice(lineStart, offset).trim();
|
||||||
|
const after = input.slice(offset + match.length, lineEnd).trim();
|
||||||
|
return before.length === 0 && after.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasInternalAttachments(content: string, sourceFile: TFile): boolean {
|
||||||
|
const app = this.plugin.app;
|
||||||
|
const contentWithoutCode = content.replace(/```[\s\S]*?```|`[^`\n]+`/g, "");
|
||||||
|
|
||||||
|
const embedImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||||
|
const embedWikilinkRegex = /!\[\[([^\]]+)\]\]/g;
|
||||||
|
const linkMarkdownRegex = /(?<!!)\[([^\]]+)\]\(([^)]+)\)/g;
|
||||||
|
const linkWikilinkRegex = /(?<!!)\[\[([^\]]+)\]\]/g;
|
||||||
|
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = embedImageRegex.exec(contentWithoutCode)) !== null) {
|
||||||
|
const rawPath = this.parseDestination(match[2]);
|
||||||
|
if (this.shouldSkipLinkDestination(rawPath)) continue;
|
||||||
|
const file = this.resolveFile(app, sourceFile, rawPath, { log: false });
|
||||||
|
if (file && this.isSupported(file)) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
while ((match = embedWikilinkRegex.exec(contentWithoutCode)) !== null) {
|
||||||
|
const linkPath = this.parseWikilink(match[1]);
|
||||||
|
if (this.shouldSkipLinkDestination(linkPath)) continue;
|
||||||
|
const file = this.resolveFile(app, sourceFile, linkPath, { log: false });
|
||||||
|
if (file && this.isSupported(file)) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
while ((match = linkMarkdownRegex.exec(contentWithoutCode)) !== null) {
|
||||||
|
const rawPath = this.parseDestination(match[2]);
|
||||||
|
if (this.shouldSkipLinkDestination(rawPath)) continue;
|
||||||
|
const file = this.resolveFile(app, sourceFile, rawPath, { log: false });
|
||||||
|
if (file && this.isSupported(file)) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
while ((match = linkWikilinkRegex.exec(contentWithoutCode)) !== null) {
|
||||||
|
const linkPath = this.parseWikilink(match[1]);
|
||||||
|
if (this.shouldSkipLinkDestination(linkPath)) continue;
|
||||||
|
const file = this.resolveFile(app, sourceFile, linkPath, { log: false });
|
||||||
|
if (file && this.isSupported(file)) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async processContent(content: string, sourceFile: TFile): Promise<AttachmentPrepareResult> {
|
||||||
|
console.log(`[AttachmentProcessor] Starting attachment processing for file: ${sourceFile.path}`);
|
||||||
|
|
||||||
|
// Strip code blocks before processing to avoid matching inside them
|
||||||
|
const codeBlockPlaceholders: string[] = [];
|
||||||
|
const contentWithoutCode = content.replace(/```[\s\S]*?```|`[^`\n]+`/g, (match) => {
|
||||||
|
const placeholder = `__CODE_BLOCK_${codeBlockPlaceholders.length}__`;
|
||||||
|
codeBlockPlaceholders.push(match);
|
||||||
|
return placeholder;
|
||||||
|
});
|
||||||
|
console.log(`[AttachmentProcessor] Stripped ${codeBlockPlaceholders.length} code blocks`);
|
||||||
|
|
||||||
|
const { internal, external } = this.collectAttachments(contentWithoutCode, sourceFile);
|
||||||
|
|
||||||
|
if (external.length > 0) {
|
||||||
|
console.log(`[AttachmentProcessor] Found ${external.length} external reference(s) (will be skipped):`);
|
||||||
|
external.forEach((ref, idx) => {
|
||||||
|
console.log(` ${idx + 1}. [EXTERNAL] ${ref}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (internal.length === 0) {
|
||||||
|
console.log(`[AttachmentProcessor] No internal attachments found in ${sourceFile.path}`);
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
imageUrlToUploadId: {},
|
||||||
|
filePlaceholderToUpload: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[AttachmentProcessor] Found ${internal.length} internal attachment(s) to upload:`);
|
||||||
|
internal.forEach((attachment, idx) => {
|
||||||
|
const typeLabel = this.isImage(attachment.file) ? 'IMAGE' : 'FILE';
|
||||||
|
const sizeKB = (attachment.file.stat.size / 1024).toFixed(2);
|
||||||
|
console.log(` ${idx + 1}. [${typeLabel}] ${attachment.file.path} (${sizeKB} KB) - Ref: "${attachment.originalRef}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadedMap = new Map<string, { id: string; file: TFile }>();
|
||||||
|
|
||||||
|
for (const attachment of internal) {
|
||||||
|
try {
|
||||||
|
console.log(`[AttachmentProcessor] Uploading: ${attachment.file.path} (${(attachment.file.stat.size / 1024).toFixed(2)} KB)`);
|
||||||
|
const result = await this.uploader.uploadFile(attachment.file);
|
||||||
|
uploadedMap.set(attachment.file.path, { id: result.id, file: attachment.file });
|
||||||
|
console.log(`[AttachmentProcessor] ✓ Uploaded successfully: ${attachment.file.name} -> ${result.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[AttachmentProcessor] ✗ Failed to upload ${attachment.file.path}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[AttachmentProcessor] Upload complete: ${uploadedMap.size}/${internal.length} successful`);
|
||||||
|
|
||||||
|
const rewriteResult = this.rewriteContent(contentWithoutCode, sourceFile, uploadedMap);
|
||||||
|
|
||||||
|
// Restore code blocks
|
||||||
|
let restoredContent = rewriteResult.content;
|
||||||
|
codeBlockPlaceholders.forEach((code, idx) => {
|
||||||
|
restoredContent = restoredContent.replace(`__CODE_BLOCK_${idx}__`, code);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[AttachmentProcessor] Content rewrite complete:`, {
|
||||||
|
imageReplacements: Object.keys(rewriteResult.imageUrlToUploadId).length,
|
||||||
|
fileReplacements: Object.keys(rewriteResult.filePlaceholderToUpload).length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: restoredContent,
|
||||||
|
imageUrlToUploadId: rewriteResult.imageUrlToUploadId,
|
||||||
|
filePlaceholderToUpload: rewriteResult.filePlaceholderToUpload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private collectAttachments(content: string, sourceFile: TFile): { internal: LocalAttachment[]; external: string[] } {
|
||||||
|
const internal: LocalAttachment[] = [];
|
||||||
|
const external: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const app = this.plugin.app;
|
||||||
|
|
||||||
|
console.log(`[AttachmentProcessor] Scanning for attachments in content (${content.length} chars)`);
|
||||||
|
|
||||||
|
// Match all types of references:  ![[...]] [...](...) [[...]]
|
||||||
|
const embedImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||||
|
const embedWikilinkRegex = /!\[\[([^\]]+)\]\]/g;
|
||||||
|
const linkMarkdownRegex = /(?<!!)\[([^\]]+)\]\(([^)]+)\)/g;
|
||||||
|
const linkWikilinkRegex = /(?<!!)\[\[([^\]]+)\]\]/g;
|
||||||
|
|
||||||
|
let match;
|
||||||
|
|
||||||
|
// Process embedded images: 
|
||||||
|
let embedCount = 0;
|
||||||
|
while ((match = embedImageRegex.exec(content)) !== null) {
|
||||||
|
embedCount++;
|
||||||
|
const rawPath = this.parseDestination(match[2]);
|
||||||
|
console.log(`[AttachmentProcessor] Found embedded image #${embedCount}: "${match[0]}" -> parsed path: "${rawPath}"`);
|
||||||
|
|
||||||
|
if (this.shouldSkipLinkDestination(rawPath)) {
|
||||||
|
if (this.isTodoUrl(rawPath)) {
|
||||||
|
console.log(`[AttachmentProcessor] ⊘ Unsupported URL scheme (TODO, skipped): ${rawPath}`);
|
||||||
|
} else {
|
||||||
|
console.log(`[AttachmentProcessor] ⊘ External URL (skipped): ${rawPath}`);
|
||||||
|
}
|
||||||
|
external.push(match[0]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = this.resolveFile(app, sourceFile, rawPath);
|
||||||
|
if (!file) {
|
||||||
|
console.log(`[AttachmentProcessor] ✗ Could not resolve file for path: "${rawPath}"`);
|
||||||
|
} else if (!this.isSupported(file)) {
|
||||||
|
console.log(`[AttachmentProcessor] ✗ Unsupported file type: ${file.extension} (${file.path})`);
|
||||||
|
} else if (seen.has(file.path)) {
|
||||||
|
console.log(`[AttachmentProcessor] ⊚ Duplicate (already added): ${file.path}`);
|
||||||
|
} else {
|
||||||
|
seen.add(file.path);
|
||||||
|
internal.push({ file, originalRef: match[0] });
|
||||||
|
console.log(`[AttachmentProcessor] ✓ Added: ${file.path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process embedded wikilinks: ![[path]]
|
||||||
|
let wikiEmbedCount = 0;
|
||||||
|
while ((match = embedWikilinkRegex.exec(content)) !== null) {
|
||||||
|
wikiEmbedCount++;
|
||||||
|
const linkPath = this.parseWikilink(match[1]);
|
||||||
|
console.log(`[AttachmentProcessor] Found embedded wikilink #${wikiEmbedCount}: "${match[0]}" -> parsed path: "${linkPath}"`);
|
||||||
|
|
||||||
|
if (this.shouldSkipLinkDestination(linkPath)) {
|
||||||
|
if (this.isTodoUrl(linkPath)) {
|
||||||
|
console.log(`[AttachmentProcessor] ⊘ Unsupported URL scheme (TODO, skipped): ${linkPath}`);
|
||||||
|
} else {
|
||||||
|
console.log(`[AttachmentProcessor] ⊘ External URL (skipped): ${linkPath}`);
|
||||||
|
}
|
||||||
|
external.push(match[0]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = this.resolveFile(app, sourceFile, linkPath);
|
||||||
|
if (!file) {
|
||||||
|
console.log(`[AttachmentProcessor] ✗ Could not resolve file for path: "${linkPath}"`);
|
||||||
|
} else if (!this.isSupported(file)) {
|
||||||
|
console.log(`[AttachmentProcessor] ✗ Unsupported file type: ${file.extension} (${file.path})`);
|
||||||
|
} else if (seen.has(file.path)) {
|
||||||
|
console.log(`[AttachmentProcessor] ⊚ Duplicate (already added): ${file.path}`);
|
||||||
|
} else {
|
||||||
|
seen.add(file.path);
|
||||||
|
internal.push({ file, originalRef: match[0] });
|
||||||
|
console.log(`[AttachmentProcessor] ✓ Added: ${file.path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process markdown links: [text](path)
|
||||||
|
let linkCount = 0;
|
||||||
|
while ((match = linkMarkdownRegex.exec(content)) !== null) {
|
||||||
|
linkCount++;
|
||||||
|
const rawPath = this.parseDestination(match[2]);
|
||||||
|
console.log(`[AttachmentProcessor] Found markdown link #${linkCount}: "${match[0]}" -> parsed path: "${rawPath}"`);
|
||||||
|
|
||||||
|
if (this.shouldSkipLinkDestination(rawPath)) {
|
||||||
|
if (this.isTodoUrl(rawPath)) {
|
||||||
|
console.log(`[AttachmentProcessor] ⊘ Unsupported URL scheme (TODO, skipped): ${rawPath}`);
|
||||||
|
} else {
|
||||||
|
console.log(`[AttachmentProcessor] ⊘ External URL (skipped): ${rawPath}`);
|
||||||
|
}
|
||||||
|
external.push(match[0]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = this.resolveFile(app, sourceFile, rawPath);
|
||||||
|
if (!file) {
|
||||||
|
console.log(`[AttachmentProcessor] ✗ Could not resolve file for path: "${rawPath}"`);
|
||||||
|
} else if (!this.isSupported(file)) {
|
||||||
|
console.log(`[AttachmentProcessor] ✗ Unsupported file type: ${file.extension} (${file.path})`);
|
||||||
|
} else if (seen.has(file.path)) {
|
||||||
|
console.log(`[AttachmentProcessor] ⊚ Duplicate (already added): ${file.path}`);
|
||||||
|
} else {
|
||||||
|
seen.add(file.path);
|
||||||
|
internal.push({ file, originalRef: match[0] });
|
||||||
|
console.log(`[AttachmentProcessor] ✓ Added: ${file.path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process wikilink references: [[path]]
|
||||||
|
let wikilinkCount = 0;
|
||||||
|
while ((match = linkWikilinkRegex.exec(content)) !== null) {
|
||||||
|
wikilinkCount++;
|
||||||
|
const linkPath = this.parseWikilink(match[1]);
|
||||||
|
console.log(`[AttachmentProcessor] Found wikilink reference #${wikilinkCount}: "${match[0]}" -> parsed path: "${linkPath}"`);
|
||||||
|
|
||||||
|
if (this.shouldSkipLinkDestination(linkPath)) {
|
||||||
|
if (this.isTodoUrl(linkPath)) {
|
||||||
|
console.log(`[AttachmentProcessor] ⊘ Unsupported URL scheme (TODO, skipped): ${linkPath}`);
|
||||||
|
} else {
|
||||||
|
console.log(`[AttachmentProcessor] ⊘ External URL (skipped): ${linkPath}`);
|
||||||
|
}
|
||||||
|
external.push(match[0]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = this.resolveFile(app, sourceFile, linkPath);
|
||||||
|
if (!file) {
|
||||||
|
console.log(`[AttachmentProcessor] ✗ Could not resolve file for path: "${linkPath}"`);
|
||||||
|
} else if (!this.isSupported(file)) {
|
||||||
|
console.log(`[AttachmentProcessor] ✗ Unsupported file type: ${file.extension} (${file.path})`);
|
||||||
|
} else if (seen.has(file.path)) {
|
||||||
|
console.log(`[AttachmentProcessor] ⊚ Duplicate (already added): ${file.path}`);
|
||||||
|
} else {
|
||||||
|
seen.add(file.path);
|
||||||
|
internal.push({ file, originalRef: match[0] });
|
||||||
|
console.log(`[AttachmentProcessor] ✓ Added: ${file.path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[AttachmentProcessor] Scan complete: ${embedCount} embeds, ${wikiEmbedCount} wiki-embeds, ${linkCount} links, ${wikilinkCount} wikilinks -> ${internal.length} internal attachments, ${external.length} external references`);
|
||||||
|
return { internal, external };
|
||||||
|
}
|
||||||
|
|
||||||
|
private rewriteContent(
|
||||||
|
content: string,
|
||||||
|
sourceFile: TFile,
|
||||||
|
uploadedMap: Map<string, { id: string; file: TFile }>
|
||||||
|
): AttachmentPrepareResult {
|
||||||
|
const imageUrlToUploadId: Record<string, string> = {};
|
||||||
|
const filePlaceholderToUpload: Record<string, { id: string; name: string }> = {};
|
||||||
|
const app = this.plugin.app;
|
||||||
|
|
||||||
|
let rewritten = content;
|
||||||
|
|
||||||
|
// Rewrite embedded images: 
|
||||||
|
rewritten = rewritten.replace(
|
||||||
|
/!\[([^\]]*)\]\(([^)]+)\)/g,
|
||||||
|
(fullMatch, altText, rawDest, offset, input) => {
|
||||||
|
const path = this.parseDestination(rawDest);
|
||||||
|
if (this.shouldSkipLinkDestination(path)) return fullMatch;
|
||||||
|
|
||||||
|
const file = this.resolveFile(app, sourceFile, path);
|
||||||
|
if (!file) return fullMatch;
|
||||||
|
|
||||||
|
const uploaded = uploadedMap.get(file.path);
|
||||||
|
if (!uploaded) return fullMatch;
|
||||||
|
|
||||||
|
if (this.isImage(file)) {
|
||||||
|
const sentinelUrl = this.buildSentinelUrl(uploaded.id, file, altText);
|
||||||
|
imageUrlToUploadId[sentinelUrl] = uploaded.id;
|
||||||
|
const markdown = ``;
|
||||||
|
return typeof offset === "number" && typeof input === "string" && this.isStandaloneOnLine(input, offset, fullMatch)
|
||||||
|
? `\n\n${markdown}\n\n`
|
||||||
|
: markdown;
|
||||||
|
} else {
|
||||||
|
const token = `__NOTION_FILE_UPLOAD__:${uploaded.id}`;
|
||||||
|
filePlaceholderToUpload[token] = { id: uploaded.id, name: file.name };
|
||||||
|
return `\n\n\`${token}\`\n\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rewrite embedded wikilinks: ![[path]]
|
||||||
|
rewritten = rewritten.replace(
|
||||||
|
/!\[\[([^\]]+)\]\]/g,
|
||||||
|
(fullMatch, inner, offset, input) => {
|
||||||
|
const linkPath = this.parseWikilink(inner);
|
||||||
|
if (this.shouldSkipLinkDestination(linkPath)) return fullMatch;
|
||||||
|
|
||||||
|
const file = this.resolveFile(app, sourceFile, linkPath);
|
||||||
|
if (!file) return fullMatch;
|
||||||
|
|
||||||
|
const uploaded = uploadedMap.get(file.path);
|
||||||
|
if (!uploaded) return fullMatch;
|
||||||
|
|
||||||
|
if (this.isImage(file)) {
|
||||||
|
const sentinelUrl = this.buildSentinelUrl(uploaded.id, file);
|
||||||
|
imageUrlToUploadId[sentinelUrl] = uploaded.id;
|
||||||
|
const markdown = ``;
|
||||||
|
return typeof offset === "number" && typeof input === "string" && this.isStandaloneOnLine(input, offset, fullMatch)
|
||||||
|
? `\n\n${markdown}\n\n`
|
||||||
|
: markdown;
|
||||||
|
} else {
|
||||||
|
const token = `__NOTION_FILE_UPLOAD__:${uploaded.id}`;
|
||||||
|
filePlaceholderToUpload[token] = { id: uploaded.id, name: file.name };
|
||||||
|
return `\n\n\`${token}\`\n\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rewrite markdown links: [text](path)
|
||||||
|
rewritten = rewritten.replace(
|
||||||
|
/(?<!!)\[([^\]]+)\]\(([^)]+)\)/g,
|
||||||
|
(fullMatch, _linkText, rawDest) => {
|
||||||
|
const path = this.parseDestination(rawDest);
|
||||||
|
if (this.shouldSkipLinkDestination(path)) return fullMatch;
|
||||||
|
|
||||||
|
const file = this.resolveFile(app, sourceFile, path);
|
||||||
|
if (!file) return fullMatch;
|
||||||
|
|
||||||
|
const uploaded = uploadedMap.get(file.path);
|
||||||
|
if (!uploaded) return fullMatch;
|
||||||
|
|
||||||
|
// For markdown links, always use file placeholder (non-image treatment)
|
||||||
|
const token = `__NOTION_FILE_UPLOAD__:${uploaded.id}`;
|
||||||
|
filePlaceholderToUpload[token] = { id: uploaded.id, name: file.name };
|
||||||
|
return `\n\n\`${token}\`\n\n`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rewrite wikilink references: [[path]]
|
||||||
|
rewritten = rewritten.replace(
|
||||||
|
/(?<!!)\[\[([^\]]+)\]\]/g,
|
||||||
|
(fullMatch, inner) => {
|
||||||
|
const linkPath = this.parseWikilink(inner);
|
||||||
|
if (this.shouldSkipLinkDestination(linkPath)) return fullMatch;
|
||||||
|
|
||||||
|
const file = this.resolveFile(app, sourceFile, linkPath);
|
||||||
|
if (!file) return fullMatch;
|
||||||
|
|
||||||
|
const uploaded = uploadedMap.get(file.path);
|
||||||
|
if (!uploaded) return fullMatch;
|
||||||
|
|
||||||
|
// For wikilink references, always use file placeholder (non-image treatment)
|
||||||
|
const token = `__NOTION_FILE_UPLOAD__:${uploaded.id}`;
|
||||||
|
filePlaceholderToUpload[token] = { id: uploaded.id, name: file.name };
|
||||||
|
return `\n\n\`${token}\`\n\n`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return { content: rewritten, imageUrlToUploadId, filePlaceholderToUpload };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseDestination(rawDest: string): string {
|
||||||
|
const trimmed = rawDest.trim();
|
||||||
|
// Handle angle-bracket wrapped URLs: <path>
|
||||||
|
if (trimmed.startsWith("<") && trimmed.includes(">")) {
|
||||||
|
const end = trimmed.indexOf(">");
|
||||||
|
return this.decodePathOrUrl(trimmed.slice(1, end));
|
||||||
|
}
|
||||||
|
// Take first non-space segment
|
||||||
|
const match = trimmed.match(/^(\S+)/);
|
||||||
|
return this.decodePathOrUrl(match ? match[1] : trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseWikilink(inner: string): string {
|
||||||
|
const trimmed = inner.trim();
|
||||||
|
// Remove alias: [[path|alias]]
|
||||||
|
const beforeAlias = trimmed.split("|")[0]?.trim() ?? trimmed;
|
||||||
|
// Remove heading: [[path#heading]]
|
||||||
|
const beforeHeading = beforeAlias.split("#")[0]?.trim() ?? beforeAlias;
|
||||||
|
return this.decodePathOrUrl(beforeHeading);
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodePathOrUrl(value: string): string {
|
||||||
|
/*
|
||||||
|
// TODO: Support `obsidian://` and `app://` URL destinations.
|
||||||
|
// For now we only support wikilink + standard markdown formats with vault paths.
|
||||||
|
if (value.startsWith("obsidian://") || value.startsWith("app://")) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
// For regular paths, strip query/hash and decode
|
||||||
|
const stripped = value.split(/[?#]/)[0] ?? value;
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(stripped);
|
||||||
|
} catch {
|
||||||
|
return stripped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isExternalUrl(link: string): boolean {
|
||||||
|
return link.startsWith("http://") || link.startsWith("https://");
|
||||||
|
}
|
||||||
|
|
||||||
|
private isTodoUrl(link: string): boolean {
|
||||||
|
return link.startsWith("obsidian://") || link.startsWith("app://");
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldSkipLinkDestination(link: string): boolean {
|
||||||
|
return this.isExternalUrl(link) || this.isTodoUrl(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveFile(app: App, sourceFile: TFile, link: string, options?: { log?: boolean }): TFile | null {
|
||||||
|
const shouldLog = options?.log !== false;
|
||||||
|
const log = (...args: any[]) => {
|
||||||
|
if (shouldLog) console.log(...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!link.trim()) {
|
||||||
|
log(`[AttachmentProcessor] resolveFile: empty link`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Support `obsidian://` and `app://` URL destinations.
|
||||||
|
if (this.isTodoUrl(link)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
// Handle obsidian:// URLs
|
||||||
|
if (link.startsWith("obsidian://")) {
|
||||||
|
const filePath = this.parseObsidianUrl(link);
|
||||||
|
log(`[AttachmentProcessor] resolveFile: obsidian:// URL -> extracted path: "${filePath}"`);
|
||||||
|
if (!filePath) return null;
|
||||||
|
const file = app.vault.getAbstractFileByPath(normalizePath(filePath));
|
||||||
|
if (file instanceof TFile) {
|
||||||
|
log(`[AttachmentProcessor] resolveFile: ✓ Resolved obsidian:// to: ${file.path}`);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
log(`[AttachmentProcessor] resolveFile: ✗ Failed to resolve obsidian:// path: ${filePath}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle app://local/ URLs (Obsidian internal)
|
||||||
|
if (link.startsWith("app://")) {
|
||||||
|
const filePath = this.parseAppUrl(link);
|
||||||
|
log(`[AttachmentProcessor] resolveFile: app:// URL -> extracted path: "${filePath}"`);
|
||||||
|
if (!filePath) return null;
|
||||||
|
|
||||||
|
const vaultCandidate = filePath.startsWith("/") ? filePath.slice(1) : filePath;
|
||||||
|
let file = app.vault.getAbstractFileByPath(normalizePath(vaultCandidate));
|
||||||
|
if (file instanceof TFile) {
|
||||||
|
log(`[AttachmentProcessor] resolveFile: ✓ Resolved app:// to: ${file.path}`);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped = this.mapAbsolutePathToVault(app, filePath);
|
||||||
|
if (mapped) {
|
||||||
|
file = app.vault.getAbstractFileByPath(normalizePath(mapped));
|
||||||
|
if (file instanceof TFile) {
|
||||||
|
log(`[AttachmentProcessor] resolveFile: ✓ Resolved app:// absolute path to: ${file.path}`);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`[AttachmentProcessor] resolveFile: ✗ Failed to resolve app:// path: ${filePath}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Try metadata cache first
|
||||||
|
const cached = app.metadataCache.getFirstLinkpathDest(link, sourceFile.path);
|
||||||
|
if (cached instanceof TFile) {
|
||||||
|
log(`[AttachmentProcessor] resolveFile: ✓ Resolved via metadata cache: ${link} -> ${cached.path}`);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try absolute path
|
||||||
|
const byPath = app.vault.getAbstractFileByPath(normalizePath(link));
|
||||||
|
if (byPath instanceof TFile) {
|
||||||
|
log(`[AttachmentProcessor] resolveFile: ✓ Resolved via absolute path: ${link} -> ${byPath.path}`);
|
||||||
|
return byPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try relative to source file
|
||||||
|
const sourceDir = sourceFile.path.includes("/")
|
||||||
|
? sourceFile.path.slice(0, sourceFile.path.lastIndexOf("/"))
|
||||||
|
: "";
|
||||||
|
const relPath = normalizePath(sourceDir ? `${sourceDir}/${link}` : link);
|
||||||
|
const byRel = app.vault.getAbstractFileByPath(relPath);
|
||||||
|
if (byRel instanceof TFile) {
|
||||||
|
log(`[AttachmentProcessor] resolveFile: ✓ Resolved via relative path: ${link} -> ${byRel.path}`);
|
||||||
|
return byRel;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`[AttachmentProcessor] resolveFile: ✗ Failed to resolve: ${link} (tried cache, absolute, relative)`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
// TODO: Support `obsidian://` URL destinations.
|
||||||
|
private parseObsidianUrl(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
// obsidian://open?vault=VaultName&file=path/to/file.png
|
||||||
|
const filePath = urlObj.searchParams.get("file");
|
||||||
|
if (filePath) {
|
||||||
|
return decodeURIComponent(filePath);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Support `app://` URL destinations.
|
||||||
|
private parseAppUrl(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const pathname = urlObj.pathname;
|
||||||
|
if (!pathname || pathname === "/") return null;
|
||||||
|
return decodeURIComponent(pathname);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Support mapping absolute paths to vault paths for `app://local/...`.
|
||||||
|
private mapAbsolutePathToVault(app: App, absolutePath: string): string | null {
|
||||||
|
const adapter: any = app.vault.adapter;
|
||||||
|
if (typeof adapter?.getBasePath !== "function") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedAbsolute = absolutePath.replace(/\\/g, "/");
|
||||||
|
if (/^\/[A-Za-z]:\//.test(normalizedAbsolute)) {
|
||||||
|
normalizedAbsolute = normalizedAbsolute.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let basePath = String(adapter.getBasePath()).replace(/\\/g, "/");
|
||||||
|
if (basePath.endsWith("/")) {
|
||||||
|
basePath = basePath.slice(0, -1);
|
||||||
|
}
|
||||||
|
if (/^\/[A-Za-z]:\//.test(basePath)) {
|
||||||
|
basePath = basePath.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowsStyle = /^[A-Za-z]:\//.test(basePath);
|
||||||
|
const compareAbsolute = windowsStyle ? normalizedAbsolute.toLowerCase() : normalizedAbsolute;
|
||||||
|
const compareBase = windowsStyle ? basePath.toLowerCase() : basePath;
|
||||||
|
|
||||||
|
if (!compareAbsolute.startsWith(compareBase)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let relative = normalizedAbsolute.slice(basePath.length);
|
||||||
|
if (relative.startsWith("/")) {
|
||||||
|
relative = relative.slice(1);
|
||||||
|
}
|
||||||
|
return relative || null;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
private isSupported(file: TFile): boolean {
|
||||||
|
const ext = file.extension?.toLowerCase() ?? "";
|
||||||
|
return SUPPORTED_EXTENSIONS.has(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isImage(file: TFile): boolean {
|
||||||
|
const ext = file.extension?.toLowerCase() ?? "";
|
||||||
|
return IMAGE_EXTENSIONS.has(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSentinelUrl(uploadId: string, file: TFile, _altText?: string): string {
|
||||||
|
const ext = file.extension?.toLowerCase() ?? "";
|
||||||
|
const suffix = ext ? `.${ext}` : "";
|
||||||
|
return `https://notion-file-upload.local/${uploadId}${suffix}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyBlockRewrites(
|
||||||
|
blocks: any[],
|
||||||
|
rewrites: Pick<AttachmentPrepareResult, "imageUrlToUploadId" | "filePlaceholderToUpload">
|
||||||
|
): void {
|
||||||
|
transformBlocksInPlace(blocks, rewrites);
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformBlocksInPlace(
|
||||||
|
blocks: any[],
|
||||||
|
rewrites: Pick<AttachmentPrepareResult, "imageUrlToUploadId" | "filePlaceholderToUpload">
|
||||||
|
): void {
|
||||||
|
for (let i = 0; i < blocks.length; i++) {
|
||||||
|
const block = blocks[i];
|
||||||
|
|
||||||
|
// Transform image blocks with sentinel URLs
|
||||||
|
if (block?.type === "image" && block?.image?.type === "external") {
|
||||||
|
const url = block.image?.external?.url;
|
||||||
|
if (url && rewrites.imageUrlToUploadId[url]) {
|
||||||
|
const caption = block.image?.caption;
|
||||||
|
block.image = {
|
||||||
|
type: "file_upload",
|
||||||
|
file_upload: { id: rewrites.imageUrlToUploadId[url] },
|
||||||
|
...(caption ? { caption } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform paragraph placeholders to file blocks
|
||||||
|
if (block?.type === "paragraph") {
|
||||||
|
const token = extractParagraphText(block);
|
||||||
|
if (token && rewrites.filePlaceholderToUpload[token]) {
|
||||||
|
const { id, name } = rewrites.filePlaceholderToUpload[token];
|
||||||
|
blocks[i] = buildFileBlock(id, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse into children
|
||||||
|
const inner = block?.[block?.type];
|
||||||
|
if (inner?.children && Array.isArray(inner.children)) {
|
||||||
|
transformBlocksInPlace(inner.children, rewrites);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractParagraphText(block: any): string | undefined {
|
||||||
|
const richText = block?.paragraph?.rich_text;
|
||||||
|
if (!Array.isArray(richText) || richText.length === 0) return undefined;
|
||||||
|
return richText
|
||||||
|
.map((item: any) => item?.plain_text ?? item?.text?.content ?? "")
|
||||||
|
.join("")
|
||||||
|
.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFileBlock(uploadId: string, name: string): any {
|
||||||
|
return {
|
||||||
|
object: "block",
|
||||||
|
type: "file",
|
||||||
|
file: {
|
||||||
|
type: "file_upload",
|
||||||
|
file_upload: { id: uploadId },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
256
src/upload/common/AttachmentUploader.ts
Normal file
256
src/upload/common/AttachmentUploader.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import { TFile, requestUrl } from "obsidian";
|
||||||
|
import type MyPlugin from "src/main";
|
||||||
|
import type { DatabaseDetails } from "../../ui/settingTabs";
|
||||||
|
|
||||||
|
const NOTION_API_VERSION = "2025-09-03";
|
||||||
|
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
interface FileUploadSession {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
upload_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadResult {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AttachmentUploader {
|
||||||
|
private plugin: MyPlugin;
|
||||||
|
private dbDetails: DatabaseDetails;
|
||||||
|
private textEncoder = new TextEncoder();
|
||||||
|
|
||||||
|
constructor(plugin: MyPlugin, dbDetails: DatabaseDetails) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.dbDetails = dbDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFile(file: TFile): Promise<UploadResult> {
|
||||||
|
const { notionAPI } = this.dbDetails;
|
||||||
|
const fileSizeBytes = file.stat?.size ?? 0;
|
||||||
|
const contentType = this.getContentType(file.extension);
|
||||||
|
|
||||||
|
if (fileSizeBytes > MAX_UPLOAD_BYTES) {
|
||||||
|
throw new Error(
|
||||||
|
`File too large for Notion upload (max 5MB): ${file.path} (${(fileSizeBytes / 1024 / 1024).toFixed(2)} MB)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const mode = "single_part";
|
||||||
|
|
||||||
|
console.log(`[AttachmentUploader] uploadFile: ${file.name}`, {
|
||||||
|
path: file.path,
|
||||||
|
size: `${(fileSizeBytes / 1024).toFixed(2)} KB`,
|
||||||
|
contentType,
|
||||||
|
mode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = await this.createUploadSession({
|
||||||
|
mode,
|
||||||
|
notionAPI,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[AttachmentUploader] Upload session created:`, {
|
||||||
|
sessionId: session.id,
|
||||||
|
status: session.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
const binary = await this.plugin.app.vault.readBinary(file);
|
||||||
|
console.log(`[AttachmentUploader] Read binary data: ${binary.byteLength} bytes`);
|
||||||
|
|
||||||
|
if (binary.byteLength > MAX_UPLOAD_BYTES) {
|
||||||
|
throw new Error(
|
||||||
|
`File too large for Notion upload (max 5MB): ${file.path} (${(binary.byteLength / 1024 / 1024).toFixed(2)} MB)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadUrl =
|
||||||
|
session.upload_url ??
|
||||||
|
`https://api.notion.com/v1/file_uploads/${encodeURIComponent(session.id)}/send`;
|
||||||
|
await this.sendFileData(session.id, uploadUrl, binary, notionAPI, file.name, contentType);
|
||||||
|
|
||||||
|
console.log(`[AttachmentUploader] Upload complete: ${file.name} -> ${session.id}`);
|
||||||
|
return { id: session.id, filename: file.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createUploadSession(params: {
|
||||||
|
mode: string;
|
||||||
|
notionAPI: string;
|
||||||
|
}): Promise<FileUploadSession> {
|
||||||
|
console.log(`[AttachmentUploader] Creating upload session:`, {
|
||||||
|
mode: params.mode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.requestWithRetry({
|
||||||
|
url: "https://api.notion.com/v1/file_uploads",
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${params.notionAPI}`,
|
||||||
|
"Notion-Version": NOTION_API_VERSION,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
mode: params.mode,
|
||||||
|
}),
|
||||||
|
throw: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.json;
|
||||||
|
if (response.status < 200 || response.status >= 300) {
|
||||||
|
console.error(`[AttachmentUploader] Failed to create upload session:`, {
|
||||||
|
status: response.status,
|
||||||
|
message: data?.message,
|
||||||
|
response: data,
|
||||||
|
});
|
||||||
|
throw new Error(`Failed to create upload session: ${data?.message ?? response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = data?.id ?? data?.file_upload?.id;
|
||||||
|
if (!id) {
|
||||||
|
throw new Error("Upload session response missing id");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id, status: data.status, upload_url: data.upload_url };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendFileData(
|
||||||
|
fileUploadId: string,
|
||||||
|
uploadUrl: string,
|
||||||
|
binary: ArrayBuffer,
|
||||||
|
notionAPI: string,
|
||||||
|
filename: string,
|
||||||
|
contentType: string,
|
||||||
|
): Promise<void> {
|
||||||
|
console.log(`[AttachmentUploader] Sending file data for session: ${fileUploadId} (${binary.byteLength} bytes)`);
|
||||||
|
|
||||||
|
const { body, boundary } = this.buildMultipartBody({
|
||||||
|
fieldName: "file",
|
||||||
|
filename,
|
||||||
|
contentType: contentType || "application/octet-stream",
|
||||||
|
binary,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.requestWithRetry({
|
||||||
|
url: uploadUrl,
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||||||
|
Authorization: `Bearer ${notionAPI}`,
|
||||||
|
"Notion-Version": NOTION_API_VERSION,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
throw: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.json;
|
||||||
|
if (response.status < 200 || response.status >= 300) {
|
||||||
|
console.error(`[AttachmentUploader] Failed to send file data:`, {
|
||||||
|
sessionId: fileUploadId,
|
||||||
|
status: response.status,
|
||||||
|
message: data?.message,
|
||||||
|
response: data,
|
||||||
|
});
|
||||||
|
throw new Error(`Failed to send file data: ${data?.message ?? response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[AttachmentUploader] File data sent successfully for session: ${fileUploadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildMultipartBody(params: {
|
||||||
|
fieldName: string;
|
||||||
|
filename: string;
|
||||||
|
contentType: string;
|
||||||
|
binary: ArrayBuffer;
|
||||||
|
}): { body: ArrayBuffer; boundary: string } {
|
||||||
|
const boundary = `----NotionFormBoundary${Math.random().toString(16).slice(2)}${Math.random().toString(16).slice(2)}`;
|
||||||
|
|
||||||
|
const safeFilename = params.filename.replace(/"/g, '\\"');
|
||||||
|
const prefix = [
|
||||||
|
`--${boundary}\r\n`,
|
||||||
|
`Content-Disposition: form-data; name="${params.fieldName}"; filename="${safeFilename}"\r\n`,
|
||||||
|
`Content-Type: ${params.contentType}\r\n`,
|
||||||
|
`\r\n`,
|
||||||
|
].join("");
|
||||||
|
const suffix = `\r\n--${boundary}--\r\n`;
|
||||||
|
|
||||||
|
const prefixBytes = this.textEncoder.encode(prefix);
|
||||||
|
const fileBytes = new Uint8Array(params.binary);
|
||||||
|
const suffixBytes = this.textEncoder.encode(suffix);
|
||||||
|
|
||||||
|
const out = new Uint8Array(prefixBytes.length + fileBytes.length + suffixBytes.length);
|
||||||
|
out.set(prefixBytes, 0);
|
||||||
|
out.set(fileBytes, prefixBytes.length);
|
||||||
|
out.set(suffixBytes, prefixBytes.length + fileBytes.length);
|
||||||
|
return { body: out.buffer, boundary };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestWithRetry(params: any, maxAttempts = 4): Promise<any> {
|
||||||
|
let attempt = 0;
|
||||||
|
let lastError: unknown;
|
||||||
|
|
||||||
|
while (attempt < maxAttempts) {
|
||||||
|
attempt++;
|
||||||
|
try {
|
||||||
|
const response = await requestUrl(params);
|
||||||
|
if (this.shouldRetry(response.status) && attempt < maxAttempts) {
|
||||||
|
const delayMs = this.getRetryDelay(response, attempt);
|
||||||
|
console.warn(`[AttachmentUploader] Retryable status ${response.status}, attempt ${attempt}/${maxAttempts}, retrying in ${delayMs}ms`);
|
||||||
|
await this.sleep(delayMs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
lastError = error;
|
||||||
|
console.error(`[AttachmentUploader] Request failed, attempt ${attempt}/${maxAttempts}:`, error);
|
||||||
|
if (attempt >= maxAttempts) break;
|
||||||
|
const delayMs = this.getRetryDelay(undefined, attempt);
|
||||||
|
console.warn(`[AttachmentUploader] Retrying in ${delayMs}ms`);
|
||||||
|
await this.sleep(delayMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`[AttachmentUploader] Request failed after ${maxAttempts} attempts`);
|
||||||
|
throw lastError ?? new Error("Request failed after retries");
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldRetry(status: number): boolean {
|
||||||
|
return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRetryDelay(response: any, attempt: number): number {
|
||||||
|
const retryAfter = response?.headers?.["retry-after"] ?? response?.headers?.["Retry-After"];
|
||||||
|
if (retryAfter) {
|
||||||
|
const seconds = parseInt(retryAfter, 10);
|
||||||
|
if (!isNaN(seconds)) return seconds * 1000;
|
||||||
|
}
|
||||||
|
const base = 500;
|
||||||
|
const max = 8000;
|
||||||
|
const expo = Math.min(max, base * Math.pow(2, attempt - 1));
|
||||||
|
const jitter = Math.floor(Math.random() * 250);
|
||||||
|
return expo + jitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getContentType(extension: string): string {
|
||||||
|
const ext = extension.toLowerCase();
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
png: "image/png",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
gif: "image/gif",
|
||||||
|
webp: "image/webp",
|
||||||
|
svg: "image/svg+xml",
|
||||||
|
heic: "image/heic",
|
||||||
|
tif: "image/tiff",
|
||||||
|
tiff: "image/tiff",
|
||||||
|
bmp: "image/bmp",
|
||||||
|
pdf: "application/pdf",
|
||||||
|
};
|
||||||
|
return mimeTypes[ext] ?? "application/octet-stream";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import MyPlugin from "src/main";
|
|||||||
import { DatabaseDetails } from "../../ui/settingTabs";
|
import { DatabaseDetails } from "../../ui/settingTabs";
|
||||||
import { i18nConfig } from "../../lang/I18n";
|
import { i18nConfig } from "../../lang/I18n";
|
||||||
|
|
||||||
|
const NOTION_API_VERSION = "2025-09-03";
|
||||||
|
|
||||||
export interface NotionPageResponse {
|
export interface NotionPageResponse {
|
||||||
response: any;
|
response: any;
|
||||||
data: any;
|
data: any;
|
||||||
@@ -30,7 +32,7 @@ export abstract class UploadBase {
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: "Bearer " + notionAPI,
|
Authorization: "Bearer " + notionAPI,
|
||||||
"Notion-Version": "2022-06-28",
|
"Notion-Version": NOTION_API_VERSION,
|
||||||
},
|
},
|
||||||
body: "",
|
body: "",
|
||||||
throw: false,
|
throw: false,
|
||||||
@@ -116,7 +118,7 @@ export abstract class UploadBase {
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: "Bearer " + notionAPI,
|
Authorization: "Bearer " + notionAPI,
|
||||||
"Notion-Version": "2022-06-28",
|
"Notion-Version": NOTION_API_VERSION,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
throw: false,
|
throw: false,
|
||||||
@@ -171,7 +173,7 @@ export abstract class UploadBase {
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: "Bearer " + notionAPI,
|
Authorization: "Bearer " + notionAPI,
|
||||||
"Notion-Version": "2022-06-28",
|
"Notion-Version": NOTION_API_VERSION,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(extraBlocks),
|
body: JSON.stringify(extraBlocks),
|
||||||
throw: false,
|
throw: false,
|
||||||
@@ -201,7 +203,7 @@ export abstract class UploadBase {
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Bearer " + notionAPI,
|
Authorization: "Bearer " + notionAPI,
|
||||||
"Notion-Version": "2022-06-28",
|
"Notion-Version": NOTION_API_VERSION,
|
||||||
},
|
},
|
||||||
throw: false,
|
throw: false,
|
||||||
}).catch((error) =>
|
}).catch((error) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user