Files
Antigravity 96e7902f01
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m22s
CI / Deploy production (on server) (push) Has been skipped
feat: publication IA (magazine/brief/essay) + fixes critique
Publication IA:
- 4 templates (magazine, brief, essay, simple) avec CSS riche
- Rewrite IA (article/exercises/tutorial/reference/mixed)
- Modération avec timeout 12s + fallback safe
- Quotas publish_enhance par tier (basic=2, pro=15, business=100)
- Détection contenu stale (hash)
- Migration DB publishedContent/publishedTemplate/publishedSourceHash

Fixes:
- cheerio v1.2: Element -> AnyNode (domhandler), decodeEntities cast
- _isShared ajouté au type Note (champ virtuel serveur)
- callout colors PDF export: extraction fonction pure testable
- admin/published: guard note.userId null
- Cmd+S fonctionne en mode dialog (pas seulement fullPage)

i18n:
- 23 clés publish* traduites dans les 15 locales
- Extension Web Clipper: 13 locales mise à jour

Tests:
- callout-colors.test.ts (6 tests)
- note-visible-in-view.test.ts (5 tests)
- entitlements.test.ts + byok-entitlements.test.ts: mock usageLog + unstubAllEnvs
- 199/199 tests passent

Tracker: user-stories.md sync avec sprint-status.yaml
2026-06-28 07:32:57 +00:00

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>Memento</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 })
}
}