feat: add slides generation tool with multiple slide types
- Add slides.tool.ts with support for title, bullets, chart, stats, table, cards, timeline, quote, comparison, equation, image, summary slide types - Chart types: bar, horizontal-bar, line, donut, radar - Integrate with agent executor and canvas system - Add multilingual support (en/fr) - Various UI improvements and bug fixes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,9 +10,9 @@ const TYPE_DEFAULTS: Record<GenerateType, {
|
||||
maxSteps: number
|
||||
}> = {
|
||||
'slide-generator': {
|
||||
role: 'Crée une présentation PowerPoint professionnelle et visuelle à partir du contenu de la note fournie.',
|
||||
tools: ['note_search', 'note_read', 'generate_pptx'],
|
||||
maxSteps: 8,
|
||||
role: 'Génère une présentation professionnelle à partir du contenu de la note fournie. Appelle generate_slides avec un objet JSON structuré {title, theme, slides:[...]}.',
|
||||
tools: ['note_search', 'note_read', 'generate_slides'],
|
||||
maxSteps: 6,
|
||||
},
|
||||
'excalidraw-generator': {
|
||||
role: 'Génère un diagramme Excalidraw clair et professionnel à partir du contenu de la note fournie.',
|
||||
@@ -30,12 +30,13 @@ export async function POST(req: NextRequest) {
|
||||
const userId = session.user.id
|
||||
|
||||
const body = await req.json()
|
||||
const { noteId, type, theme, style, language } = body as {
|
||||
const { noteId, type, theme, style, language, template } = body as {
|
||||
noteId: string
|
||||
type: GenerateType
|
||||
theme?: string
|
||||
style?: string
|
||||
language?: string
|
||||
template?: string
|
||||
}
|
||||
|
||||
if (!noteId || !type || !TYPE_DEFAULTS[type]) {
|
||||
@@ -56,10 +57,14 @@ export async function POST(req: NextRequest) {
|
||||
let role = defaults.role
|
||||
if (isEn) {
|
||||
if (type === 'slide-generator') {
|
||||
role = 'Create a professional and visual PowerPoint presentation from the provided note content.'
|
||||
const recipeHint = (theme && theme !== 'auto') ? ` Use theme:"${theme}".` : ''
|
||||
role = `Generate a professional presentation from the provided note content.${recipeHint} Call generate_slides with structured JSON {title, theme, slides:[...]}.`
|
||||
} else {
|
||||
role = 'Generate a clear and professional Excalidraw diagram from the provided note content.'
|
||||
}
|
||||
} else if (type === 'slide-generator') {
|
||||
const recipeHint = (theme && theme !== 'auto') ? ` Utilise le thème "${theme}".` : ''
|
||||
role = `Génère une présentation professionnelle à partir du contenu de la note fournie.${recipeHint} Appelle generate_slides avec le JSON structuré {title, theme, slides:[...]}.`
|
||||
}
|
||||
|
||||
const agentName = type === 'slide-generator'
|
||||
@@ -71,13 +76,14 @@ export async function POST(req: NextRequest) {
|
||||
name: agentName,
|
||||
type,
|
||||
role,
|
||||
description: (type === 'slide-generator' && template && template !== 'auto') ? `template:${template}` : undefined,
|
||||
tools: JSON.stringify(defaults.tools),
|
||||
maxSteps: defaults.maxSteps,
|
||||
frequency: 'one-shot',
|
||||
isEnabled: true,
|
||||
sourceNoteIds: JSON.stringify([noteId]),
|
||||
targetNotebookId: note.notebookId ?? undefined,
|
||||
slideTheme: theme ?? 'vibrant_tech',
|
||||
slideTheme: theme ?? 'keynote',
|
||||
slideStyle: style ?? 'soft',
|
||||
userId,
|
||||
},
|
||||
@@ -118,7 +124,7 @@ export async function GET(req: NextRequest) {
|
||||
const action = await prisma.agentAction.findFirst({
|
||||
where: { agentId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { id: true, status: true, result: true, log: true },
|
||||
select: { id: true, status: true, result: true, log: true, createdAt: true },
|
||||
})
|
||||
|
||||
if (!action) {
|
||||
@@ -126,6 +132,19 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ status: 'running' })
|
||||
}
|
||||
|
||||
// Detect stale running actions (killed by process restart or hot-reload)
|
||||
const GENERATION_TIMEOUT_MS = 8 * 60 * 1000 // 8 minutes
|
||||
if (action.status === 'running') {
|
||||
const ageMs = Date.now() - new Date(action.createdAt).getTime()
|
||||
if (ageMs > GENERATION_TIMEOUT_MS) {
|
||||
await prisma.agentAction.update({
|
||||
where: { id: action.id },
|
||||
data: { status: 'failure', log: 'Generation timed out (process restart)' },
|
||||
}).catch(() => { /* best-effort */ })
|
||||
return NextResponse.json({ status: 'failure', actionId: action.id, error: 'La génération a expiré. Veuillez réessayer.' })
|
||||
}
|
||||
}
|
||||
|
||||
// If success, find canvasId from the related canvas (result stores canvas id)
|
||||
let canvasId: string | null = null
|
||||
let noteId: string | null = null
|
||||
|
||||
130
memento-note/app/api/ai/personas/route.ts
Normal file
130
memento-note/app/api/ai/personas/route.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { getTagsProvider } from '@/lib/ai/factory'
|
||||
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
|
||||
|
||||
export type PersonaId = 'engineer' | 'financial' | 'customer' | 'skeptic' | 'optimist'
|
||||
|
||||
interface PersonaDef {
|
||||
id: PersonaId
|
||||
label: string
|
||||
description: string
|
||||
systemPrompt: string
|
||||
}
|
||||
|
||||
const PERSONAS: PersonaDef[] = [
|
||||
{
|
||||
id: 'engineer',
|
||||
label: 'Ingénieur',
|
||||
description: 'Faisabilité technique, complexité, risques d\'implémentation',
|
||||
systemPrompt: `Tu es un ingénieur senior pragmatique et rigoureux. Analyse les notes ci-dessous en te concentrant sur :
|
||||
la faisabilité technique, les risques d'implémentation, la complexité cachée, les dépendances et contraintes techniques.
|
||||
Fournis 3 à 5 commentaires concis et 2 à 3 questions techniques précises qui obligent à réfléchir plus profondément.
|
||||
Réponds dans la même langue que la note. Format : commentaires en liste puis questions en liste.`,
|
||||
},
|
||||
{
|
||||
id: 'financial',
|
||||
label: 'Financier',
|
||||
description: 'ROI, coûts, viabilité économique, risques financiers',
|
||||
systemPrompt: `Tu es un analyste financier exigeant. Analyse les notes ci-dessous sous l'angle économique :
|
||||
ROI potentiel, coûts cachés, modèle de revenus, risques financiers, allocation de ressources, viabilité à long terme.
|
||||
Fournis 3 à 5 observations financières et 2 à 3 questions qui remettent en cause les hypothèses économiques.
|
||||
Réponds dans la même langue que la note. Format : observations en liste puis questions en liste.`,
|
||||
},
|
||||
{
|
||||
id: 'customer',
|
||||
label: 'Client',
|
||||
description: 'Expérience utilisateur, besoins réels, valeur perçue',
|
||||
systemPrompt: `Tu es un client exigeant qui recherche de la valeur concrète. Analyse les notes ci-dessous du point de vue de l'utilisateur final :
|
||||
clarté de la valeur ajoutée, frictions dans l'expérience, besoins non satisfaits, adéquation au problème réel.
|
||||
Fournis 3 à 5 réactions authentiques de client et 2 à 3 questions sur l'utilité réelle.
|
||||
Réponds dans la même langue que la note. Format : réactions en liste puis questions en liste.`,
|
||||
},
|
||||
{
|
||||
id: 'skeptic',
|
||||
label: 'Sceptique',
|
||||
description: 'Hypothèses contestables, points faibles, angles morts',
|
||||
systemPrompt: `Tu es un expert sceptique et critique constructif. Analyse les notes ci-dessous en cherchant :
|
||||
les hypothèses non validées, les points faibles, les angles morts, les contradictions internes, les risques ignorés.
|
||||
Sois direct et précis. Fournis 3 à 5 critiques fondées et 2 à 3 questions qui remettent en cause les fondements.
|
||||
Réponds dans la même langue que la note. Format : critiques en liste puis questions en liste.`,
|
||||
},
|
||||
{
|
||||
id: 'optimist',
|
||||
label: 'Optimiste',
|
||||
description: 'Opportunités sous-estimées, potentiel inexploité, synergies',
|
||||
systemPrompt: `Tu es un stratège visionnaire et enthousiaste. Analyse les notes ci-dessous pour identifier :
|
||||
les opportunités sous-estimées, le potentiel inexploité, les synergies possibles, les effets de levier, les succès rapides atteignables.
|
||||
Fournis 3 à 5 opportunités concrètes et 2 à 3 questions qui ouvrent de nouvelles perspectives.
|
||||
Réponds dans la même langue que la note. Format : opportunités en liste puis questions en liste.`,
|
||||
},
|
||||
]
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { content, title, personaId } = await request.json()
|
||||
|
||||
if (!content || typeof content !== 'string') {
|
||||
return NextResponse.json({ error: 'Content is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!personaId) {
|
||||
return NextResponse.json({ error: 'personaId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const persona = PERSONAS.find(p => p.id === personaId)
|
||||
if (!persona) {
|
||||
return NextResponse.json({ error: 'Invalid personaId' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Quota check (reuse reformulate quota)
|
||||
try {
|
||||
await checkEntitlementOrThrow(session.user.id, 'reformulate')
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
const isTierLocked = err.currentQuota === 0
|
||||
return NextResponse.json({
|
||||
error: isTierLocked ? 'feature_locked' : 'quota_exceeded',
|
||||
errorKey: isTierLocked ? 'ai.featureLocked' : 'ai.quotaExceeded',
|
||||
upgradeTier: err.upgradeTier,
|
||||
quotaExceeded: true,
|
||||
}, { status: 402 })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getTagsProvider(config)
|
||||
|
||||
const noteText = title ? `Titre : ${title}\n\n${content}` : content
|
||||
// Strip HTML tags for cleaner analysis
|
||||
const plainText = noteText.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
|
||||
const fullPrompt = `${persona.systemPrompt}\n\n---\nNOTE À ANALYSER :\n${plainText}`
|
||||
const result = await provider.generateText(fullPrompt)
|
||||
|
||||
incrementUsageAsync(session.user.id, 'reformulate')
|
||||
|
||||
return NextResponse.json({
|
||||
personaId: persona.id,
|
||||
personaLabel: persona.label,
|
||||
analysis: result,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Personas API error:', error)
|
||||
return NextResponse.json({ error: 'Failed to generate persona analysis' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// GET — return available personas (no auth needed)
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
personas: PERSONAS.map(({ id, label, description }) => ({ id, label, description })),
|
||||
})
|
||||
}
|
||||
@@ -95,6 +95,16 @@ async function getParentContext(
|
||||
return { notes, noteIds }
|
||||
}
|
||||
|
||||
function localeToLanguageName(locale?: string): string {
|
||||
const map: Record<string, string> = {
|
||||
en: 'English', fr: 'French', es: 'Spanish', de: 'German',
|
||||
it: 'Italian', pt: 'Portuguese', nl: 'Dutch', ru: 'Russian',
|
||||
zh: 'Chinese', ja: 'Japanese', ko: 'Korean', ar: 'Arabic',
|
||||
fa: 'Farsi', hi: 'Hindi', pl: 'Polish',
|
||||
}
|
||||
return map[locale ?? ''] ?? 'English'
|
||||
}
|
||||
|
||||
function buildExpandPromptV2(
|
||||
parentTitle: string,
|
||||
parentDesc: string,
|
||||
@@ -157,7 +167,7 @@ RESPOND ONLY with a valid JSON array of 9 objects:
|
||||
|
||||
CRITICAL: Each idea MUST have at least 1 noteRef when notes are provided.
|
||||
|
||||
LANGUAGE: You MUST write ALL titles, descriptions, connectionToSeed, and explanation fields in ${locale === 'fr' ? 'French' : locale === 'es' ? 'Spanish' : locale === 'de' ? 'German' : locale === 'it' ? 'Italian' : locale === 'pt' ? 'Portuguese' : locale === 'nl' ? 'Dutch' : locale === 'ru' ? 'Russian' : locale === 'zh' ? 'Chinese' : locale === 'ja' ? 'Japanese' : locale === 'ko' ? 'Korean' : locale === 'ar' ? 'Arabic' : locale === 'fa' ? 'Farsi' : locale === 'hi' ? 'Hindi' : locale === 'pl' ? 'Polish' : 'the same language as the seed idea'}.`
|
||||
LANGUAGE: You MUST write ALL titles, descriptions, connectionToSeed, and explanation fields in ${localeToLanguageName(locale)}.`
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
|
||||
@@ -161,6 +161,7 @@ export async function PATCH(
|
||||
const updates: Record<string, any> = {}
|
||||
if (typeof body.isPublic === 'boolean') updates.isPublic = body.isPublic
|
||||
if (typeof body.guestCanEdit === 'boolean') updates.guestCanEdit = body.guestCanEdit
|
||||
if (typeof body.seedIdea === 'string' && body.seedIdea.trim()) updates.seedIdea = body.seedIdea.trim()
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 })
|
||||
|
||||
63
memento-note/app/api/brainstorm/[sessionId]/star/route.ts
Normal file
63
memento-note/app/api/brainstorm/[sessionId]/star/route.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { z } from 'zod'
|
||||
import { verifyParticipant } from '@/lib/brainstorm-collab'
|
||||
import { emitToSession } from '@/lib/socket-emit'
|
||||
|
||||
const starSchema = z.object({
|
||||
ideaId: z.string().min(1),
|
||||
})
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { sessionId } = await params
|
||||
const body = await request.json()
|
||||
const { ideaId } = starSchema.parse(body)
|
||||
|
||||
const brainstormSession = await prisma.brainstormSession.findFirst({
|
||||
where: { id: sessionId },
|
||||
})
|
||||
|
||||
if (!brainstormSession) {
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { isParticipant } = await verifyParticipant(sessionId, session.user.id, 'editor')
|
||||
if (!isParticipant) {
|
||||
return NextResponse.json({ error: 'No edit permission' }, { status: 403 })
|
||||
}
|
||||
|
||||
const idea = await prisma.brainstormIdea.findFirst({
|
||||
where: { id: ideaId, sessionId },
|
||||
})
|
||||
|
||||
if (!idea) {
|
||||
return NextResponse.json({ error: 'Idea not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const updated = await prisma.brainstormIdea.update({
|
||||
where: { id: ideaId },
|
||||
data: { isStarred: !idea.isStarred },
|
||||
})
|
||||
|
||||
await emitToSession(sessionId, 'idea:starred', {
|
||||
ideaId,
|
||||
isStarred: updated.isStarred,
|
||||
userId: session.user.id,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, data: { ideaId, isStarred: updated.isStarred } })
|
||||
} catch (error) {
|
||||
console.error('Error starring idea:', error)
|
||||
return NextResponse.json({ error: 'Failed to star idea' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { runLaneWithBillingUser } from '@/lib/ai/provider-for-user'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { verifyParticipant } from '@/lib/brainstorm-collab'
|
||||
|
||||
function localeToLanguageName(locale?: string): string {
|
||||
const map: Record<string, string> = {
|
||||
en: 'English', fr: 'French', es: 'Spanish', de: 'German',
|
||||
it: 'Italian', pt: 'Portuguese', nl: 'Dutch', ru: 'Russian',
|
||||
zh: 'Chinese', ja: 'Japanese', ko: 'Korean', ar: 'Arabic',
|
||||
fa: 'Farsi', hi: 'Hindi', pl: 'Polish',
|
||||
}
|
||||
return map[locale ?? ''] ?? 'English'
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { sessionId } = await params
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const locale: string = body.locale || 'en'
|
||||
|
||||
const brainstormSession = await prisma.brainstormSession.findFirst({
|
||||
where: { id: sessionId },
|
||||
include: {
|
||||
ideas: {
|
||||
where: { status: { not: 'dismissed' } },
|
||||
orderBy: [{ waveNumber: 'asc' }, { createdAt: 'asc' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!brainstormSession) {
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { isParticipant } = await verifyParticipant(sessionId, session.user.id, 'viewer')
|
||||
if (!isParticipant) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const ideas = brainstormSession.ideas
|
||||
if (ideas.length === 0) {
|
||||
return NextResponse.json({ error: 'No ideas to summarize' }, { status: 400 })
|
||||
}
|
||||
|
||||
const ideasText = ideas
|
||||
.map((idea, i) => `${i + 1}. [Wave ${idea.waveNumber}] ${idea.title}: ${idea.description}`)
|
||||
.join('\n')
|
||||
|
||||
const starredIdeas = ideas.filter(i => i.isStarred)
|
||||
const starredText = starredIdeas.length > 0
|
||||
? `\nStarred/favorite ideas: ${starredIdeas.map(i => i.title).join(', ')}`
|
||||
: ''
|
||||
|
||||
const language = localeToLanguageName(locale)
|
||||
|
||||
const prompt = `You are summarizing a brainstorming session. Respond in ${language}.
|
||||
|
||||
Seed idea: "${brainstormSession.seedIdea}"
|
||||
|
||||
Ideas generated (${ideas.length} total):
|
||||
${ideasText}${starredText}
|
||||
|
||||
Write a concise synthesis (4-6 sentences) that:
|
||||
1. Captures the main themes and directions explored
|
||||
2. Highlights the most promising ideas or clusters
|
||||
3. Suggests a concrete next step
|
||||
|
||||
Be direct and action-oriented. No bullet points, just flowing prose.`
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const { result: summary } = await runLaneWithBillingUser(
|
||||
'tags',
|
||||
config,
|
||||
session.user.id,
|
||||
(provider) => provider.generateText(prompt),
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, data: { summary: summary.trim() } })
|
||||
} catch (error) {
|
||||
console.error('Error summarizing brainstorm:', error)
|
||||
return NextResponse.json({ error: 'Failed to summarize' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,16 @@ Respond ONLY with a valid JSON array of objects:
|
||||
}
|
||||
}
|
||||
|
||||
function localeToLanguageName(locale?: string): string {
|
||||
const map: Record<string, string> = {
|
||||
en: 'English', fr: 'French', es: 'Spanish', de: 'German',
|
||||
it: 'Italian', pt: 'Portuguese', nl: 'Dutch', ru: 'Russian',
|
||||
zh: 'Chinese', ja: 'Japanese', ko: 'Korean', ar: 'Arabic',
|
||||
fa: 'Farsi', hi: 'Hindi', pl: 'Polish',
|
||||
}
|
||||
return map[locale ?? ''] ?? 'English'
|
||||
}
|
||||
|
||||
function buildPromptV2(seedIdea: string, classifiedNotes: ClassifiedNote[], locale?: string): string {
|
||||
const supportNotes = classifiedNotes.filter(n => n.category === 'SUPPORT')
|
||||
const tensionNotes = classifiedNotes.filter(n => n.category === 'TENSION')
|
||||
@@ -168,7 +178,7 @@ RESPOND ONLY with a valid JSON array of 9 objects:
|
||||
|
||||
CRITICAL: Each idea MUST have at least 1 noteRef. Only use null noteId if genuinely no note connects to that idea.
|
||||
|
||||
LANGUAGE: You MUST write ALL titles, descriptions, connectionToSeed, and explanation fields in ${locale === 'fr' ? 'French' : locale === 'es' ? 'Spanish' : locale === 'de' ? 'German' : locale === 'it' ? 'Italian' : locale === 'pt' ? 'Portuguese' : locale === 'nl' ? 'Dutch' : locale === 'ru' ? 'Russian' : locale === 'zh' ? 'Chinese' : locale === 'ja' ? 'Japanese' : locale === 'ko' ? 'Korean' : locale === 'ar' ? 'Arabic' : locale === 'fa' ? 'Farsi' : locale === 'hi' ? 'Hindi' : locale === 'pl' ? 'Polish' : 'the same language as the seed idea'}.`
|
||||
LANGUAGE: You MUST write ALL titles, descriptions, connectionToSeed, and explanation fields in ${localeToLanguageName(locale)}.`
|
||||
}
|
||||
|
||||
function buildFallbackIdeas(classifiedNotes: ClassifiedNote[]): any[] {
|
||||
|
||||
528
memento-note/app/api/canvas/slides/pptx/route.ts
Normal file
528
memento-note/app/api/canvas/slides/pptx/route.ts
Normal file
@@ -0,0 +1,528 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import PptxGenJS from 'pptxgenjs'
|
||||
|
||||
// ─── Color helpers ─────────────────────────────────────────────────────────
|
||||
const hex = (c: string) => c.replace('#', '').toUpperCase()
|
||||
|
||||
interface PptxPalette {
|
||||
primary: string; secondary: string; accent: string
|
||||
bg: string; text: string; muted: string; isDark: boolean
|
||||
}
|
||||
|
||||
function resolvePalette(theme?: string): PptxPalette {
|
||||
const palettes: Record<string, PptxPalette> = {
|
||||
'midnight-cathedral': { primary: '0A0F1E', secondary: 'C9A84C', accent: 'E8D5B5', bg: '0A0F1E', text: 'F1F5F9', muted: '94A3B8', isDark: true },
|
||||
'aurora-borealis': { primary: '0F0A2A', secondary: '7C3AED', accent: '06B6D4', bg: '0F0A2A', text: 'F1F5F9', muted: '94A3B8', isDark: true },
|
||||
'tokyo-neon': { primary: '0A0A0F', secondary: 'FF006E', accent: '3A86FF', bg: '0A0A0F', text: 'F1F5F9', muted: '94A3B8', isDark: true },
|
||||
'venture-pitch': { primary: '18181B', secondary: 'F97316', accent: '14B8A6', bg: '18181B', text: 'F1F5F9', muted: '94A3B8', isDark: true },
|
||||
'forest-floor': { primary: '0D1B0E', secondary: '22C55E', accent: 'A3B18A', bg: '0D1B0E', text: 'F1F5F9', muted: '94A3B8', isDark: true },
|
||||
'steel-glass': { primary: '292524', secondary: 'D4C5A9', accent: '94A3B8', bg: '292524', text: 'F1F5F9', muted: '94A3B8', isDark: true },
|
||||
'cyberpunk-terminal': { primary: '0A0A0A', secondary: '00FF41', accent: 'FFA500', bg: '0A0A0A', text: 'F1F5F9', muted: '94A3B8', isDark: true },
|
||||
'sunlit-gallery': { primary: 'FAF7F0', secondary: 'D4A574', accent: '5B9BD5', bg: 'FAF7F0', text: '1C1C1C', muted: '64748B', isDark: false },
|
||||
'clinical-precision': { primary: 'F8FAFC', secondary: '0891B2', accent: '34D399', bg: 'F8FAFC', text: '0F172A', muted: '64748B', isDark: false },
|
||||
'editorial-ink': { primary: 'FFFCF5', secondary: '1A1A2E', accent: '800020', bg: 'FFFCF5', text: '1A1A2E', muted: '64748B', isDark: false },
|
||||
'coastal-morning': { primary: 'F0F7FF', secondary: '2563EB', accent: 'F97066', bg: 'F0F7FF', text: '0F172A', muted: '64748B', isDark: false },
|
||||
'paper-studio': { primary: 'FEFCF8', secondary: '1E293B', accent: 'C2410C', bg: 'FEFCF8', text: '1E293B', muted: '64748B', isDark: false },
|
||||
'architectural-saas': { primary: 'F2F0E9', secondary: 'A47148', accent: '4A4E69', bg: 'F2F0E9', text: '1C1C1C', muted: '64748B', isDark: false },
|
||||
}
|
||||
const key = (theme || 'architectural-saas').toLowerCase().replace(/[^a-z]/g, '-').replace(/-+/g, '-')
|
||||
return palettes[key] ?? palettes['architectural-saas']
|
||||
}
|
||||
|
||||
// ─── PPTX Builder (new JSON format) ────────────────────────────────────────
|
||||
async function buildPptx(spec: any): Promise<Buffer> {
|
||||
const pptx = new PptxGenJS()
|
||||
pptx.layout = 'LAYOUT_WIDE'
|
||||
pptx.title = spec.title || 'Presentation'
|
||||
pptx.subject = spec.title || 'Presentation'
|
||||
|
||||
const p = resolvePalette(spec.theme)
|
||||
const W = 13.33, H = 7.5
|
||||
|
||||
for (const slide of (spec.slides ?? [])) {
|
||||
const s = pptx.addSlide()
|
||||
|
||||
switch (slide.type) {
|
||||
case 'title': {
|
||||
s.background = { color: p.isDark ? p.primary : p.secondary }
|
||||
s.addShape(pptx.ShapeType.rect, {
|
||||
x: 0, y: 0, w: W, h: 0.08,
|
||||
fill: { color: p.accent }, line: { type: 'none' },
|
||||
})
|
||||
s.addText(slide.title || spec.title, {
|
||||
x: 1, y: 2, w: W - 2, h: 2,
|
||||
fontSize: 42, color: p.isDark ? 'FFFFFF' : 'FFFFFF', bold: true, align: 'center',
|
||||
})
|
||||
if (slide.subtitle) {
|
||||
s.addText(slide.subtitle, {
|
||||
x: 1, y: 4.3, w: W - 2, h: 1,
|
||||
fontSize: 20, color: 'FFFFFF', align: 'center', transparency: 20,
|
||||
})
|
||||
}
|
||||
s.addShape(pptx.ShapeType.rect, {
|
||||
x: 0, y: H - 0.08, w: W, h: 0.08,
|
||||
fill: { color: p.accent }, line: { type: 'none' },
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'bullets': {
|
||||
s.background = { color: p.bg }
|
||||
s.addShape(pptx.ShapeType.rect, {
|
||||
x: 0, y: 0, w: 0.06, h: H,
|
||||
fill: { color: p.secondary }, line: { type: 'none' },
|
||||
})
|
||||
s.addText(slide.title, {
|
||||
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
|
||||
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
|
||||
})
|
||||
s.addShape(pptx.ShapeType.rect, {
|
||||
x: 0.5, y: 1.35, w: 0.5, h: 0.06,
|
||||
fill: { color: p.accent }, line: { type: 'none' },
|
||||
})
|
||||
const bullets = (slide.items ?? []).map((item: string) => ({
|
||||
text: item,
|
||||
options: { bullet: { type: 'bullet' as const }, paraSpaceAfter: 6 },
|
||||
}))
|
||||
if (bullets.length) {
|
||||
s.addText(bullets, {
|
||||
x: 0.5, y: 1.6, w: W - 1, h: H - 2,
|
||||
fontSize: 17, color: p.text,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'chart': {
|
||||
s.background = { color: p.bg }
|
||||
s.addShape(pptx.ShapeType.rect, {
|
||||
x: 0, y: 0, w: 0.06, h: H,
|
||||
fill: { color: p.secondary }, line: { type: 'none' },
|
||||
})
|
||||
s.addText(slide.title, {
|
||||
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
|
||||
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
|
||||
})
|
||||
if (slide.subtitle) {
|
||||
s.addText(slide.subtitle, {
|
||||
x: 0.5, y: 1.2, w: W - 1, h: 0.5,
|
||||
fontSize: 12, color: p.muted,
|
||||
})
|
||||
}
|
||||
const data = slide.data ?? []
|
||||
if (data.length > 0) {
|
||||
const chartType = slide.chartType === 'line' ? pptx.ChartType.line
|
||||
: slide.chartType === 'donut' ? pptx.ChartType.doughnut
|
||||
: slide.chartType === 'radar' ? pptx.ChartType.radar
|
||||
: pptx.ChartType.bar
|
||||
const chartData = [{
|
||||
name: slide.title,
|
||||
labels: data.map((d: any) => d.label),
|
||||
values: data.map((d: any) => d.value),
|
||||
}]
|
||||
s.addChart(chartType, chartData, {
|
||||
x: 0.8, y: 1.8, w: W - 1.6, h: H - 2.5,
|
||||
showValue: true,
|
||||
showTitle: false,
|
||||
showLegend: false,
|
||||
chartColors: [p.secondary, p.accent, '10B981', 'F59E0B', 'EF4444', '6366F1'],
|
||||
valAxisHidden: slide.chartType === 'donut' || slide.chartType === 'radar',
|
||||
catAxisHidden: slide.chartType === 'donut',
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'stats': {
|
||||
s.background = { color: p.bg }
|
||||
s.addShape(pptx.ShapeType.rect, {
|
||||
x: 0, y: 0, w: 0.06, h: H,
|
||||
fill: { color: p.secondary }, line: { type: 'none' },
|
||||
})
|
||||
s.addText(slide.title, {
|
||||
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
|
||||
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
|
||||
})
|
||||
const stats = (slide.stats ?? []).slice(0, 4)
|
||||
const statW = (W - 1 - (stats.length - 1) * 0.3) / stats.length
|
||||
stats.forEach((st: any, i: number) => {
|
||||
const x = 0.5 + i * (statW + 0.3)
|
||||
s.addShape(pptx.ShapeType.rect, {
|
||||
x, y: 2, w: statW, h: 0.06,
|
||||
fill: { color: p.accent }, line: { type: 'none' },
|
||||
})
|
||||
s.addText(st.value, {
|
||||
x, y: 2.3, w: statW, h: 2,
|
||||
fontSize: 48, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true, align: 'center',
|
||||
})
|
||||
s.addText(st.label, {
|
||||
x, y: 4.4, w: statW, h: 0.7,
|
||||
fontSize: 13, color: p.muted, align: 'center',
|
||||
})
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'table': {
|
||||
s.background = { color: p.bg }
|
||||
s.addText(slide.title, {
|
||||
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
|
||||
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
|
||||
})
|
||||
const headers = slide.headers ?? []
|
||||
const rows = slide.rows ?? []
|
||||
if (headers.length > 0) {
|
||||
const tableRows = [
|
||||
headers.map((h: string) => ({ text: h, options: { bold: true, color: 'FFFFFF', fill: { color: p.secondary } } })),
|
||||
...rows.map((row: string[], ri: number) =>
|
||||
row.map((cell: string) => ({ text: cell, options: { fill: { color: ri % 2 === 0 ? 'F8F8F8' : 'FFFFFF' } } }))
|
||||
),
|
||||
]
|
||||
s.addTable(tableRows, {
|
||||
x: 0.5, y: 1.5, w: W - 1, h: H - 2,
|
||||
fontSize: 13, color: p.text,
|
||||
border: { type: 'solid', pt: 0.5, color: 'DDDDDD' },
|
||||
colW: Array(headers.length).fill((W - 1) / headers.length),
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'cards': {
|
||||
s.background = { color: p.bg }
|
||||
s.addShape(pptx.ShapeType.rect, {
|
||||
x: 0, y: 0, w: 0.06, h: H,
|
||||
fill: { color: p.secondary }, line: { type: 'none' },
|
||||
})
|
||||
s.addText(slide.title, {
|
||||
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
|
||||
fontSize: 26, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
|
||||
})
|
||||
const cards = (slide.cards ?? []).slice(0, 6)
|
||||
const cols = cards.length <= 2 ? 2 : cards.length <= 3 ? 3 : cards.length === 4 ? 2 : 3
|
||||
const cardW = (W - 1 - (cols - 1) * 0.2) / cols
|
||||
const cardH = cards.length > cols ? 2.3 : 3.5
|
||||
cards.forEach((card: any, i: number) => {
|
||||
const col = i % cols
|
||||
const row = Math.floor(i / cols)
|
||||
const x = 0.5 + col * (cardW + 0.2)
|
||||
const y = 1.5 + row * (cardH + 0.15)
|
||||
s.addShape(pptx.ShapeType.roundRect, {
|
||||
x, y, w: cardW, h: cardH,
|
||||
fill: { color: p.isDark ? '1E1E2E' : 'FFFFFF' },
|
||||
line: { color: p.isDark ? '333333' : 'E5E7EB', pt: 1 },
|
||||
rectRadius: 0.08,
|
||||
})
|
||||
s.addShape(pptx.ShapeType.rect, {
|
||||
x, y, w: cardW, h: 0.05,
|
||||
fill: { color: p.accent }, line: { type: 'none' },
|
||||
})
|
||||
s.addText(card.title, {
|
||||
x: x + 0.15, y: y + 0.25, w: cardW - 0.3, h: 0.6,
|
||||
fontSize: 14, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
|
||||
})
|
||||
s.addText(card.description, {
|
||||
x: x + 0.15, y: y + 0.8, w: cardW - 0.3, h: cardH - 1,
|
||||
fontSize: 12, color: p.text,
|
||||
})
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'timeline': {
|
||||
s.background = { color: p.bg }
|
||||
s.addText(slide.title, {
|
||||
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
|
||||
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
|
||||
})
|
||||
const events = (slide.events ?? []).slice(0, 6)
|
||||
const evH = Math.min(0.9, (H - 2) / events.length)
|
||||
events.forEach((ev: any, i: number) => {
|
||||
const y = 1.6 + i * evH
|
||||
// Dot
|
||||
s.addShape(pptx.ShapeType.ellipse, {
|
||||
x: 0.7, y: y + 0.1, w: 0.2, h: 0.2,
|
||||
fill: { color: p.accent }, line: { type: 'none' },
|
||||
})
|
||||
// Line
|
||||
if (i < events.length - 1) {
|
||||
s.addShape(pptx.ShapeType.rect, {
|
||||
x: 0.78, y: y + 0.32, w: 0.04, h: evH - 0.2,
|
||||
fill: { color: p.accent }, line: { type: 'none' },
|
||||
})
|
||||
}
|
||||
s.addText(ev.date, {
|
||||
x: 1.1, y, w: 2, h: 0.4,
|
||||
fontSize: 10, color: p.accent, bold: true,
|
||||
})
|
||||
s.addText(ev.title, {
|
||||
x: 1.1, y: y + 0.3, w: W - 2, h: 0.4,
|
||||
fontSize: 14, color: p.text, bold: true,
|
||||
})
|
||||
if (ev.description) {
|
||||
s.addText(ev.description, {
|
||||
x: 1.1, y: y + 0.6, w: W - 2, h: 0.3,
|
||||
fontSize: 11, color: p.muted,
|
||||
})
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'quote': {
|
||||
const qBg = p.isDark ? '0D1117' : '1A1A2E'
|
||||
s.background = { color: qBg }
|
||||
s.addText('\u201C', {
|
||||
x: 0.5, y: 0.2, w: 2, h: 2,
|
||||
fontSize: 96, color: p.accent, bold: true, transparency: 60,
|
||||
})
|
||||
s.addText(slide.quote, {
|
||||
x: 1, y: 1.5, w: W - 2, h: 3.5,
|
||||
fontSize: 24, color: 'FFFFFF', italic: true, align: 'left',
|
||||
})
|
||||
if (slide.author) {
|
||||
s.addText(`\u2014 ${slide.author}`, {
|
||||
x: 1, y: 5.2, w: W - 2, h: 0.8,
|
||||
fontSize: 14, color: p.accent, align: 'left',
|
||||
})
|
||||
}
|
||||
if (slide.context) {
|
||||
s.addText(slide.context, {
|
||||
x: 1, y: 6, w: W - 2, h: 0.8,
|
||||
fontSize: 12, color: 'AAAAAA',
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'comparison': {
|
||||
s.background = { color: p.bg }
|
||||
s.addText(slide.title, {
|
||||
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
|
||||
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
|
||||
})
|
||||
const colW = (W - 1.5) / 2
|
||||
const colY = 1.5, colH = H - 2
|
||||
// Left
|
||||
if (slide.left) {
|
||||
s.addShape(pptx.ShapeType.roundRect, {
|
||||
x: 0.5, y: colY, w: colW, h: colH,
|
||||
fill: { color: p.isDark ? '1E1E2E' : 'F8FAFC' },
|
||||
line: { color: p.secondary, pt: 1 },
|
||||
rectRadius: 0.08,
|
||||
})
|
||||
s.addText(slide.left.title, {
|
||||
x: 0.7, y: colY + 0.15, w: colW - 0.4, h: 0.5,
|
||||
fontSize: 14, color: p.secondary, bold: true,
|
||||
})
|
||||
const lBullets = (slide.left.points ?? []).map((pt: string) => ({
|
||||
text: pt, options: { bullet: { type: 'bullet' as const }, paraSpaceAfter: 4 },
|
||||
}))
|
||||
s.addText(lBullets, {
|
||||
x: 0.7, y: colY + 0.7, w: colW - 0.4, h: colH - 1.4,
|
||||
fontSize: 13, color: p.text,
|
||||
})
|
||||
if (slide.left.score) {
|
||||
s.addText(slide.left.score, {
|
||||
x: 0.7, y: colY + colH - 0.6, w: colW - 0.4, h: 0.5,
|
||||
fontSize: 18, color: p.secondary, bold: true, align: 'center',
|
||||
})
|
||||
}
|
||||
}
|
||||
// Right
|
||||
if (slide.right) {
|
||||
const rx = 0.5 + colW + 0.5
|
||||
s.addShape(pptx.ShapeType.roundRect, {
|
||||
x: rx, y: colY, w: colW, h: colH,
|
||||
fill: { color: p.isDark ? '1E1E2E' : 'F8FAFC' },
|
||||
line: { color: p.accent, pt: 1 },
|
||||
rectRadius: 0.08,
|
||||
})
|
||||
s.addText(slide.right.title, {
|
||||
x: rx + 0.2, y: colY + 0.15, w: colW - 0.4, h: 0.5,
|
||||
fontSize: 14, color: p.accent, bold: true,
|
||||
})
|
||||
const rBullets = (slide.right.points ?? []).map((pt: string) => ({
|
||||
text: pt, options: { bullet: { type: 'bullet' as const }, paraSpaceAfter: 4 },
|
||||
}))
|
||||
s.addText(rBullets, {
|
||||
x: rx + 0.2, y: colY + 0.7, w: colW - 0.4, h: colH - 1.4,
|
||||
fontSize: 13, color: p.text,
|
||||
})
|
||||
if (slide.right.score) {
|
||||
s.addText(slide.right.score, {
|
||||
x: rx + 0.2, y: colY + colH - 0.6, w: colW - 0.4, h: 0.5,
|
||||
fontSize: 18, color: p.accent, bold: true, align: 'center',
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'equation': {
|
||||
s.background = { color: p.bg }
|
||||
s.addText(slide.title, {
|
||||
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
|
||||
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
|
||||
})
|
||||
const eqs = (slide.equations ?? []).slice(0, 4)
|
||||
const eqH = Math.min(1.5, (H - 2.5) / eqs.length)
|
||||
eqs.forEach((eq: any, i: number) => {
|
||||
const y = 1.8 + i * eqH
|
||||
s.addText(eq.latex, {
|
||||
x: 1, y, w: W - 2, h: eqH * 0.6,
|
||||
fontSize: 28, color: p.text, align: 'center', fontFace: 'Cambria Math',
|
||||
})
|
||||
if (eq.label) {
|
||||
s.addText(eq.label, {
|
||||
x: 1, y: y + eqH * 0.6, w: W - 2, h: eqH * 0.3,
|
||||
fontSize: 11, color: p.muted, align: 'center',
|
||||
})
|
||||
}
|
||||
})
|
||||
if (slide.explanation) {
|
||||
s.addText(slide.explanation, {
|
||||
x: 1, y: H - 1.2, w: W - 2, h: 0.8,
|
||||
fontSize: 12, color: p.muted, align: 'center',
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'image': {
|
||||
s.background = { color: p.bg }
|
||||
s.addText(slide.title, {
|
||||
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
|
||||
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
|
||||
})
|
||||
if (slide.url) {
|
||||
try {
|
||||
s.addImage({
|
||||
path: slide.url,
|
||||
x: 2, y: 1.5, w: W - 4, h: H - 2.5,
|
||||
sizing: { type: 'contain', w: W - 4, h: H - 2.5 },
|
||||
})
|
||||
} catch { /* image unavailable */ }
|
||||
}
|
||||
if (slide.caption) {
|
||||
s.addText(slide.caption, {
|
||||
x: 0.5, y: H - 0.9, w: W - 1, h: 0.7,
|
||||
fontSize: 12, color: p.muted, align: 'center',
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'summary': {
|
||||
s.background = { color: p.bg }
|
||||
s.addShape(pptx.ShapeType.rect, {
|
||||
x: 0, y: 0, w: 0.06, h: H,
|
||||
fill: { color: p.secondary }, line: { type: 'none' },
|
||||
})
|
||||
s.addText(slide.title || 'En résumé', {
|
||||
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
|
||||
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
|
||||
})
|
||||
s.addShape(pptx.ShapeType.rect, {
|
||||
x: 0.5, y: 1.35, w: 0.5, h: 0.06,
|
||||
fill: { color: p.accent }, line: { type: 'none' },
|
||||
})
|
||||
const items = (slide.items ?? []).slice(0, 8)
|
||||
items.forEach((item: string, i: number) => {
|
||||
const y = 1.6 + i * 0.7
|
||||
s.addShape(pptx.ShapeType.rect, {
|
||||
x: 0.5, y, w: W - 1, h: 0.55,
|
||||
fill: { color: i % 2 === 0 ? (p.isDark ? '1E1E2E' : 'F5F5F5') : (p.isDark ? '151525' : 'EBEBEB') },
|
||||
line: { type: 'none' },
|
||||
})
|
||||
s.addText('\u2713', {
|
||||
x: 0.7, y, w: 0.5, h: 0.55,
|
||||
fontSize: 14, color: p.accent, bold: true, valign: 'middle',
|
||||
})
|
||||
s.addText(item, {
|
||||
x: 1.3, y, w: W - 2, h: 0.55,
|
||||
fontSize: 14, color: p.text, valign: 'middle',
|
||||
})
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
// Fallback: treat as bullets
|
||||
default: {
|
||||
s.background = { color: p.bg }
|
||||
s.addText(slide.title || '', {
|
||||
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
|
||||
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
|
||||
})
|
||||
const content = slide.items || slide.content || []
|
||||
if (Array.isArray(content) && content.length) {
|
||||
const defBullets = content.map((item: string) => ({
|
||||
text: String(item),
|
||||
options: { bullet: { type: 'bullet' as const }, paraSpaceAfter: 6 },
|
||||
}))
|
||||
s.addText(defBullets, {
|
||||
x: 0.5, y: 1.5, w: W - 1, h: H - 2,
|
||||
fontSize: 17, color: p.text,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pptx.write({ outputType: 'nodebuffer' }) as Promise<Buffer>
|
||||
}
|
||||
|
||||
// ─── API Route ─────────────────────────────────────────────────────────────
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const canvasId = req.nextUrl.searchParams.get('id')
|
||||
if (!canvasId) {
|
||||
return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||||
}
|
||||
|
||||
const canvas = await prisma.canvas.findUnique({
|
||||
where: { id: canvasId, userId: session.user.id },
|
||||
})
|
||||
if (!canvas) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
let parsed: any
|
||||
try {
|
||||
parsed = JSON.parse(canvas.data)
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid canvas data' }, { status: 500 })
|
||||
}
|
||||
|
||||
if (parsed.type !== 'slides') {
|
||||
return NextResponse.json({ error: 'Not a slides canvas' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Support both new format (spec embedded) and legacy (spec at root)
|
||||
const spec = parsed.spec || parsed
|
||||
if (!spec || !Array.isArray(spec.slides)) {
|
||||
return NextResponse.json({ error: 'No slide data found — please regenerate the presentation' }, { status: 400 })
|
||||
}
|
||||
|
||||
const buffer = await buildPptx(spec)
|
||||
const filename = `${(canvas.name || 'presentation').replace(/[^a-zA-Z0-9-_ ]/g, '_').trim()}.pptx`
|
||||
|
||||
return new NextResponse(new Uint8Array(buffer), {
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Content-Length': String(buffer.length),
|
||||
},
|
||||
})
|
||||
} catch (err: any) {
|
||||
console.error('[PPTX Export]', err)
|
||||
return NextResponse.json({ error: err.message || 'Export failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -220,10 +220,11 @@ Momento is an intelligent note-taking application. Key features include:
|
||||
- **Lab**: Experimental AI tools for data analysis and deeper insights.
|
||||
|
||||
## Available tools
|
||||
You have access to: note_search, note_read, document_search, task_extract, web_search, web_scrape.
|
||||
You have access to: note_search, note_read, note_find_and_update, document_search, task_extract, web_search, web_scrape.
|
||||
Only use tools if you need more information. Never invent note IDs or URLs.
|
||||
- document_search: Searches attached PDF documents for the current note/notebook. Use when the user asks about documents or files.
|
||||
- task_extract: Extracts action items from notes and creates a synthesis note. Use when the user asks to extract tasks or TODOs.`,
|
||||
- task_extract: Extracts action items from notes and creates a synthesis note. Use when the user asks to extract tasks or TODOs.
|
||||
- note_find_and_update: Finds a note by search query and appends/prepends/replaces content. Use when the user says "find the note about X and add Y to it".`,
|
||||
},
|
||||
fr: {
|
||||
contextWithNotes: `## Notes et documents de l'utilisateur\n\n${contextNotes}\n\nQuand tu utilises une info venant des notes ci-dessus, cite le titre de la note source entre parenthèses, ex: "Le déploiement se fait via Docker (💻 Development Guide)". Pour les documents PDF, cite le nom du fichier et la page, ex: "Le chiffre d'affaires est de 5M$ (📄 rapport.pdf p.12)". Ne recopie pas mot pour mot — reformule.`,
|
||||
@@ -254,9 +255,10 @@ Only use tools if you need more information. Never invent note IDs or URLs.
|
||||
Momento est une application de prise de notes intelligente. Ses fonctionnalités : Éditeur Markdown riche, Copilot IA, Organisation par Carnets, Recherche sémantique, Agents IA, Lab.
|
||||
|
||||
## Outils disponibles
|
||||
Tu as accès à : note_search, note_read, document_search, task_extract, web_search, web_scrape.
|
||||
Tu as accès à : note_search, note_read, note_find_and_update, document_search, task_extract, web_search, web_scrape.
|
||||
- document_search : Recherche dans les documents PDF attachés à la note/au carnet.
|
||||
- task_extract : Extrait les tâches/action items des notes et crée une note de synthèse.`,
|
||||
- task_extract : Extrait les tâches/action items des notes et crée une note de synthèse.
|
||||
- note_find_and_update : Trouve une note par recherche textuelle et ajoute/prépose/remplace du contenu. Utilise quand l'utilisateur dit "trouve la note sur X et ajoute-y Y".`,
|
||||
},
|
||||
fa: {
|
||||
contextWithNotes: `## یادداشتهای کاربر\n\n${contextNotes}\n\nهنگام استفاده از اطلاعات یادداشتهای بالا، عنوان یادداشت منبع را در پرانتز ذکر کنید.`,
|
||||
|
||||
138
memento-note/app/api/graph/route.ts
Normal file
138
memento-note/app/api/graph/route.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
// ── Stopwords FR + EN ─────────────────────────────────────────────────────────
|
||||
const STOPWORDS = new Set([
|
||||
'le','la','les','de','du','des','un','une','et','en','au','aux','ce','se',
|
||||
'sa','son','ses','mon','ma','mes','ton','ta','tes','que','qui','quoi','dont',
|
||||
'il','elle','ils','elles','nous','vous','je','tu','on','par','pour','sur',
|
||||
'sous','avec','dans','est','sont','pas','ne','plus','très','tout','comme',
|
||||
'mais','donc','car','cet','cette','ces','leur','leurs','note','notes',
|
||||
'the','a','an','and','or','but','in','on','at','to','for','of','with','by',
|
||||
'from','is','are','was','were','be','been','have','has','had','do','does',
|
||||
'did','will','would','could','should','may','might','this','that','these',
|
||||
'those','it','its','they','them','their','he','she','we','you','not','no',
|
||||
'so','if','as','up','out','about','also','just','can','all','any','get',
|
||||
])
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]+>/g, ' ').replace(/&\w+;/g, ' ')
|
||||
}
|
||||
|
||||
function extractKeywords(text: string): Set<string> {
|
||||
return new Set(
|
||||
stripHtml(text)
|
||||
.toLowerCase()
|
||||
.split(/[\s\p{P}]+/u)
|
||||
.filter(w => w.length >= 3 && !STOPWORDS.has(w) && !/^\d+$/.test(w))
|
||||
)
|
||||
}
|
||||
|
||||
function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
|
||||
if (a.size === 0 || b.size === 0) return 0
|
||||
let intersection = 0
|
||||
for (const w of a) if (b.has(w)) intersection++
|
||||
return intersection / (a.size + b.size - intersection)
|
||||
}
|
||||
|
||||
type EdgeType = 'title_mention' | 'shared_label' | 'jaccard'
|
||||
|
||||
interface GraphEdge { source: string; target: string; weight: number; type: EdgeType }
|
||||
|
||||
// GET /api/graph — connexions automatiques à 3 niveaux
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const userId = session.user.id
|
||||
const { searchParams } = new URL(request.url)
|
||||
const notebookId = searchParams.get('notebookId') || undefined
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { userId, trashedAt: null, ...(notebookId ? { notebookId } : {}) },
|
||||
select: {
|
||||
id: true, title: true, content: true, notebookId: true, createdAt: true,
|
||||
labelRelations: { select: { id: true } },
|
||||
notebook: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (notes.length === 0) return NextResponse.json({ nodes: [], edges: [] })
|
||||
|
||||
const ids = notes.map(n => n.id)
|
||||
|
||||
// Pré-calcul
|
||||
const keywordsMap = new Map<string, Set<string>>()
|
||||
const labelMap = new Map<string, Set<string>>()
|
||||
for (const note of notes) {
|
||||
keywordsMap.set(note.id, extractKeywords(`${note.title ?? ''} ${note.content}`))
|
||||
labelMap.set(note.id, new Set(note.labelRelations.map((l: any) => l.id)))
|
||||
}
|
||||
|
||||
const edgeMap = new Map<string, GraphEdge>()
|
||||
function upsertEdge(a: string, b: string, weight: number, type: EdgeType) {
|
||||
const key = a < b ? `${a}--${b}` : `${b}--${a}`
|
||||
const ex = edgeMap.get(key)
|
||||
if (!ex || ex.weight < weight) edgeMap.set(key, { source: a < b ? a : b, target: a < b ? b : a, weight, type })
|
||||
}
|
||||
|
||||
// ── Niveau 1 : Title Mention (comme Obsidian "unlinked mentions") ──────────
|
||||
for (const noteA of notes) {
|
||||
const title = (noteA.title ?? '').trim().toLowerCase()
|
||||
if (title.length < 3) continue
|
||||
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const re = new RegExp(`\\b${escaped}\\b`, 'i')
|
||||
for (const noteB of notes) {
|
||||
if (noteA.id === noteB.id) continue
|
||||
if (re.test(stripHtml(noteB.content))) upsertEdge(noteA.id, noteB.id, 1.0, 'title_mention')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Niveau 2 : Labels partagés ────────────────────────────────────────────
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
for (let j = i + 1; j < ids.length; j++) {
|
||||
const la = labelMap.get(ids[i])!
|
||||
const lb = labelMap.get(ids[j])!
|
||||
const shared = [...la].filter(l => lb.has(l)).length
|
||||
if (shared > 0) upsertEdge(ids[i], ids[j], Math.min(0.5 + shared * 0.15, 0.9), 'shared_label')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Niveau 3 : Jaccard (désactivé > 500 notes) ───────────────────────────
|
||||
if (notes.length <= 500) {
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const kwI = keywordsMap.get(ids[i])!
|
||||
const candidates: { j: number; score: number }[] = []
|
||||
for (let j = i + 1; j < ids.length; j++) {
|
||||
const score = jaccardSimilarity(kwI, keywordsMap.get(ids[j])!)
|
||||
if (score >= 0.12) candidates.push({ j, score })
|
||||
}
|
||||
candidates.sort((a, b) => b.score - a.score).slice(0, 10)
|
||||
.forEach(({ j, score }) => upsertEdge(ids[i], ids[j], score * 0.8, 'jaccard'))
|
||||
}
|
||||
}
|
||||
|
||||
const degreeMap = new Map<string, number>()
|
||||
for (const e of edgeMap.values()) {
|
||||
degreeMap.set(e.source, (degreeMap.get(e.source) ?? 0) + 1)
|
||||
degreeMap.set(e.target, (degreeMap.get(e.target) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const nodes = notes.map(n => ({
|
||||
id: n.id,
|
||||
title: n.title || 'Sans titre',
|
||||
notebookId: n.notebookId,
|
||||
createdAt: n.createdAt,
|
||||
degree: degreeMap.get(n.id) ?? 0,
|
||||
}))
|
||||
|
||||
// Build clusters (notebooks)
|
||||
const notebookMap = new Map<string, string>()
|
||||
for (const n of notes) {
|
||||
if (n.notebook) notebookMap.set(n.notebook.id, n.notebook.name)
|
||||
}
|
||||
const clusters = [...notebookMap.entries()].map(([id, name]) => ({ id, name }))
|
||||
|
||||
return NextResponse.json({ nodes, edges: Array.from(edgeMap.values()), clusters })
|
||||
}
|
||||
84
memento-note/app/api/graph/sync-all/route.ts
Normal file
84
memento-note/app/api/graph/sync-all/route.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
const WIKILINK_RE = /\[\[([^\]|#]+?)(?:[|#][^\]]+)?\]\]/g
|
||||
|
||||
function extractWikilinks(content: string): { title: string; snippet: string }[] {
|
||||
const plain = content.replace(/<[^>]+>/g, ' ')
|
||||
const results: { title: string; snippet: string }[] = []
|
||||
const seen = new Set<string>()
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
WIKILINK_RE.lastIndex = 0
|
||||
while ((match = WIKILINK_RE.exec(plain)) !== null) {
|
||||
const title = match[1].trim()
|
||||
if (!title || seen.has(title.toLowerCase())) continue
|
||||
seen.add(title.toLowerCase())
|
||||
const start = Math.max(0, match.index - 50)
|
||||
const end = Math.min(plain.length, match.index + match[0].length + 50)
|
||||
const snippet = plain.slice(start, end).replace(/\s+/g, ' ').trim()
|
||||
results.push({ title, snippet })
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/graph/sync-all
|
||||
* Batch-sync [[wikilinks]] for ALL notes of the authenticated user.
|
||||
* Call once to populate the NoteLink table from existing notes.
|
||||
*/
|
||||
export async function POST() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = session.user.id
|
||||
|
||||
// Get all non-trashed notes with content
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { userId, trashedAt: null, content: { not: '' } },
|
||||
select: { id: true, content: true, notebookId: true },
|
||||
})
|
||||
|
||||
let totalLinks = 0
|
||||
|
||||
for (const note of notes) {
|
||||
if (!note.content.includes('[[')) continue
|
||||
|
||||
const wikilinks = extractWikilinks(note.content)
|
||||
if (wikilinks.length === 0) continue
|
||||
|
||||
for (const { title, snippet } of wikilinks) {
|
||||
let targetNote = await prisma.note.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
title: { equals: title, mode: 'insensitive' },
|
||||
trashedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!targetNote) {
|
||||
// Skip stubs in batch sync — we only link existing notes
|
||||
continue
|
||||
}
|
||||
|
||||
if (targetNote.id === note.id) continue
|
||||
|
||||
try {
|
||||
await (prisma as any).noteLink.upsert({
|
||||
where: { sourceNoteId_targetNoteId: { sourceNoteId: note.id, targetNoteId: targetNote.id } },
|
||||
update: { contextSnippet: snippet },
|
||||
create: { sourceNoteId: note.id, targetNoteId: targetNote.id, contextSnippet: snippet },
|
||||
})
|
||||
totalLinks++
|
||||
} catch {
|
||||
// ignore duplicate constraint errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ synced: notes.length, links: totalLinks })
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { CreateLabelSchema } from '@/lib/validators'
|
||||
|
||||
const COLORS = ['red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray'];
|
||||
|
||||
@@ -12,11 +14,10 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const notebookId = searchParams.get('notebookId')
|
||||
const notebookId = request.nextUrl.searchParams.get('notebookId')
|
||||
|
||||
// Build where clause
|
||||
const where: any = {}
|
||||
// userId is always required — prevents reading another user's labels by notebookId
|
||||
const where: Prisma.LabelWhereInput = { userId: session.user.id }
|
||||
|
||||
if (notebookId === 'null' || notebookId === '') {
|
||||
// Get labels without a notebook (backward compatibility)
|
||||
@@ -24,9 +25,6 @@ export async function GET(request: NextRequest) {
|
||||
} else if (notebookId) {
|
||||
// Get labels for a specific notebook
|
||||
where.notebookId = notebookId
|
||||
} else {
|
||||
// Get all labels for the user
|
||||
where.userId = session.user.id
|
||||
}
|
||||
|
||||
const labels = await prisma.label.findMany({
|
||||
@@ -61,21 +59,14 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { name, color, notebookId } = body
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
const parsed = CreateLabelSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Label name is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!notebookId || typeof notebookId !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'notebookId is required' },
|
||||
{ success: false, error: 'Invalid request body', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { name, color, notebookId } = parsed.data
|
||||
|
||||
// Verify notebook ownership
|
||||
const notebook = await prisma.notebook.findUnique({
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { PatchNotebookSchema } from '@/lib/validators'
|
||||
|
||||
async function getDescendantIds(notebookId: string): Promise<string[]> {
|
||||
const ids: string[] = []
|
||||
@@ -28,7 +30,14 @@ export async function PATCH(
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
const { name, icon, color, order, trashedAt, parentId } = body
|
||||
const parsed = PatchNotebookSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid request body', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { name, icon, color, order, trashedAt, parentId } = parsed.data
|
||||
|
||||
const existing = await prisma.notebook.findUnique({
|
||||
where: { id },
|
||||
@@ -76,12 +85,12 @@ export async function PATCH(
|
||||
}
|
||||
}
|
||||
|
||||
const updateData: any = {}
|
||||
if (name !== undefined) updateData.name = name.trim()
|
||||
const updateData: Prisma.NotebookUpdateInput = {}
|
||||
if (name !== undefined) updateData.name = name
|
||||
if (icon !== undefined) updateData.icon = icon
|
||||
if (color !== undefined) updateData.color = color
|
||||
if (order !== undefined) updateData.order = order
|
||||
if (trashedAt !== undefined) updateData.trashedAt = trashedAt
|
||||
if (trashedAt !== undefined) updateData.trashedAt = trashedAt ? new Date(trashedAt) : null
|
||||
if (parentId !== undefined) updateData.parentId = parentId
|
||||
|
||||
if (trashedAt !== undefined) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { CreateNotebookSchema } from '@/lib/validators'
|
||||
|
||||
const DEFAULT_COLORS = ['#3B82F6', '#8B5CF6', '#EC4899', '#F59E0B', '#10B981', '#06B6D4']
|
||||
const DEFAULT_ICONS = ['📁', '📚', '💼', '🎯', '📊', '🎨', '💡', '🔧']
|
||||
@@ -63,11 +65,14 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { name, icon, color, parentId } = body
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
return NextResponse.json({ success: false, error: 'Notebook name is required' }, { status: 400 })
|
||||
const parsed = CreateNotebookSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid request body', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { name, icon, color, parentId } = parsed.data
|
||||
|
||||
if (parentId) {
|
||||
const parent = await prisma.notebook.findFirst({
|
||||
@@ -78,9 +83,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
const whereClause: any = { userId: session.user.id }
|
||||
if (parentId) whereClause.parentId = parentId
|
||||
else whereClause.parentId = null
|
||||
const whereClause: Prisma.NotebookWhereInput = { userId: session.user.id, parentId: parentId ?? null }
|
||||
|
||||
const highestOrder = await prisma.notebook.findFirst({
|
||||
where: whereClause,
|
||||
|
||||
42
memento-note/app/api/notes/[id]/backlinks/route.ts
Normal file
42
memento-note/app/api/notes/[id]/backlinks/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
// GET /api/notes/[id]/backlinks
|
||||
// Returns all notes that link TO this note via [[wikilinks]]
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
// Verify the note belongs to the user
|
||||
const note = await prisma.note.findUnique({ where: { id }, select: { userId: true } })
|
||||
if (!note || note.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const backlinks = await (prisma as any).noteLink.findMany({
|
||||
where: { targetNoteId: id },
|
||||
include: {
|
||||
sourceNote: {
|
||||
select: { id: true, title: true, updatedAt: true, notebookId: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
backlinks: backlinks.map((bl: any) => ({
|
||||
id: bl.id,
|
||||
sourceNote: bl.sourceNote,
|
||||
contextSnippet: bl.contextSnippet,
|
||||
createdAt: bl.createdAt,
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -167,6 +167,11 @@ export async function PUT(
|
||||
console.error('[HISTORY] Failed to create snapshot from /api/notes/[id] PUT:', snapshotError)
|
||||
}
|
||||
|
||||
// Fire-and-forget: sync [[wikilinks]] in background after content change
|
||||
if ('content' in updateData) {
|
||||
syncNoteLinksBackground(id, session.user.id, note.content).catch(() => {})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: parseNote(note)
|
||||
@@ -180,6 +185,56 @@ export async function PUT(
|
||||
}
|
||||
}
|
||||
|
||||
/** Background job: parse [[wikilinks]] and sync NoteLink table */
|
||||
async function syncNoteLinksBackground(noteId: string, userId: string, content: string) {
|
||||
const WIKILINK_RE = /\[\[([^\]|#]+?)(?:[|#][^\]]+)?\]\]/g
|
||||
const plain = content.replace(/<[^>]+>/g, ' ')
|
||||
const wikilinks: { title: string; snippet: string }[] = []
|
||||
const seen = new Set<string>()
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
WIKILINK_RE.lastIndex = 0
|
||||
while ((match = WIKILINK_RE.exec(plain)) !== null) {
|
||||
const title = match[1].trim()
|
||||
if (!title || seen.has(title.toLowerCase())) continue
|
||||
seen.add(title.toLowerCase())
|
||||
const start = Math.max(0, match.index - 50)
|
||||
const end = Math.min(plain.length, match.index + match[0].length + 50)
|
||||
const snippet = plain.slice(start, end).replace(/\s+/g, ' ').trim()
|
||||
wikilinks.push({ title, snippet })
|
||||
}
|
||||
|
||||
const upsertedIds: string[] = []
|
||||
|
||||
for (const { title, snippet } of wikilinks) {
|
||||
let targetNote = await prisma.note.findFirst({
|
||||
where: { userId, title: { equals: title, mode: 'insensitive' }, trashedAt: null },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!targetNote) {
|
||||
targetNote = await prisma.note.create({
|
||||
data: { title, content: '', userId, type: 'richtext', color: 'default', isMarkdown: true, order: 0 },
|
||||
select: { id: true },
|
||||
})
|
||||
}
|
||||
if (targetNote.id === noteId) continue
|
||||
await (prisma as any).noteLink.upsert({
|
||||
where: { sourceNoteId_targetNoteId: { sourceNoteId: noteId, targetNoteId: targetNote.id } },
|
||||
update: { contextSnippet: snippet.slice(0, 200) },
|
||||
create: { id: crypto.randomUUID(), sourceNoteId: noteId, targetNoteId: targetNote.id, contextSnippet: snippet.slice(0, 200) },
|
||||
})
|
||||
upsertedIds.push(targetNote.id)
|
||||
}
|
||||
|
||||
if (upsertedIds.length > 0) {
|
||||
await (prisma as any).noteLink.deleteMany({
|
||||
where: { sourceNoteId: noteId, targetNoteId: { notIn: upsertedIds } },
|
||||
})
|
||||
} else {
|
||||
await (prisma as any).noteLink.deleteMany({ where: { sourceNoteId: noteId } })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/notes/[id] - Delete a note
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
|
||||
130
memento-note/app/api/notes/[id]/sync-links/route.ts
Normal file
130
memento-note/app/api/notes/[id]/sync-links/route.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
const WIKILINK_RE = /\[\[([^\]|#]+?)(?:[|#][^\]]+)?\]\]/g
|
||||
|
||||
/**
|
||||
* Extract [[wikilink]] targets from markdown/html content.
|
||||
* Returns deduplicated list of linked note titles.
|
||||
*/
|
||||
function extractWikilinks(content: string): { title: string; snippet: string }[] {
|
||||
// Strip HTML tags
|
||||
const plain = content.replace(/<[^>]+>/g, ' ')
|
||||
const results: { title: string; snippet: string }[] = []
|
||||
const seen = new Set<string>()
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
WIKILINK_RE.lastIndex = 0
|
||||
while ((match = WIKILINK_RE.exec(plain)) !== null) {
|
||||
const title = match[1].trim()
|
||||
if (!title || seen.has(title.toLowerCase())) continue
|
||||
seen.add(title.toLowerCase())
|
||||
|
||||
// Extract snippet: 50 chars before + after the match
|
||||
const start = Math.max(0, match.index - 50)
|
||||
const end = Math.min(plain.length, match.index + match[0].length + 50)
|
||||
const snippet = plain.slice(start, end).replace(/\s+/g, ' ').trim()
|
||||
|
||||
results.push({ title, snippet })
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// POST /api/notes/[id]/sync-links
|
||||
// Parse [[wikilinks]] in note content and sync the NoteLink table.
|
||||
// Called automatically after note save.
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const userId = session.user.id
|
||||
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true, content: true },
|
||||
})
|
||||
|
||||
if (!note || note.userId !== userId) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const wikilinks = extractWikilinks(note.content)
|
||||
|
||||
// For each wikilink, find or create the target note
|
||||
const upsertedLinks: string[] = []
|
||||
|
||||
for (const { title, snippet } of wikilinks) {
|
||||
// Find target note by title (case-insensitive) belonging to same user
|
||||
let targetNote = await prisma.note.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
title: { equals: title, mode: 'insensitive' },
|
||||
trashedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!targetNote) {
|
||||
// Create a stub note so the link resolves
|
||||
targetNote = await prisma.note.create({
|
||||
data: {
|
||||
title,
|
||||
content: '',
|
||||
userId,
|
||||
type: 'richtext',
|
||||
color: 'default',
|
||||
isMarkdown: true,
|
||||
order: 0,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
}
|
||||
|
||||
// Skip self-links
|
||||
if (targetNote.id === id) continue
|
||||
|
||||
// Upsert the NoteLink
|
||||
await (prisma as any).noteLink.upsert({
|
||||
where: {
|
||||
sourceNoteId_targetNoteId: {
|
||||
sourceNoteId: id,
|
||||
targetNoteId: targetNote.id,
|
||||
},
|
||||
},
|
||||
update: { contextSnippet: snippet.slice(0, 200) },
|
||||
create: {
|
||||
id: crypto.randomUUID(),
|
||||
sourceNoteId: id,
|
||||
targetNoteId: targetNote.id,
|
||||
contextSnippet: snippet.slice(0, 200),
|
||||
},
|
||||
})
|
||||
|
||||
upsertedLinks.push(targetNote.id)
|
||||
}
|
||||
|
||||
// Delete obsolete links (links that existed before but wikilink was removed)
|
||||
if (upsertedLinks.length > 0) {
|
||||
await (prisma as any).noteLink.deleteMany({
|
||||
where: {
|
||||
sourceNoteId: id,
|
||||
targetNoteId: { notIn: upsertedLinks },
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// No wikilinks at all — remove all outgoing links from this note
|
||||
await (prisma as any).noteLink.deleteMany({
|
||||
where: { sourceNoteId: id },
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ synced: upsertedLinks.length })
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { parseNote } from '@/lib/utils'
|
||||
import { CreateNoteSchema, UpdateNoteSchema, GetNotesQuerySchema } from '@/lib/validators'
|
||||
|
||||
// GET /api/notes - Get all notes
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -14,13 +16,23 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const includeArchived = searchParams.get('archived') === 'true'
|
||||
const search = searchParams.get('search')
|
||||
const notebookId = searchParams.get('notebookId')
|
||||
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : undefined
|
||||
const rawQuery = {
|
||||
archived: request.nextUrl.searchParams.get('archived') ?? undefined,
|
||||
search: request.nextUrl.searchParams.get('search') ?? undefined,
|
||||
notebookId: request.nextUrl.searchParams.get('notebookId') ?? undefined,
|
||||
limit: request.nextUrl.searchParams.get('limit') ?? undefined,
|
||||
}
|
||||
const queryResult = GetNotesQuerySchema.safeParse(rawQuery)
|
||||
if (!queryResult.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid query parameters', details: queryResult.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { archived, search, notebookId, limit } = queryResult.data
|
||||
const includeArchived = archived === 'true'
|
||||
|
||||
const where: any = {
|
||||
const where: Prisma.NoteWhereInput = {
|
||||
userId: session.user.id,
|
||||
trashedAt: null
|
||||
}
|
||||
@@ -75,25 +87,25 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { title, content, color, type, checkItems, labels, images } = body
|
||||
|
||||
if (!content && type !== 'checklist') {
|
||||
const parsed = CreateNoteSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Content is required' },
|
||||
{ success: false, error: 'Invalid request body', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { title, content, color, type, checkItems, labels, images } = parsed.data
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
title: title || null,
|
||||
content: content || '',
|
||||
color: color || 'default',
|
||||
type: type || 'text',
|
||||
checkItems: checkItems ?? null,
|
||||
labels: labels ?? null,
|
||||
images: images ?? null,
|
||||
title: title ?? null,
|
||||
content: content ?? '',
|
||||
color: color ?? 'default',
|
||||
type: type ?? 'text',
|
||||
checkItems: checkItems != null ? JSON.stringify(checkItems) : null,
|
||||
labels: labels != null ? JSON.stringify(labels) : null,
|
||||
images: images != null ? JSON.stringify(images) : null,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -122,34 +134,16 @@ export async function PUT(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { id, title, content, color, type, checkItems, labels, isPinned, isArchived, images } = body
|
||||
|
||||
if (!id) {
|
||||
const parsed = UpdateNoteSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note ID is required' },
|
||||
{ success: false, error: 'Invalid request body', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { id, title, content, color, type, checkItems, labels, isPinned, isArchived, images } = parsed.data
|
||||
|
||||
const existingNote = await prisma.note.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!existingNote) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (existingNote.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Forbidden' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const updateData: any = {}
|
||||
const updateData: Prisma.NoteUpdateInput = {}
|
||||
|
||||
if (title !== undefined) updateData.title = title
|
||||
if (content !== undefined) updateData.content = content
|
||||
@@ -161,10 +155,21 @@ export async function PUT(request: NextRequest) {
|
||||
if (isArchived !== undefined) updateData.isArchived = isArchived
|
||||
if (images !== undefined) updateData.images = images ?? null
|
||||
|
||||
const note = await prisma.note.update({
|
||||
where: { id },
|
||||
data: updateData
|
||||
})
|
||||
let note
|
||||
try {
|
||||
note = await prisma.note.update({
|
||||
where: { id, userId: session.user.id },
|
||||
data: updateData
|
||||
})
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note not found or access denied' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@@ -200,29 +205,21 @@ export async function DELETE(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const existingNote = await prisma.note.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!existingNote) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
try {
|
||||
await prisma.note.update({
|
||||
where: { id, userId: session.user.id },
|
||||
data: { trashedAt: new Date() }
|
||||
})
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note not found or access denied' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
if (existingNote.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Forbidden' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.note.update({
|
||||
where: { id },
|
||||
data: { trashedAt: new Date() }
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Note moved to trash'
|
||||
|
||||
Reference in New Issue
Block a user