- Nouveau bloc Toggle : sections dépliables dans l'éditeur (slash menu + drag handle) - Transformer un bloc existant en section repliable via le menu d'action - Boutons désactiver (unwrap) et supprimer dans l'en-tête - i18n FR/EN complet - Infrastructure embeddings par fragments (inspiré AppFlowy) - Table NoteEmbeddingChunk + index HNSW - Chunking sémantique (~1000 chars, overlap 200, dedup par hash) - Indexation incrémentale au save (createNote + updateNote + clip) - Queue concurrence 4, retry backoff exponentiel
124 lines
4.5 KiB
TypeScript
124 lines
4.5 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
|
import { auth } from '@/auth'
|
|
import prisma from '@/lib/prisma'
|
|
import { syncNoteLabels } from '@/app/actions/notes'
|
|
import { createNotification } from '@/app/actions/notifications'
|
|
import { buildClipSourceFooter, clipFooterLocaleTag } from '@/lib/clip/extract-article'
|
|
import { resolveClipLocale, wrapClipArticleHtml, applyRtlToHtmlBlocks } from '@/lib/clip/rtl-content'
|
|
import { embeddingService } from '@/lib/ai/services/embedding.service'
|
|
import { chunkIndexingService } from '@/lib/ai/services/chunk-indexing.service'
|
|
import { upsertNoteEmbedding } from '@/lib/embeddings'
|
|
import DOMPurify from 'isomorphic-dompurify'
|
|
|
|
function isBlockedUrl(url: string): boolean {
|
|
try {
|
|
const parsed = new URL(url)
|
|
const hostname = parsed.hostname.toLowerCase()
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return true
|
|
return ['localhost', '127.0.0.1', '0.0.0.0', '::1'].includes(hostname)
|
|
} catch {
|
|
return true
|
|
}
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const session = await auth()
|
|
if (!session?.user?.id) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
const body = await request.json()
|
|
const url = typeof body.url === 'string' ? body.url.trim() : ''
|
|
const title = typeof body.title === 'string' ? body.title.trim().slice(0, 300) : null
|
|
const rawContent = typeof body.content === 'string' ? body.content : ''
|
|
const summary = typeof body.summary === 'string' ? body.summary.trim() : ''
|
|
const notebookId = typeof body.notebookId === 'string' ? body.notebookId : null
|
|
const tags = Array.isArray(body.tags)
|
|
? body.tags.filter((t: unknown): t is string => typeof t === 'string').slice(0, 5)
|
|
: []
|
|
|
|
if (!url || isBlockedUrl(url)) {
|
|
return NextResponse.json({ error: 'Invalid URL' }, { status: 400 })
|
|
}
|
|
if (!rawContent.trim()) {
|
|
return NextResponse.json({ error: 'Content required' }, { status: 400 })
|
|
}
|
|
|
|
await prisma.user.upsert({
|
|
where: { id: session.user.id },
|
|
update: {
|
|
...(session.user.email ? { email: session.user.email } : {}),
|
|
...(session.user.name !== undefined ? { name: session.user.name } : {}),
|
|
},
|
|
create: {
|
|
id: session.user.id,
|
|
email: session.user.email || `user-${session.user.id}@local.momento`,
|
|
name: session.user.name || null,
|
|
},
|
|
})
|
|
|
|
const domain = new URL(url).hostname.replace(/^www\./, '')
|
|
const locale = resolveClipLocale(url, title || '', summary, rawContent.replace(/<[^>]+>/g, ' '))
|
|
const sanitizedContent = DOMPurify.sanitize(rawContent)
|
|
const rtlBlocks = applyRtlToHtmlBlocks(sanitizedContent, locale)
|
|
const summaryBlock = summary
|
|
? `<p dir="${locale.direction}"${locale.lang ? ` lang="${locale.lang}"` : ''}><em>${DOMPurify.sanitize(summary)}</em></p>`
|
|
: ''
|
|
const footer = buildClipSourceFooter(domain, new Date(), clipFooterLocaleTag(locale.lang))
|
|
const bodyHtml = rtlBlocks.includes('clip-article--rtl')
|
|
? rtlBlocks
|
|
: wrapClipArticleHtml(rtlBlocks, locale)
|
|
const fullContent = `${summaryBlock}${bodyHtml}${footer}`
|
|
|
|
const note = await prisma.note.create({
|
|
data: {
|
|
userId: session.user.id,
|
|
title: title || domain,
|
|
content: fullContent,
|
|
type: 'richtext',
|
|
notebookId,
|
|
sourceUrl: url,
|
|
autoGenerated: true,
|
|
...(locale.lang ? { language: locale.lang } : {}),
|
|
},
|
|
})
|
|
|
|
void (async () => {
|
|
try {
|
|
const { embedding } = await embeddingService.generateNoteEmbedding(
|
|
title || domain,
|
|
fullContent,
|
|
)
|
|
if (embedding?.length) {
|
|
await upsertNoteEmbedding(note.id, embedding)
|
|
}
|
|
|
|
await chunkIndexingService.indexNote(note.id, title || domain, fullContent)
|
|
} catch (error) {
|
|
console.error('[clip/save] embedding generation failed:', error)
|
|
}
|
|
})()
|
|
|
|
if (tags.length > 0) {
|
|
await syncNoteLabels(note.id, tags, notebookId, session.user.id)
|
|
}
|
|
|
|
const noteUrl = `/home?openNote=${encodeURIComponent(note.id)}`
|
|
|
|
await createNotification({
|
|
userId: session.user.id,
|
|
type: 'clip',
|
|
title: title || domain,
|
|
message: summary || undefined,
|
|
actionUrl: noteUrl,
|
|
relatedId: note.id,
|
|
})
|
|
|
|
return NextResponse.json({ noteId: note.id, noteUrl })
|
|
} catch (error) {
|
|
console.error('[POST /api/clip/save]', error)
|
|
return NextResponse.json({ error: 'Save failed' }, { status: 500 })
|
|
}
|
|
}
|