feat(pwa): register native Android Web Share Target with dedicated /share route, explicit MIME types, and seamless note opening

This commit is contained in:
chrisf committed 2026-10-22 19:26:18 -06:00
1 parent 8907b4c494
commit fb3c01c6ae
4 files changed
+307 -6

No files matched your search

+257
View File
@@ -0,0 +1,257 @@
import { NextRequest, NextResponse } from 'next/server';
import { marked } from 'marked';
import { prisma, ensureDefaultNotebook, getCurrentUserId } from '@/lib/prisma';
import {
clipUrlToNote,
getOrCreateTagIds,
localizeClippedImages,
} from '@/lib/importers';
import {
saveAttachmentBuffer,
stripHtmlToText,
makeExcerpt,
} from '@/lib/storage';
import { applyAutoSortToNote } from '@/lib/autoSort';
export const dynamic = 'force-dynamic';
function extractUrl(text: string): { extractedUrl: string | null; remainingText: string } {
if (!text) return { extractedUrl: null, remainingText: '' };
const match = /(https?:\/\/[^\s]+)/i.exec(text);
if (!match) return { extractedUrl: null, remainingText: text };
const extractedUrl = match[1];
const remainingText = text.replace(extractedUrl, '').trim();
return { extractedUrl, remainingText };
}
export async function GET(req: NextRequest) {
try {
const userId = await getCurrentUserId();
const defaultNb = await ensureDefaultNotebook(userId);
const sp = req.nextUrl.searchParams;
let title = (sp.get('title') || '').trim();
let text = (sp.get('text') || sp.get('content') || '').trim();
let url = (sp.get('url') || '').trim();
// Check if URL was shared inside text or title
if (!url && text) {
const extracted = extractUrl(text);
if (extracted.extractedUrl) {
url = extracted.extractedUrl;
text = extracted.remainingText;
}
}
if (!url && title && /^https?:\/\/\S+$/i.test(title)) {
url = title;
title = '';
}
let noteId: string;
if (url && !text) {
// Direct web clip
noteId = await clipUrlToNote(url, {
title: title || undefined,
notebookId: defaultNb.id,
tags: ['inbox', 'web-clip'],
});
} else {
const contentHtml = text
? `<p>${text.replace(/\n/g, '<br/>')}</p>${
url ? `<p>Source: <a href="${url}" target="_blank">${url}</a></p>` : ''
}`
: `<p><a href="${url}" target="_blank">${url}</a></p>`;
const tagIds = await getOrCreateTagIds(['inbox', 'share'], userId);
const created = await prisma.note.create({
data: {
title: title || url || 'Shared Note',
contentHtml,
contentText: stripHtmlToText(contentHtml),
excerpt: makeExcerpt(stripHtmlToText(contentHtml)),
notebookId: defaultNb.id,
sourceUrl: url || null,
tags: {
create: tagIds.map((tagId) => ({ tagId })),
},
},
});
noteId = created.id;
}
await applyAutoSortToNote(noteId, userId).catch(() => {});
return NextResponse.redirect(new URL(`/?noteId=${noteId}&shared=1`, req.url), 303);
} catch (err) {
console.error('GET /share error:', err);
return NextResponse.redirect(new URL('/?shared_error=1', req.url), 303);
}
}
export async function POST(req: NextRequest) {
try {
const userId = await getCurrentUserId();
const defaultNb = await ensureDefaultNotebook(userId);
const contentType = req.headers.get('content-type') || '';
let title = '';
let text = '';
let url = '';
const uploadedFiles: File[] = [];
if (contentType.includes('application/json')) {
const body = await req.json();
title = (body.title || '').trim();
text = (body.text || body.content || '').trim();
url = (body.url || '').trim();
} else {
const formData = await req.formData();
title = ((formData.get('title') as string) || '').trim();
text = (
(formData.get('text') as string) ||
(formData.get('content') as string) ||
''
).trim();
url = ((formData.get('url') as string) || '').trim();
const rawFiles = [
...formData.getAll('files'),
...formData.getAll('file'),
];
for (const item of rawFiles) {
if (item && typeof item === 'object' && 'arrayBuffer' in item) {
const file = item as File;
if (file.size > 0) {
uploadedFiles.push(file);
}
}
}
}
// Check if Android shared a URL inside `text` or `title`
if (!url && text) {
const extracted = extractUrl(text);
if (extracted.extractedUrl) {
url = extracted.extractedUrl;
text = extracted.remainingText;
}
}
if (!url && title && /^https?:\/\/\S+$/i.test(title)) {
url = title;
title = '';
}
// 1. If sharing a URL with no uploaded files and no extra body text, clip the webpage
if (url && !text && uploadedFiles.length === 0) {
try {
const clippedId = await clipUrlToNote(url, {
title: title || undefined,
notebookId: defaultNb.id,
tags: ['inbox', 'web-clip'],
});
await applyAutoSortToNote(clippedId, userId).catch(() => {});
return NextResponse.redirect(new URL(`/?noteId=${clippedId}&shared=1`, req.url), 303);
} catch (e) {
console.warn('URL clipping failed in /share, falling back to basic link note:', e);
}
}
// 2. Generate base HTML content
let finalHtml = '';
if (text) {
finalHtml = await marked.parse(text, { gfm: true, breaks: true });
}
if (url && !finalHtml.includes(url)) {
finalHtml = `${finalHtml ? `${finalHtml}\n` : ''}<p>Source: <a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a></p>`;
}
if (!finalHtml && uploadedFiles.length === 0) {
finalHtml = '<p></p>';
}
const finalTitle =
title ||
stripHtmlToText(finalHtml).slice(0, 60).trim() ||
(uploadedFiles[0]?.name ? `Attachment: ${uploadedFiles[0].name}` : 'Shared Note');
const tagIds = await getOrCreateTagIds(['inbox', 'share'], userId);
const note = await prisma.note.create({
data: {
title: finalTitle,
contentHtml: finalHtml,
contentText: stripHtmlToText(finalHtml),
excerpt: makeExcerpt(stripHtmlToText(finalHtml)),
notebookId: defaultNb.id,
sourceUrl: url || null,
tags: {
create: tagIds.map((tagId) => ({ tagId })),
},
},
});
// 3. Save any uploaded attachments (images, PDFs, audio, documents)
let appendedMediaHtml = '';
for (const f of uploadedFiles) {
const buf = Buffer.from(await f.arrayBuffer());
const mimeType = f.type || 'application/octet-stream';
const filename = f.name || 'shared-file';
const savedBlob = await saveAttachmentBuffer(buf, filename, mimeType);
const att = await prisma.attachment.create({
data: {
noteId: note.id,
filename,
mimeType,
sizeBytes: savedBlob.sizeBytes,
md5Hash: savedBlob.md5Hash,
storagePath: savedBlob.storagePath,
extractedText: savedBlob.extractedText,
},
});
if (mimeType.startsWith('image/')) {
appendedMediaHtml += `<p><img src="/api/attachments/${att.id}" alt="${filename}" /></p>\n`;
} else if (mimeType.startsWith('audio/')) {
appendedMediaHtml += `<p><audio controls src="/api/attachments/${att.id}"></audio></p>\n`;
} else if (mimeType.startsWith('video/')) {
appendedMediaHtml += `<p><video controls src="/api/attachments/${att.id}" style="max-width: 100%; border-radius: 8px;"></video></p>\n`;
} else {
appendedMediaHtml += `<p><a href="/api/attachments/${att.id}" target="_blank" rel="noopener noreferrer">📎 ${filename}</a></p>\n`;
}
}
if (appendedMediaHtml) {
const combinedHtml = `${finalHtml ? `${finalHtml}\n` : ''}${appendedMediaHtml}`;
await prisma.note.update({
where: { id: note.id },
data: {
contentHtml: combinedHtml,
contentText: stripHtmlToText(combinedHtml),
excerpt: makeExcerpt(stripHtmlToText(combinedHtml)),
},
});
}
if (finalHtml.includes('<img')) {
const localized = await localizeClippedImages(
note.id,
finalHtml,
url || undefined
);
if (localized !== finalHtml) {
await prisma.note.update({
where: { id: note.id },
data: { contentHtml: localized },
});
}
}
// 4. Run Smart Auto-Sorting & Auto-Tagging
await applyAutoSortToNote(note.id, userId).catch(() => {});
// 5. Redirect browser / Android WebAPK straight into Everclone displaying the new note
return NextResponse.redirect(new URL(`/?noteId=${note.id}&shared=1`, req.url), 303);
} catch (err) {
console.error('POST /share error:', err);
return NextResponse.redirect(new URL('/?shared_error=1', req.url), 303);
}
}