Rend les liens entre notes visibles et persistants (sync NoteLink au save, auto-save, graphe réseau rafraîchi), ajoute living blocks, Memory Echo, recherche globale, consentement IA explicite et consolide les prototypes design en architectural-grid. Co-authored-by: Cursor <cursoragent@cursor.com>
513 lines
18 KiB
TypeScript
513 lines
18 KiB
TypeScript
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>(.*?)<\/h1>/gi, '# $1\n\n')
|
|
.replace(/<h2>(.*?)<\/h2>/gi, '## $1\n\n')
|
|
.replace(/<h3>(.*?)<\/h3>/gi, '### $1\n\n')
|
|
.replace(/<strong>(.*?)<\/strong>/gi, '**$1**')
|
|
.replace(/<em>(.*?)<\/em>/gi, '*$1*')
|
|
.replace(/<p>(.*?)<\/p>/gi, '$1\n\n')
|
|
.replace(/<li>(.*?)<\/li>/gi, '- $1\n')
|
|
.replace(/<ul>/gi, '')
|
|
.replace(/<\/ul>/gi, '\n')
|
|
.replace(/<ol>/gi, '')
|
|
.replace(/<\/ol>/gi, '\n')
|
|
.replace(/<br\s*\/?>/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<string>()
|
|
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<string, JSZip>
|
|
): 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<string, string[]>()
|
|
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<string, JSZip>()
|
|
|
|
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 = `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Memento Workspace Export Browser</title>
|
|
<style>
|
|
:root {
|
|
--bg: #F8F9FA;
|
|
--card-bg: #FFFFFF;
|
|
--border: #E9ECEF;
|
|
--text: #212529;
|
|
--text-muted: #6C757D;
|
|
--primary: #1C1C1C;
|
|
--rose: #FFF5F5;
|
|
--rose-border: #FFE3E3;
|
|
--accent: #E9ECEF;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:root {
|
|
--bg: #121212;
|
|
--card-bg: #1E1E1E;
|
|
--border: #2D2D2D;
|
|
--text: #F8F9FA;
|
|
--text-muted: #ADB5BD;
|
|
--primary: #FFFFFF;
|
|
--rose: #2D1A1A;
|
|
--rose-border: #4D2626;
|
|
--accent: #2D2D2D;
|
|
}
|
|
}
|
|
body {
|
|
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
margin: 0;
|
|
padding: 0;
|
|
background-color: var(--bg);
|
|
color: var(--text);
|
|
display: flex;
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
}
|
|
#sidebar {
|
|
width: 320px;
|
|
border-right: 1px solid var(--border);
|
|
background-color: var(--card-bg);
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
flex-shrink: 0;
|
|
}
|
|
#sidebar-header { padding: 24px; border-bottom: 1px solid var(--border); }
|
|
#sidebar-header h1 { font-size: 20px; margin: 0; font-weight: 700; }
|
|
#sidebar-header p { font-size: 11px; color: var(--text-muted); margin: 4px 0 0 0; }
|
|
#search-bar { margin: 16px 24px; }
|
|
#search-input {
|
|
width: 80%;
|
|
padding: 10px 16px;
|
|
border-radius: 12px;
|
|
border: 1px solid var(--border);
|
|
background-color: var(--bg);
|
|
color: var(--text);
|
|
font-size: 13px;
|
|
outline: none;
|
|
}
|
|
#sidebar-list { flex-grow: 1; overflow-y: auto; padding: 0 16px 24px 16px; }
|
|
.section-title {
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.1em;
|
|
color: var(--text-muted);
|
|
margin: 20px 8px 8px 8px;
|
|
}
|
|
.item-card {
|
|
padding: 12px 16px;
|
|
border-radius: 12px;
|
|
cursor: pointer;
|
|
margin-bottom: 4px;
|
|
border: 1px solid transparent;
|
|
}
|
|
.item-card:hover { background-color: var(--bg); }
|
|
.item-card.active { background-color: var(--accent); border-color: var(--border); }
|
|
.item-card h3 { font-size: 13px; font-weight: 600; margin: 0; }
|
|
.item-card p {
|
|
font-size: 11px;
|
|
color: var(--text-muted);
|
|
margin: 4px 0 0 0;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
}
|
|
#content-area { flex-grow: 1; padding: 48px; overflow-y: auto; display: flex; flex-direction: column; }
|
|
#welcome-screen { margin: auto; text-align: center; max-width: 400px; }
|
|
#welcome-screen h2 { font-size: 28px; margin-bottom: 8px; }
|
|
#welcome-screen p { color: var(--text-muted); font-size: 14px; line-height: 1.6; }
|
|
#detail-container { display: none; max-width: 800px; width: 100%; margin: 0 auto; }
|
|
.meta-badge {
|
|
display: inline-block;
|
|
padding: 4px 8px;
|
|
border-radius: 6px;
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
background-color: var(--border);
|
|
color: var(--text-muted);
|
|
margin-right: 8px;
|
|
}
|
|
#detail-header h2 { font-size: 36px; margin: 16px 0 8px 0; font-weight: 700; }
|
|
#detail-header p {
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
margin: 0 0 24px 0;
|
|
border-bottom: 1px solid var(--border);
|
|
padding-bottom: 16px;
|
|
}
|
|
#detail-body { font-size: 15px; line-height: 1.8; }
|
|
.canvas-node {
|
|
padding: 12px 16px;
|
|
border-radius: 12px;
|
|
border: 1px solid var(--border);
|
|
margin-bottom: 8px;
|
|
background-color: var(--card-bg);
|
|
font-size: 13px;
|
|
}
|
|
.starred-node { background-color: var(--rose); border-color: var(--rose-border); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="sidebar">
|
|
<div id="sidebar-header">
|
|
<h1>Momento</h1>
|
|
<p>Offline Workspace Export</p>
|
|
</div>
|
|
<div id="search-bar">
|
|
<input type="text" id="search-input" placeholder="Search notes..." oninput="filterItems()">
|
|
</div>
|
|
<div id="sidebar-list">
|
|
<div class="section-title">Notebooks & Notes</div>
|
|
<div id="notes-list"></div>
|
|
<div class="section-title">Canvases</div>
|
|
<div id="canvases-list"></div>
|
|
</div>
|
|
</div>
|
|
<div id="content-area">
|
|
<div id="welcome-screen">
|
|
<h2>Welcome Back</h2>
|
|
<p>Explore your entire workspace offline. Use the sidebar to search and select your notes and canvases.</p>
|
|
</div>
|
|
<div id="detail-container">
|
|
<div id="detail-header">
|
|
<span id="notebook-badge" class="meta-badge">Notebook</span>
|
|
<span id="date-badge" class="meta-badge">Date</span>
|
|
<h2 id="note-title">Title</h2>
|
|
<p id="note-meta">Meta description</p>
|
|
</div>
|
|
<div id="detail-body">Content</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
const notes = ${JSON.stringify(offlineNotes)};
|
|
const canvases = ${JSON.stringify(offlineCanvases)};
|
|
const notesList = document.getElementById('notes-list');
|
|
const canvasesList = document.getElementById('canvases-list');
|
|
const welcomeScreen = document.getElementById('welcome-screen');
|
|
const detailContainer = document.getElementById('detail-container');
|
|
const noteTitle = document.getElementById('note-title');
|
|
const dateBadge = document.getElementById('date-badge');
|
|
const notebookBadge = document.getElementById('notebook-badge');
|
|
const noteMeta = document.getElementById('note-meta');
|
|
const detailBody = document.getElementById('detail-body');
|
|
let activeIndex = -1;
|
|
let activeType = '';
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function renderList() {
|
|
notesList.innerHTML = notes.map((n, i) => \`
|
|
<div class="item-card \${activeType === 'note' && activeIndex === i ? 'active' : ''}" data-type="note" data-index="\${i}" onclick="selectItem('note', \${i})">
|
|
<h3>\${escapeHtml(n.title)}</h3>
|
|
<p><span>\${escapeHtml(n.notebook)}</span> <span>\${new Date(n.updatedAt).toLocaleDateString()}</span></p>
|
|
</div>
|
|
\`).join('');
|
|
canvasesList.innerHTML = canvases.map((c, i) => \`
|
|
<div class="item-card \${activeType === 'canvas' && activeIndex === i ? 'active' : ''}" data-type="canvas" data-index="\${i}" onclick="selectItem('canvas', \${i})">
|
|
<h3>\${escapeHtml(c.title)}</h3>
|
|
<p><span>Canvas</span> <span>\${new Date(c.createdAt).toLocaleDateString()}</span></p>
|
|
</div>
|
|
\`).join('');
|
|
}
|
|
|
|
function selectItem(type, index) {
|
|
activeIndex = index;
|
|
activeType = type;
|
|
renderList();
|
|
welcomeScreen.style.display = 'none';
|
|
detailContainer.style.display = 'block';
|
|
if (type === 'note') {
|
|
const note = notes[index];
|
|
notebookBadge.innerText = note.notebook;
|
|
dateBadge.innerText = new Date(note.updatedAt).toLocaleDateString();
|
|
noteTitle.innerText = note.title;
|
|
noteMeta.innerText = 'Created: ' + new Date(note.createdAt).toLocaleString() + ' | Tags: ' + (note.tags.join(', ') || 'None');
|
|
detailBody.innerHTML = note.content;
|
|
} else {
|
|
const canvas = canvases[index];
|
|
notebookBadge.innerText = 'Canvas';
|
|
dateBadge.innerText = new Date(canvas.createdAt).toLocaleDateString();
|
|
noteTitle.innerText = canvas.title;
|
|
noteMeta.innerText = 'Total Ideas: ' + canvas.ideas.length;
|
|
let nodesHtml = '<div style="margin-top: 24px;">';
|
|
canvas.ideas.forEach(idea => {
|
|
const pos = idea.x != null && idea.y != null ? ' (' + idea.x + ', ' + idea.y + ')' : '';
|
|
const conn = idea.connectionToSeed ? ' | ' + escapeHtml(idea.connectionToSeed) : '';
|
|
nodesHtml += '<div class="canvas-node' + (idea.isStarred ? ' starred-node' : '') + '">' +
|
|
'<strong>[' + escapeHtml((idea.kind || 'idea').toUpperCase()) + ']</strong> ' + escapeHtml(idea.title) +
|
|
(idea.description ? '<div style="margin-top:6px">' + escapeHtml(idea.description) + '</div>' : '') +
|
|
'<div style="font-size: 10px; color: var(--text-muted); margin-top: 4px;">Position' + pos + conn + '</div></div>';
|
|
});
|
|
nodesHtml += '</div>';
|
|
detailBody.innerHTML = nodesHtml;
|
|
}
|
|
}
|
|
|
|
function filterItems() {
|
|
const q = document.getElementById('search-input').value.toLowerCase();
|
|
document.querySelectorAll('#notes-list .item-card').forEach((card) => {
|
|
const i = Number(card.dataset.index);
|
|
const n = notes[i];
|
|
const match = n.title.toLowerCase().includes(q) || n.notebook.toLowerCase().includes(q) || n.content.toLowerCase().includes(q);
|
|
card.style.display = match ? 'block' : 'none';
|
|
});
|
|
document.querySelectorAll('#canvases-list .item-card').forEach((card) => {
|
|
const i = Number(card.dataset.index);
|
|
const c = canvases[i];
|
|
const hay = (c.title + ' ' + c.ideas.map(x => x.title + ' ' + (x.description || '')).join(' ')).toLowerCase();
|
|
card.style.display = hay.includes(q) ? 'block' : 'none';
|
|
});
|
|
}
|
|
|
|
renderList();
|
|
</script>
|
|
</body>
|
|
</html>`
|
|
|
|
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 })
|
|
}
|
|
}
|