From 3ed7fb8036a4756052a2ac95c434dc412dea61e2 Mon Sep 17 00:00:00 2001 From: Chris Fuller Date: Thu, 29 Oct 2026 17:13:57 -0600 Subject: [PATCH] feat: enable Android Share Sheet for graphics & files; handle direct PDF/image clipping --- public/manifest.json | 34 +++++++- src/app/share/route.ts | 8 ++ src/lib/importers.ts | 186 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 217 insertions(+), 11 deletions(-) diff --git a/public/manifest.json b/public/manifest.json index b13c1ec..b92af7e 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -47,13 +47,39 @@ } ], "share_target": { - "action": "/", - "method": "GET", - "enctype": "application/x-www-form-urlencoded", + "action": "/share", + "method": "POST", + "enctype": "multipart/form-data", "params": { "title": "title", "text": "text", - "url": "url" + "url": "url", + "files": [ + { + "name": "files", + "accept": [ + "image/*", + "application/pdf", + "text/*", + "audio/*", + "video/*", + ".pdf", + ".jpg", + ".jpeg", + ".png", + ".webp", + ".gif", + ".svg", + ".enex", + ".md", + ".txt", + ".mp3", + ".m4a", + ".wav", + ".mp4" + ] + } + ] } }, "shortcuts": [ diff --git a/src/app/share/route.ts b/src/app/share/route.ts index b73306c..dca6cbb 100644 --- a/src/app/share/route.ts +++ b/src/app/share/route.ts @@ -321,11 +321,18 @@ export async function POST(req: NextRequest) { appendedMediaHtml += `

\n`; } else if (mimeType.startsWith('video/')) { appendedMediaHtml += `

\n`; + } else if (mimeType === 'application/pdf' || filename.toLowerCase().endsWith('.pdf')) { + appendedMediaHtml += `

📄 View PDF: ${filename}

\n`; } else { appendedMediaHtml += `

📎 ${filename}

\n`; } } + const firstImgAtt = await prisma.attachment.findFirst({ + where: { noteId: note.id, mimeType: { startsWith: 'image/' } }, + select: { id: true }, + }); + if (appendedMediaHtml) { const combinedHtml = `${finalHtml ? `${finalHtml}\n` : ''}${appendedMediaHtml}`; await prisma.note.update({ @@ -334,6 +341,7 @@ export async function POST(req: NextRequest) { contentHtml: combinedHtml, contentText: stripHtmlToText(combinedHtml), excerpt: makeExcerpt(stripHtmlToText(combinedHtml)), + ...(firstImgAtt ? { thumbnailUrl: `/api/attachments/${firstImgAtt.id}` } : {}), }, }); } diff --git a/src/lib/importers.ts b/src/lib/importers.ts index 1db95cd..a100f64 100644 --- a/src/lib/importers.ts +++ b/src/lib/importers.ts @@ -926,13 +926,14 @@ export async function clipUrlToNote( } ): Promise { const notebookId = options.notebookId || (await ensureDefaultNotebook()).id; - let html = ''; + const userId = await getCurrentUserId(); + let res: Response | null = null; try { - const res = await fetch(url, { + res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,application/pdf,*/*;q=0.8', 'Accept-Language': 'en-US,en;q=0.9', 'Sec-Ch-Ua': '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"', 'Sec-Ch-Ua-Mobile': '?0', @@ -943,10 +944,182 @@ export async function clipUrlToNote( 'Sec-Fetch-User': '?1', 'Upgrade-Insecure-Requests': '1', }, - signal: AbortSignal.timeout(15000), + signal: AbortSignal.timeout(20000), }); - html = await res.text(); - } catch { + } catch (fetchErr) { + console.warn('Failed to fetch URL for clipping:', fetchErr); + } + + const contentType = res ? (res.headers.get('content-type')?.toLowerCase() || '') : ''; + const cleanUrlPath = url.split('?')[0].toLowerCase(); + const isPdf = contentType.includes('application/pdf') || cleanUrlPath.endsWith('.pdf'); + const isImage = contentType.startsWith('image/') || /\.(jpe?g|png|webp|gif|avif|svg)$/i.test(cleanUrlPath); + + // === CASE A: Direct PDF clipping === + if (res && res.ok && isPdf) { + const buf = Buffer.from(await res.arrayBuffer()); + + let filename = ''; + const cd = res.headers.get('content-disposition') || ''; + const fnMatch = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(cd); + if (fnMatch) { + try { + filename = decodeURIComponent(fnMatch[1].trim()); + } catch { + filename = fnMatch[1].trim(); + } + } + if (!filename) { + try { + const rawPart = url.split('?')[0].split('/').pop() || 'document.pdf'; + filename = decodeURIComponent(rawPart); + } catch { + filename = 'document.pdf'; + } + } + if (!filename.toLowerCase().endsWith('.pdf')) { + filename += '.pdf'; + } + + let resolvedTitle = ''; + if (options.title && options.title !== url && !options.title.startsWith('http')) { + resolvedTitle = options.title.trim(); + } else { + const headerSnippet = buf.toString('latin1', 0, 8192); + const titleMatch = /\/Title\s*\(([^)]+)\)/i.exec(headerSnippet); + if (titleMatch && titleMatch[1].trim()) { + resolvedTitle = titleMatch[1].replace(/\\([()\\])/g, '$1').trim(); + } + if (!resolvedTitle) { + resolvedTitle = filename.replace(/\.pdf$/i, '').replace(/[_-]+/g, ' ').trim(); + } + } + + const savedBlob = await saveAttachmentBuffer(buf, filename, 'application/pdf'); + const tagsToApply = [...(options.tags || ['inbox', 'web-clip'])]; + if (!tagsToApply.includes('pdf')) { + tagsToApply.push('pdf'); + } + const tagIds = await getOrCreateTagIds(tagsToApply, userId); + + const note = await prisma.note.create({ + data: { + userId, + title: resolvedTitle || filename, + contentHtml: `

Saved PDF document: ${escapeHtml(filename)}

\n

Source: ${escapeHtml(url)}

`, + contentText: savedBlob.extractedText || resolvedTitle || filename, + excerpt: makeExcerpt(savedBlob.extractedText || resolvedTitle || filename), + notebookId, + sourceUrl: url, + isShortcut: options.isShortcut ?? false, + tags: { + create: tagIds.map((tagId) => ({ tagId })), + }, + }, + }); + + const att = await prisma.attachment.create({ + data: { + noteId: note.id, + filename, + mimeType: 'application/pdf', + sizeBytes: savedBlob.sizeBytes, + md5Hash: savedBlob.md5Hash, + storagePath: savedBlob.storagePath, + extractedText: savedBlob.extractedText, + }, + }); + + await prisma.note.update({ + where: { id: note.id }, + data: { + contentHtml: `

Saved PDF document: ${escapeHtml(filename)}

\n

Source: ${escapeHtml(url)}

`, + }, + }); + + try { + enqueueAttachmentOcr(att.id); + } catch {} + + try { + const { applyAutoSortToNote } = await import('./autoSort'); + await applyAutoSortToNote(note.id, userId); + } catch {} + + return note.id; + } + + // === CASE B: Direct Image clipping === + if (res && res.ok && isImage) { + const buf = Buffer.from(await res.arrayBuffer()); + const mimeType = contentType.startsWith('image/') ? contentType.split(';')[0].trim() : 'image/jpeg'; + let filename = ''; + try { + filename = decodeURIComponent(url.split('?')[0].split('/').pop() || 'image.jpg'); + } catch { + filename = 'image.jpg'; + } + const savedBlob = await saveAttachmentBuffer(buf, filename, mimeType); + const tagsToApply = [...(options.tags || ['inbox', 'web-clip'])]; + const tagIds = await getOrCreateTagIds(tagsToApply, userId); + + const resolvedTitle = + options.title && !options.title.startsWith('http') + ? options.title.trim() + : filename.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim(); + + const note = await prisma.note.create({ + data: { + userId, + title: resolvedTitle || 'Image Clip', + contentHtml: `

Loading image...

`, + contentText: resolvedTitle || 'Image Clip', + excerpt: makeExcerpt(resolvedTitle || 'Image Clip'), + notebookId, + sourceUrl: url, + isShortcut: options.isShortcut ?? false, + tags: { + create: tagIds.map((tagId) => ({ tagId })), + }, + }, + }); + + const att = await prisma.attachment.create({ + data: { + noteId: note.id, + filename, + mimeType, + sizeBytes: savedBlob.sizeBytes, + md5Hash: savedBlob.md5Hash, + storagePath: savedBlob.storagePath, + extractedText: savedBlob.extractedText, + }, + }); + + await prisma.note.update({ + where: { id: note.id }, + data: { + contentHtml: `

${escapeHtml(filename)}

\n

Source: ${escapeHtml(url)}

`, + thumbnailUrl: `/api/attachments/${att.id}`, + }, + }); + + try { + const { applyAutoSortToNote } = await import('./autoSort'); + await applyAutoSortToNote(note.id, userId); + } catch {} + + return note.id; + } + + let html = ''; + if (res) { + try { + html = await res.text(); + } catch { + html = `

Saved link: ${escapeHtml(url)}

`; + } + } else { html = `

Saved link: ${escapeHtml(url)}

`; } @@ -994,7 +1167,6 @@ export async function clipUrlToNote( contentHtml = `

${escapeHtml(title)}

\n${contentHtml}`; } - const userId = await getCurrentUserId(); const contentText = stripHtmlToText(contentHtml); const excerpt = makeExcerpt(article?.excerpt || contentText); const tagIds = await getOrCreateTagIds(tagsToApply, userId);