diff --git a/src/main.ts b/src/main.ts index 9a0943a..bc57e3f 100644 --- a/src/main.ts +++ b/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 = new Set(); private lastFrontmatterCache: Map = new Map(); private lastContentHashCache: Map = new Map(); + private autoSyncAttachmentBlocked: Set = new Set(); async onload() { @@ -69,6 +71,7 @@ export default class ObsidianSyncNotionPlugin extends Plugin { } this.lastFrontmatterCache.clear(); this.lastContentHashCache.clear(); + this.autoSyncAttachmentBlocked.clear(); } @@ -315,7 +318,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 +341,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 +386,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)); diff --git a/src/upload/Upload2Notion.ts b/src/upload/Upload2Notion.ts index fe2e985..be208c4 100644 --- a/src/upload/Upload2Notion.ts +++ b/src/upload/Upload2Notion.ts @@ -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"; @@ -128,7 +129,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 +154,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 +188,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 +207,24 @@ export class Upload2Notion extends UploadBase { }); } - private buildBlocks(markdown: string, options: Record): any[] { + private async buildBlocks(markdown: string, nowFile: TFile, options: Record): Promise { 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), diff --git a/src/upload/common/AttachmentProcessor.ts b/src/upload/common/AttachmentProcessor.ts new file mode 100644 index 0000000..abf1363 --- /dev/null +++ b/src/upload/common/AttachmentProcessor.ts @@ -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; + filePlaceholderToUpload: Record; +} + +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 = /(? { + 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(); + + 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(); + 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 = /(? 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 + ): AttachmentPrepareResult { + const imageUrlToUploadId: Record = {}; + const filePlaceholderToUpload: Record = {}; + const app = this.plugin.app; + + let rewritten = content; + + // Rewrite embedded images: ![alt](path) + rewritten = rewritten.replace( + /!\[([^\]]*)\]\(([^)]+)\)/g, + (fullMatch, altText, rawDest, offset, input) => { + const path = this.parseDestination(rawDest); + if (this.shouldSkipLinkDestination(path)) return fullMatch; + + const file = this.resolveFile(app, sourceFile, path); + if (!file) return fullMatch; + + const uploaded = uploadedMap.get(file.path); + if (!uploaded) return fullMatch; + + if (this.isImage(file)) { + const sentinelUrl = this.buildSentinelUrl(uploaded.id, file, altText); + imageUrlToUploadId[sentinelUrl] = uploaded.id; + const markdown = `![${altText}](${sentinelUrl})`; + return typeof offset === "number" && typeof input === "string" && this.isStandaloneOnLine(input, offset, fullMatch) + ? `\n\n${markdown}\n\n` + : markdown; + } else { + const token = `__NOTION_FILE_UPLOAD__:${uploaded.id}`; + filePlaceholderToUpload[token] = { id: uploaded.id, name: file.name }; + return `\n\n\`${token}\`\n\n`; + } + } + ); + + // Rewrite embedded wikilinks: ![[path]] + rewritten = rewritten.replace( + /!\[\[([^\]]+)\]\]/g, + (fullMatch, inner, offset, input) => { + const linkPath = this.parseWikilink(inner); + if (this.shouldSkipLinkDestination(linkPath)) return fullMatch; + + const file = this.resolveFile(app, sourceFile, linkPath); + if (!file) return fullMatch; + + const uploaded = uploadedMap.get(file.path); + if (!uploaded) return fullMatch; + + if (this.isImage(file)) { + const sentinelUrl = this.buildSentinelUrl(uploaded.id, file); + imageUrlToUploadId[sentinelUrl] = uploaded.id; + const markdown = `![](${sentinelUrl})`; + return typeof offset === "number" && typeof input === "string" && this.isStandaloneOnLine(input, offset, fullMatch) + ? `\n\n${markdown}\n\n` + : markdown; + } else { + const token = `__NOTION_FILE_UPLOAD__:${uploaded.id}`; + filePlaceholderToUpload[token] = { id: uploaded.id, name: file.name }; + return `\n\n\`${token}\`\n\n`; + } + } + ); + + // Rewrite markdown links: [text](path) + rewritten = rewritten.replace( + /(? { + 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( + /(? { + 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: + 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 +): void { + transformBlocksInPlace(blocks, rewrites); +} + +function transformBlocksInPlace( + blocks: any[], + rewrites: Pick +): 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 }, + }, + }; +} diff --git a/src/upload/common/AttachmentUploader.ts b/src/upload/common/AttachmentUploader.ts new file mode 100644 index 0000000..d6ee34e --- /dev/null +++ b/src/upload/common/AttachmentUploader.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + private getContentType(extension: string): string { + const ext = extension.toLowerCase(); + const mimeTypes: Record = { + 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"; + } +} diff --git a/src/upload/common/UploadBase.ts b/src/upload/common/UploadBase.ts index 4799791..019c685 100644 --- a/src/upload/common/UploadBase.ts +++ b/src/upload/common/UploadBase.ts @@ -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; @@ -30,7 +32,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 +118,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 +173,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, @@ -201,7 +203,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) =>