Files
obsidian-to-NotionNext/src/upload/common/AttachmentUploader.ts
Jiaxin Peng 202385ac21 feat: Implement attachment processing and uploading for Notion integration
- Added AttachmentProcessor to handle local attachments in markdown content.
- Introduced AttachmentUploader for managing file uploads to Notion.
- Updated Upload2Notion to utilize new attachment processing and uploading logic.
- Enhanced buildBlocks method to process attachments and apply block rewrites.
- Updated Notion API version to 2025-09-03 for compatibility with new features.
2026-01-04 19:11:37 +00:00

257 lines
7.6 KiB
TypeScript

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";
}
}