import { NextResponse } from 'next/server' import { promises as fs } from 'fs' import { auth } from '@/auth' import { prisma } from '@/lib/prisma' import JSZip from 'jszip' import DOMPurify from 'isomorphic-dompurify' import type { BrainstormIdea, Note } from '@prisma/client' function htmlToMarkdown(html: string): string { if (!html) return '' let md = html .replace(/

(.*?)<\/h1>/gi, '# $1\n\n') .replace(/

(.*?)<\/h2>/gi, '## $1\n\n') .replace(/

(.*?)<\/h3>/gi, '### $1\n\n') .replace(/(.*?)<\/strong>/gi, '**$1**') .replace(/(.*?)<\/em>/gi, '*$1*') .replace(/

(.*?)<\/p>/gi, '$1\n\n') .replace(/

  • (.*?)<\/li>/gi, '- $1\n') .replace(/
      /gi, '') .replace(/<\/ul>/gi, '\n') .replace(/
        /gi, '') .replace(/<\/ol>/gi, '\n') .replace(//gi, '\n') md = md.replace(/<[^>]*>/g, '') return md.trim() } function sanitizeFilename(name: string): string { return name.replace(/[^a-zA-Z0-9_\-\s]/g, '').trim() || 'Untitled' } function uniqueBaseName(title: string, id: string): string { return `${sanitizeFilename(title)}--${id.slice(0, 8)}` } function serializeBrainstormIdeas(ideas: BrainstormIdea[]): string { const rendered = new Set() const ideaIds = new Set(ideas.map((i) => i.id)) const formatIdea = (idea: BrainstormIdea, depth: number): string => { if (rendered.has(idea.id)) return '' rendered.add(idea.id) const indent = ' '.repeat(depth) const pos = idea.positionX != null && idea.positionY != null ? ` (pos: ${idea.positionX}, ${idea.positionY})` : '' const conn = idea.connectionToSeed ? ` → seed: ${idea.connectionToSeed}` : '' const star = idea.isStarred ? ' ★' : '' const kind = idea.createdByType || 'idea' let block = `${indent}- **[${kind}]** ${idea.title}${star}${conn}${pos}\n` if (idea.description?.trim()) { block += `${indent} ${idea.description.trim()}\n` } for (const child of ideas.filter((i) => i.parentIdeaId === idea.id)) { block += formatIdea(child, depth + 1) } return block } let md = '' const roots = ideas.filter( (i) => !i.parentIdeaId || !ideaIds.has(i.parentIdeaId) ) for (const root of roots) { md += formatIdea(root, 0) } for (const idea of ideas) { if (!rendered.has(idea.id)) { md += formatIdea(idea, 0) } } return md || '_No ideas in this session._\n' } function resolveNoteFolder( zip: JSZip, note: Note, notebookName: string, activeNotebookFolders: Map ): JSZip { const notebookSegment = sanitizeFilename(notebookName) if (note.isArchived) { return zip.folder(`archive/${notebookSegment}`)! } if (note.trashedAt) { return zip.folder(`trash/${notebookSegment}`)! } let folder = activeNotebookFolders.get(notebookSegment) if (!folder) { folder = zip.folder(notebookSegment)! activeNotebookFolders.set(notebookSegment, folder) } return folder } export async function GET() { try { const session = await auth() if (!session?.user?.id) { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) } const userId = session.user.id const zip = new JSZip() const [allNotes, notebooks, labels, attachments, brainstorms] = await Promise.all([ prisma.note.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, }), prisma.notebook.findMany({ where: { userId }, orderBy: { name: 'asc' }, }), prisma.label.findMany({ where: { userId }, include: { notes: { select: { id: true } } }, }), prisma.noteAttachment.findMany({ where: { note: { userId } }, }), prisma.brainstormSession.findMany({ where: { userId }, include: { ideas: true }, orderBy: { createdAt: 'desc' }, }), ]) const notebookMap = new Map(notebooks.map((nb) => [nb.id, nb.name])) const noteLabelMap = new Map() for (const label of labels) { for (const note of label.notes) { const arr = noteLabelMap.get(note.id) || [] arr.push(label.name) noteLabelMap.set(note.id, arr) } } const metadataJson = { version: '3.0.0', exportDate: new Date().toISOString(), user: { id: userId, email: session.user.email ?? '', name: session.user.name ?? '', }, notebooks: notebooks.map((n) => ({ id: n.id, name: n.name, color: n.color })), labels: labels.map((l) => ({ id: l.id, name: l.name, color: l.color })), notes: allNotes.map((n) => ({ id: n.id, title: n.title, isPinned: n.isPinned, isArchived: n.isArchived, trashedAt: n.trashedAt, createdAt: n.createdAt, updatedAt: n.updatedAt, notebookId: n.notebookId, labels: noteLabelMap.get(n.id) || [], })), brainstorms: brainstorms.map((b) => ({ id: b.id, title: b.title, createdAt: b.createdAt, ideasCount: b.ideas.length, })), } zip.file('metadata.json', JSON.stringify(metadataJson, null, 2)) const activeNotebookFolders = new Map() for (const note of allNotes) { const notebookName = note.notebookId ? notebookMap.get(note.notebookId) || 'Unsorted' : 'Unsorted' const folder = resolveNoteFolder(zip, note, notebookName, activeNotebookFolders) const noteTags = noteLabelMap.get(note.id) || [] const fileBase = uniqueBaseName(note.title || 'Untitled Note', note.id) let mdContent = '---\n' mdContent += `title: "${note.title || 'Untitled'}"\n` mdContent += `id: "${note.id}"\n` mdContent += `createdAt: "${note.createdAt.toISOString()}"\n` mdContent += `updatedAt: "${note.updatedAt.toISOString()}"\n` mdContent += `tags: [${noteTags.map((t) => `"${t}"`).join(', ')}]\n` mdContent += `notebook: "${notebookName}"\n` mdContent += `pinned: ${note.isPinned}\n` mdContent += `archived: ${note.isArchived}\n` mdContent += '---\n\n' mdContent += htmlToMarkdown(note.content || '') folder.file(`${fileBase}.md`, mdContent) folder.file(`${fileBase}.json`, JSON.stringify(note, null, 2)) } const canvasFolder = zip.folder('canvases')! for (const brainstorm of brainstorms) { const fileBase = uniqueBaseName(brainstorm.title || 'Untitled Brainstorm', brainstorm.id) let canvasMd = `# Brainstorm Canvas: ${brainstorm.title || 'Untitled'}\n\n` canvasMd += `- **Created At:** ${brainstorm.createdAt.toISOString()}\n` canvasMd += `- **Updated At:** ${brainstorm.updatedAt.toISOString()}\n` canvasMd += `- **Ideas Count:** ${brainstorm.ideas.length}\n\n` canvasMd += `## Ideas and Connections\n\n` canvasMd += serializeBrainstormIdeas(brainstorm.ideas) canvasFolder.file(`${fileBase}.md`, canvasMd) canvasFolder.file(`${fileBase}.json`, JSON.stringify(brainstorm, null, 2)) } const attachmentsFolder = zip.folder('attachments')! for (const attachment of attachments) { try { const fileContent = await fs.readFile(attachment.filePath) const safeName = `${attachment.id.slice(0, 8)}-${sanitizeFilename(attachment.fileName)}` attachmentsFolder.file(safeName, fileContent) } catch (err) { console.error(`[Export] Failed to pack attachment file: ${attachment.filePath}`, err) } } const offlineNotes = allNotes.map((n) => ({ title: n.title || 'Untitled Note', notebook: n.notebookId ? notebookMap.get(n.notebookId) || 'Unsorted' : 'Unsorted', tags: noteLabelMap.get(n.id) || [], content: DOMPurify.sanitize(n.content || ''), createdAt: n.createdAt.toISOString(), updatedAt: n.updatedAt.toISOString(), archived: n.isArchived, trashed: n.trashedAt !== null, })) const offlineCanvases = brainstorms.map((b) => ({ title: b.title || 'Untitled Brainstorm', createdAt: b.createdAt.toISOString(), ideas: b.ideas.map((i) => ({ title: i.title, description: i.description, kind: i.createdByType || 'idea', x: i.positionX, y: i.positionY, isStarred: i.isStarred, connectionToSeed: i.connectionToSeed, parentIdeaId: i.parentIdeaId, })), })) const indexHtml = ` Memento Workspace Export Browser

        Welcome Back

        Explore your entire workspace offline. Use the sidebar to search and select your notes and canvases.

        Notebook Date

        Title

        Meta description

        Content
        ` zip.file('index.html', indexHtml) // v1: full buffer in memory (documented limit for very large workspaces) const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' }) const dateString = new Date().toISOString().split('T')[0] return new NextResponse(zipBuffer, { headers: { 'Content-Type': 'application/zip', 'Content-Disposition': `attachment; filename="memento-workspace-export-${dateString}.zip"`, }, }) } catch (error) { console.error('[GET /api/user/export] Export failed', error) return NextResponse.json({ success: false, error: 'Export failed' }, { status: 500 }) } }