feat(pwa): register native Android Web Share Target with dedicated /share route, explicit MIME types, and seamless note opening
This commit is contained in:
1 parent
8907b4c494
commit
fb3c01c6ae
4 files changed
+307
-6
No files matched your search
+31
-2
@@ -44,7 +44,7 @@
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "/api/hooks/note?source=share-target",
|
||||
"action": "/share",
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": {
|
||||
@@ -54,7 +54,36 @@
|
||||
"files": [
|
||||
{
|
||||
"name": "files",
|
||||
"accept": ["image/*", "audio/*", "video/*", "application/pdf", "text/*", "*/*"]
|
||||
"accept": [
|
||||
"text/plain",
|
||||
"text/html",
|
||||
"text/markdown",
|
||||
"text/csv",
|
||||
".txt",
|
||||
".md",
|
||||
".html",
|
||||
".csv",
|
||||
"application/pdf",
|
||||
".pdf",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/*",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".webp",
|
||||
"audio/*",
|
||||
".mp3",
|
||||
".m4a",
|
||||
".wav",
|
||||
".ogg",
|
||||
"video/*",
|
||||
".mp4",
|
||||
".webm"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,5 +1,5 @@
|
||||
// Everclone PWA Service Worker (v1.0.0)
|
||||
const CACHE_NAME = 'everclone-cache-v1';
|
||||
// Everclone PWA Service Worker (v1.1.0)
|
||||
const CACHE_NAME = 'everclone-cache-v2';
|
||||
const PRECACHE_URLS = [
|
||||
'/',
|
||||
'/manifest.json',
|
||||
@@ -38,8 +38,8 @@ self.addEventListener('fetch', (event) => {
|
||||
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Bypass API endpoints and file downloads to always get fresh data
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
// Bypass API endpoints, hooks, and share endpoint to always get fresh data
|
||||
if (url.pathname.startsWith('/api/') || url.pathname === '/share') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -383,6 +383,21 @@ export default function EvercloneWorkspace() {
|
||||
}, [fetchSidebar]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
const urlNoteId = sp.get('noteId');
|
||||
if (sp.get('shared') === '1') {
|
||||
setShareToast('Note saved to Everclone from Share!');
|
||||
setTimeout(() => setShareToast(null), 4000);
|
||||
}
|
||||
if (urlNoteId) {
|
||||
setSelectedNoteId(urlNoteId);
|
||||
setMobilePane('editor');
|
||||
fetchNotesList(urlNoteId);
|
||||
window.history.replaceState({}, '', '/');
|
||||
return;
|
||||
}
|
||||
}
|
||||
fetchNotesList();
|
||||
}, [fetchNotesList]);
|
||||
|
||||
|
||||
Reference in new issue
Block a user