diff --git a/memento-note/app/(admin)/admin/page.tsx b/memento-note/app/(admin)/admin/page.tsx index 6f7558a..5819e95 100644 --- a/memento-note/app/(admin)/admin/page.tsx +++ b/memento-note/app/(admin)/admin/page.tsx @@ -1,58 +1,184 @@ import { getUsers } from '@/app/actions/admin' import { AdminMetrics } from '@/components/admin-metrics' -import { Users, Activity, Database, Zap } from 'lucide-react' +import { Users, Activity, Database, Zap, CreditCard, Bot } from 'lucide-react' +import prisma from '@/lib/prisma' +import Link from 'next/link' + +async function getAdminDashboardStats() { + const now = new Date() + const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000) + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1) + + const [ + userCount, + noteCount, + notebookCount, + agentsEnabled, + agentRunsToday, + paidSubs, + recentUsers, + ] = await Promise.all([ + prisma.user.count().catch(() => 0), + prisma.note.count({ where: { trashedAt: null } }).catch(() => 0), + prisma.notebook.count().catch(() => 0), + prisma.agent.count({ where: { isEnabled: true } }).catch(() => 0), + prisma.agentAction.count({ where: { createdAt: { gte: dayAgo } } }).catch(() => 0), + prisma.subscription.count({ + where: { tier: { in: ['PRO', 'BUSINESS', 'ENTERPRISE'] }, status: 'ACTIVE' }, + }).catch(() => 0), + prisma.user.findMany({ + orderBy: { createdAt: 'desc' }, + take: 8, + select: { id: true, name: true, email: true, createdAt: true, role: true }, + }).catch(() => []), + ]) + + // AI usage this month + let aiRequestsMonth = 0 + try { + aiRequestsMonth = await prisma.usageLog.aggregate({ + where: { periodStart: { gte: monthStart } }, + _sum: { requestsCount: true }, + }).then((r) => r._sum.requestsCount ?? 0) + } catch { + try { + aiRequestsMonth = await prisma.agentAction.count({ + where: { createdAt: { gte: monthStart } }, + }) + } catch { + aiRequestsMonth = 0 + } + } + + return { + userCount, + noteCount, + notebookCount, + agentsEnabled, + agentRunsToday, + paidSubs, + aiRequestsMonth, + recentUsers, + } +} export default async function AdminPage() { - const users = await getUsers() + const [users, stats] = await Promise.all([getUsers(), getAdminDashboardStats()]) - // Mock metrics data - in a real app, these would come from analytics const metrics = [ { - title: 'Total Users', - value: users.length, - trend: { value: 12, isPositive: true }, + title: 'Utilisateurs', + value: stats.userCount, + trend: undefined as { value: number; isPositive: boolean } | undefined, icon: , }, { - title: 'Active Sessions', - value: '24', - trend: { value: 8, isPositive: true }, - icon: , - }, - { - title: 'Total Notes', - value: '1,234', - trend: { value: 24, isPositive: true }, + title: 'Notes', + value: stats.noteCount.toLocaleString('fr-FR'), icon: , }, { - title: 'AI Requests', - value: '856', - trend: { value: 5, isPositive: false }, + title: 'Abonnements payants', + value: stats.paidSubs, + icon: , + }, + { + title: 'Agents actifs', + value: stats.agentsEnabled, + icon: , + }, + { + title: 'Exécutions agents (24h)', + value: stats.agentRunsToday, + icon: , + }, + { + title: 'Usage IA (mois)', + value: stats.aiRequestsMonth.toLocaleString('fr-FR'), icon: , }, ] return (
-
-

- Dashboard -

-

- Overview of your application metrics -

+
+
+

+ Administration +

+

+ Vue d'ensemble en temps réel — {stats.notebookCount} carnets · {users.length} comptes listés +

+
+
+ + Facturation & quotas + + + Config IA + + + Utilisateurs + + + Paramètres + +
-
-

- Recent Activity -

-

- Recent activity will be displayed here. -

+
+
+

+ Derniers utilisateurs +

+ {stats.recentUsers.length === 0 ? ( +

Aucun utilisateur.

+ ) : ( +
    + {stats.recentUsers.map((u) => ( +
  • +
    +

    {u.name || u.email}

    +

    {u.email}

    +
    +
    + {u.role} +

    + {u.createdAt ? new Date(u.createdAt).toLocaleDateString('fr-FR') : '—'} +

    +
    +
  • + ))} +
+ )} +
+ +
+

+ Raccourcis ops +

+
    +
  • · Configurer les Price IDs Stripe dans Facturation
  • +
  • · Vérifier les quotas IA par feature (BASIC / PRO / BUSINESS)
  • +
  • · Tester les providers dans IA
  • +
  • · CRON_SECRET requis pour agents planifiés (entrypoint Docker)
  • +
  • · Notes indexées via embeddings + fragments (y compris notes courtes)
  • +
+
) diff --git a/memento-note/app/(main)/settings/layout.tsx b/memento-note/app/(main)/settings/layout.tsx index 5ec76e2..dc0e447 100644 --- a/memento-note/app/(main)/settings/layout.tsx +++ b/memento-note/app/(main)/settings/layout.tsx @@ -11,21 +11,21 @@ export default function SettingsLayout({ }) { const { t } = useLanguage() return ( -
+
-

+

{t('settings.title')}

-

+

{t('settings.description')}

diff --git a/memento-note/app/actions/agent-actions.ts b/memento-note/app/actions/agent-actions.ts index ada823c..7faff0e 100644 --- a/memento-note/app/actions/agent-actions.ts +++ b/memento-note/app/actions/agent-actions.ts @@ -240,10 +240,10 @@ export async function runAgent(id: string) { return { success: false, agentId: id, error: 'Agent introuvable' } } - // Fire and forget — return immediately so the UI doesn't block - import('@/lib/ai/services/agent-executor.service') - .then(({ executeAgent }) => executeAgent(id, userId)) - .then(() => { /* revalidation is handled client-side via polling */ }) + // Load module first so import failures surface immediately (not silently after response) + const { executeAgent } = await import('@/lib/ai/services/agent-executor.service') + + void executeAgent(id, userId) .catch(err => console.error('[runAgent] Background error:', err)) return { success: true, agentId: id, status: 'running' } @@ -319,9 +319,35 @@ export async function toggleAgent(id: string, isEnabled: boolean) { } try { + const existing = await prisma.agent.findFirst({ + where: { id, userId: session.user.id }, + select: { + id: true, + frequency: true, + scheduledTime: true, + scheduledDay: true, + timezone: true, + nextRun: true, + }, + }) + if (!existing) throw new Error('Agent introuvable') + + const data: { isEnabled: boolean; nextRun?: Date | null } = { isEnabled } + + // When re-enabling a scheduled agent, ensure nextRun is set (was often null → never cron'd) + if (isEnabled && existing.frequency !== 'manual' && existing.frequency !== 'one-shot') { + const nextRun = calculateNextRun({ + frequency: existing.frequency, + scheduledTime: existing.scheduledTime, + scheduledDay: existing.scheduledDay, + timezone: existing.timezone, + }) + data.nextRun = nextRun + } + const agent = await prisma.agent.update({ where: { id, userId: session.user.id }, - data: { isEnabled } + data, }) return { success: true, agent } } catch (error) { diff --git a/memento-note/app/api/ai/notebook-wizard/route.ts b/memento-note/app/api/ai/notebook-wizard/route.ts index 5022ed3..d62f670 100644 --- a/memento-note/app/api/ai/notebook-wizard/route.ts +++ b/memento-note/app/api/ai/notebook-wizard/route.ts @@ -39,10 +39,18 @@ export async function POST(request: NextRequest) { { level, count, language: language || 'fr' } ) + // Prefer AI short title over raw user prompt (notebookName from client is often the full message) + const resolvedName = ( + result.notebookName?.trim() + || (notebookName && notebookName.trim().length <= 60 ? notebookName.trim() : '') + || result.notes[0]?.title?.trim() + || topic.trim().slice(0, 60) + ).slice(0, 80) + // 1. Create notebook const notebook = await prisma.notebook.create({ data: { - name: notebookName || topic, + name: resolvedName, icon: notebookIcon || '📚', userId: session.user.id, order: 0, diff --git a/memento-note/app/api/billing/create-checkout/route.ts b/memento-note/app/api/billing/create-checkout/route.ts index d4a6804..7062b59 100644 --- a/memento-note/app/api/billing/create-checkout/route.ts +++ b/memento-note/app/api/billing/create-checkout/route.ts @@ -1,13 +1,15 @@ import { NextRequest, NextResponse } from 'next/server'; import { auth } from '@/auth'; import { stripe } from '@/lib/stripe'; -import { resolvePriceId } from '@/lib/billing/stripe-prices'; +import { isBillingEnabled, resolvePriceId } from '@/lib/billing/stripe-prices'; import { prisma } from '@/lib/prisma'; import { z } from 'zod'; const bodySchema = z.object({ tier: z.enum(['PRO', 'BUSINESS']), interval: z.enum(['month', 'year']), + /** Prefer hosted redirect when embedded checkout is unavailable */ + mode: z.enum(['hosted', 'embedded']).optional(), }); export async function POST(req: NextRequest) { @@ -16,17 +18,46 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } + if (!(await isBillingEnabled())) { + return NextResponse.json({ error: 'Billing is not enabled' }, { status: 403 }); + } + + const secret = process.env.STRIPE_SECRET_KEY; + if (!secret || secret === 'sk_test_placeholder') { + return NextResponse.json( + { error: 'Stripe is not configured (STRIPE_SECRET_KEY missing)' }, + { status: 503 }, + ); + } + const parsed = bodySchema.safeParse(await req.json()); if (!parsed.success) { return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); } const { tier, interval } = parsed.data; + const preferredMode = parsed.data.mode ?? 'hosted'; const userId = session.user.id; const userEmail = session.user.email; try { - const priceId = await resolvePriceId(tier, interval); + let priceId: string; + try { + priceId = await resolvePriceId(tier, interval); + } catch (e) { + console.error('[billing/create-checkout] price resolve failed:', e); + return NextResponse.json( + { error: 'Stripe price IDs not configured. Set them in Admin > Billing.' }, + { status: 503 }, + ); + } + + if (priceId.startsWith('price_mock_')) { + return NextResponse.json( + { error: 'Stripe price IDs not configured. Set them in Admin > Billing.' }, + { status: 503 }, + ); + } const subscription = await prisma.subscription.findUnique({ where: { userId } }); let customerId = subscription?.stripeCustomerId ?? undefined; @@ -41,8 +72,7 @@ export async function POST(req: NextRequest) { metadata: { userId }, }); customerId = customer.id; - - // Update DB to save the real Stripe customer ID + await prisma.subscription.upsert({ where: { userId }, update: { stripeCustomerId: customerId }, @@ -52,8 +82,8 @@ export async function POST(req: NextRequest) { tier: 'BASIC', status: 'ACTIVE', currentPeriodStart: new Date(), - currentPeriodEnd: new Date(Date.now() + 30 * 24 * 3600 * 1000), // temp basic dates - } + currentPeriodEnd: new Date(Date.now() + 30 * 24 * 3600 * 1000), + }, }); } @@ -61,29 +91,51 @@ export async function POST(req: NextRequest) { const proto = req.headers.get('x-forwarded-proto') ?? 'http'; const origin = `${proto}://${host}`; - const sessionParams = { + // Hosted Checkout is the most reliable path (redirect). Embedded is optional. + if (preferredMode === 'embedded') { + try { + const embedded = await stripe.checkout.sessions.create({ + customer: customerId, + mode: 'subscription', + line_items: [{ price: priceId, quantity: 1 }], + ui_mode: 'embedded' as any, + return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`, + metadata: { userId, tier }, + subscription_data: { metadata: { userId, tier } }, + customer_update: { address: 'auto' }, + allow_promotion_codes: true, + } as any); + if (embedded.client_secret) { + return NextResponse.json({ + clientSecret: embedded.client_secret, + sessionId: embedded.id, + }); + } + } catch (embeddedErr) { + console.warn('[billing/create-checkout] embedded failed, falling back to hosted:', embeddedErr); + } + } + + const checkoutSession = await stripe.checkout.sessions.create({ customer: customerId, - mode: 'subscription' as const, + mode: 'subscription', line_items: [{ price: priceId, quantity: 1 }], - ui_mode: 'embedded_page', - return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`, + success_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${origin}/settings/billing?canceled=1`, metadata: { userId, tier }, subscription_data: { metadata: { userId, tier } }, customer_update: { address: 'auto' }, allow_promotion_codes: true, - }; - const checkoutSession = await stripe.checkout.sessions.create(sessionParams as any); + }); - if (checkoutSession.client_secret) { - return NextResponse.json({ - clientSecret: checkoutSession.client_secret, - sessionId: checkoutSession.id, - }); + if (!checkoutSession.url) { + return NextResponse.json({ error: 'Checkout session has no URL' }, { status: 500 }); } - return NextResponse.json({ url: checkoutSession.url }); + return NextResponse.json({ url: checkoutSession.url, sessionId: checkoutSession.id }); } catch (error) { console.error('[billing/create-checkout]', error); - return NextResponse.json({ error: 'Failed to create checkout session' }, { status: 500 }); + const msg = error instanceof Error ? error.message : 'Failed to create checkout session'; + return NextResponse.json({ error: msg }, { status: 500 }); } } diff --git a/memento-note/app/api/cron/agents/route.ts b/memento-note/app/api/cron/agents/route.ts index 4a31b98..17eeff7 100644 --- a/memento-note/app/api/cron/agents/route.ts +++ b/memento-note/app/api/cron/agents/route.ts @@ -27,10 +27,12 @@ export async function POST(request: NextRequest) { try { const now = new Date() + // Due = nextRun in the past; also backfill agents whose nextRun was never set + // (legacy / toggle bug) — but only schedule them, don't stampede-execute all nulls const dueAgents = await prisma.agent.findMany({ where: { isEnabled: true, - frequency: { not: 'manual' }, + frequency: { notIn: ['manual', 'one-shot'] }, nextRun: { lte: now }, }, select: { @@ -40,13 +42,44 @@ export async function POST(request: NextRequest) { scheduledTime: true, scheduledDay: true, timezone: true, + nextRun: true, }, + orderBy: { nextRun: 'asc' }, }) - if (dueAgents.length === 0) { - return NextResponse.json({ success: true, executed: 0 }) + // One-time repair: set nextRun for enabled scheduled agents stuck with null + const unscheduled = await prisma.agent.findMany({ + where: { + isEnabled: true, + frequency: { notIn: ['manual', 'one-shot'] }, + nextRun: null, + }, + select: { + id: true, + frequency: true, + scheduledTime: true, + scheduledDay: true, + timezone: true, + }, + take: 50, + }) + for (const a of unscheduled) { + const nextRun = calculateNextRun({ + frequency: a.frequency, + scheduledTime: a.scheduledTime, + scheduledDay: a.scheduledDay, + timezone: a.timezone, + }) + await prisma.agent.update({ where: { id: a.id }, data: { nextRun } }) } + if (dueAgents.length === 0) { + return NextResponse.json({ + success: true, + executed: 0, + repairedSchedules: unscheduled.length, + }) + } const results: { id: string; success: boolean; error?: string }[] = [] @@ -56,7 +89,6 @@ export async function POST(request: NextRequest) { const { executeAgent } = await import('@/lib/ai/services/agent-executor.service') const result = await executeAgent(agent.id, agent.userId) - // Calculate and set next run const nextRun = calculateNextRun({ frequency: agent.frequency, scheduledTime: agent.scheduledTime, @@ -64,7 +96,6 @@ export async function POST(request: NextRequest) { timezone: agent.timezone, }) - await prisma.agent.update({ where: { id: agent.id }, data: { nextRun }, @@ -75,7 +106,6 @@ export async function POST(request: NextRequest) { const msg = error instanceof Error ? error.message : 'Unknown error' console.error(`[CronAgents] Agent ${agent.id} failed:`, msg) - // Still schedule next run even on failure const nextRun = calculateNextRun({ frequency: agent.frequency, scheduledTime: agent.scheduledTime, diff --git a/memento-note/app/api/upload/route.ts b/memento-note/app/api/upload/route.ts index 1f9cbae..b48b78a 100644 --- a/memento-note/app/api/upload/route.ts +++ b/memento-note/app/api/upload/route.ts @@ -3,6 +3,7 @@ import { writeFile, mkdir } from 'fs/promises' import path from 'path' import { randomUUID } from 'crypto' import { auth } from '@/auth' +import { writeUploadMeta } from '@/lib/upload-access' const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] const MAX_SIZE = 5 * 1024 * 1024 // 5MB @@ -14,14 +15,12 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const userId = session.user.id const formData = await request.formData() const file = formData.get('file') as File if (!file) { - return NextResponse.json( - { error: 'No file uploaded' }, - { status: 400 } - ) + return NextResponse.json({ error: 'No file uploaded' }, { status: 400 }) } if (!ALLOWED_TYPES.includes(file.type)) { @@ -33,7 +32,6 @@ export async function POST(request: NextRequest) { } const buffer = Buffer.from(await file.arrayBuffer()) - // Resolve extension from file name, falling back to MIME type (e.g. clipboard pastes) let ext = path.extname(file.name).toLowerCase() if (!['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) { const mimeToExt: Record = { @@ -44,23 +42,28 @@ export async function POST(request: NextRequest) { } ext = mimeToExt[file.type] || '.png' } + const filename = `${randomUUID()}${ext}` - - // Ensure directory exists (data/uploads is outside public/, served via API route) - const uploadDir = path.join(process.cwd(), 'data/uploads/notes') + // Ownership in path: /uploads/notes/{userId}/{filename} + const relativeUnderNotes = `${userId}/${filename}` + const uploadDir = path.join(process.cwd(), 'data', 'uploads', 'notes', userId) await mkdir(uploadDir, { recursive: true }) - + const filePath = path.join(uploadDir, filename) await writeFile(filePath, buffer) + await writeUploadMeta(relativeUnderNotes, userId) - return NextResponse.json({ - success: true, - url: `/uploads/notes/${filename}` + const url = `/uploads/notes/${relativeUnderNotes}` + + return NextResponse.json({ + success: true, + url, }) } catch (error) { + console.error('[upload]', error) return NextResponse.json( { error: 'Failed to upload file' }, - { status: 500 } + { status: 500 }, ) } } diff --git a/memento-note/app/api/uploads/[...path]/route.ts b/memento-note/app/api/uploads/[...path]/route.ts index 27bce3f..5235351 100644 --- a/memento-note/app/api/uploads/[...path]/route.ts +++ b/memento-note/app/api/uploads/[...path]/route.ts @@ -21,16 +21,30 @@ export async function GET( const session = await auth() const { path: segments } = await params - // Only serve from uploads/notes/ subdirectory - if (segments[0] !== 'notes') { + // Only serve from uploads/notes/ (flat or userId/file) + if (!segments.length || segments[0] !== 'notes') { return new NextResponse('Not found', { status: 404 }) } + // Reject path traversal + if (segments.some((s) => s === '..' || s.includes('\0'))) { + return new NextResponse('Forbidden', { status: 403 }) + } + const filename = segments[segments.length - 1] - const allowed = await canAccessUploadedNoteImage(filename, session?.user?.id) + // Never serve meta sidecars as images + if (filename.endsWith('.meta.json')) { + return new NextResponse('Not found', { status: 404 }) + } + + const allowed = await canAccessUploadedNoteImage(segments, session?.user?.id) if (!allowed) { return new NextResponse(session?.user?.id ? 'Forbidden' : 'Unauthorized', { status: session?.user?.id ? 403 : 401, + headers: { + // Do not let browsers / CDNs cache a denial (would stick a broken image) + 'Cache-Control': 'no-store', + }, }) } @@ -40,9 +54,8 @@ export async function GET( return new NextResponse('Unsupported file type', { status: 400 }) } - // Prevent path traversal const safePath = path.join(UPLOAD_DIR, ...segments) - if (!safePath.startsWith(UPLOAD_DIR)) { + if (!safePath.startsWith(UPLOAD_DIR + path.sep) && safePath !== UPLOAD_DIR) { return new NextResponse('Forbidden', { status: 403 }) } @@ -57,7 +70,8 @@ export async function GET( return new NextResponse(buffer, { headers: { 'Content-Type': contentType, - 'Cache-Control': 'public, max-age=31536000, immutable', + // private: only for authenticated session fetches; still long cache after auth ok + 'Cache-Control': 'private, max-age=31536000, immutable', 'Content-Length': String(buffer.length), }, }) diff --git a/memento-note/app/globals.css b/memento-note/app/globals.css index 826483d..5f788a8 100644 --- a/memento-note/app/globals.css +++ b/memento-note/app/globals.css @@ -1623,7 +1623,11 @@ html.font-system * { height: auto; display: block; margin: 0.5em 0; - transition: filter 0.15s ease; + transition: filter 0.15s ease, outline 0.15s ease; + cursor: pointer; + /* Ensure clicks select the node (not swallowed by surrounding layout) */ + position: relative; + z-index: 1; } .notion-editor-wrapper .ProseMirror img:hover { @@ -1635,6 +1639,14 @@ html.font-system * { outline-offset: 2px; } +/* Image node wrapper when selected as block */ +.notion-editor-wrapper .ProseMirror .ProseMirror-selectednode img, +.notion-editor-wrapper .ProseMirror img.ProseMirror-selectednode { + outline: 2px solid var(--primary); + outline-offset: 2px; + box-shadow: 0 0 0 3px color-mix(in oklab, var(--primary) 25%, transparent); +} + /* --- Links --- */ .notion-editor-wrapper .ProseMirror a { color: var(--primary); diff --git a/memento-note/components/admin-metrics.tsx b/memento-note/components/admin-metrics.tsx index 46ed9e8..568ff86 100644 --- a/memento-note/components/admin-metrics.tsx +++ b/memento-note/components/admin-metrics.tsx @@ -25,14 +25,14 @@ export function AdminMetrics({ metrics, className }: AdminMetricsProps) { return (
{metrics.map((metric, index) => (
diff --git a/memento-note/components/contextual-ai-chat.tsx b/memento-note/components/contextual-ai-chat.tsx index 7fffdf2..85c9990 100644 --- a/memento-note/components/contextual-ai-chat.tsx +++ b/memento-note/components/contextual-ai-chat.tsx @@ -1064,13 +1064,13 @@ export function ContextualAIChat({
-
+
-
+
{t('ai.generate.slides')}

{t('ai.generate.sectionLabel')}

@@ -1170,13 +1170,13 @@ export function ContextualAIChat({
-
+
-
+
{t('ai.generate.diagram')}

{t('ai.generate.diagramReadyHint')}

@@ -1280,7 +1280,7 @@ export function ContextualAIChat({ { id: 'complete', label: t('ai.resource.modeComplete'), sub: t('ai.resource.modeCompleteDesc') }, { id: 'merge', label: t('ai.resource.modeMerge'), sub: t('ai.resource.modeMergeDesc') }, ].map((mode) => ( - diff --git a/memento-note/components/note-editor/note-content-area.tsx b/memento-note/components/note-editor/note-content-area.tsx index a867847..3aea5d3 100644 --- a/memento-note/components/note-editor/note-content-area.tsx +++ b/memento-note/components/note-editor/note-content-area.tsx @@ -112,6 +112,7 @@ export function NoteContentArea() { ref={richTextEditorRef} content={state.content} onChange={(v: string) => actions.setContent(v)} + onChangeImmediate={(v: string) => actions.setContentImmediate(v)} className="min-h-[280px]" onImageUpload={uploadImageFile} noteId={note.id} @@ -129,6 +130,7 @@ export function NoteContentArea() { ref={richTextEditorRef} content={state.content} onChange={actions.setContent} + onChangeImmediate={actions.setContentImmediate} className="min-h-[200px]" onImageUpload={uploadImageFile} noteId={note.id} diff --git a/memento-note/components/note-editor/note-cover-hero.tsx b/memento-note/components/note-editor/note-cover-hero.tsx new file mode 100644 index 0000000..d92c29c --- /dev/null +++ b/memento-note/components/note-editor/note-cover-hero.tsx @@ -0,0 +1,192 @@ +'use client' + +/** + * Couverture sous le titre (full-page). + * - Affichée uniquement si une image de couverture est **explicite** (pas auto depuis le corps). + * - Permet de retirer la couverture sans supprimer les images du contenu. + * - Permet de choisir parmi les images de la note (corps + galerie). + */ + +import { useState } from 'react' +import { ImageIcon, X, Check, Images } from 'lucide-react' +import { useLanguage } from '@/lib/i18n' +import { cn } from '@/lib/utils' + +interface NoteCoverHeroProps { + /** URL de couverture explicite (note.images[0]), null = pas de couverture */ + coverUrl: string | null + /** Candidats (images du contenu + galerie) pour le sélecteur */ + candidates: string[] + readOnly?: boolean + title?: string + onSetCover: (url: string) => void + onClearCover: () => void +} + +export function NoteCoverHero({ + coverUrl, + candidates, + readOnly, + title, + onSetCover, + onClearCover, +}: NoteCoverHeroProps) { + const { t } = useLanguage() + const [pickerOpen, setPickerOpen] = useState(false) + + const uniqueCandidates = Array.from(new Set(candidates.filter(Boolean))) + const hasCover = Boolean(coverUrl) + const canPick = !readOnly && uniqueCandidates.length > 0 + + // Rien à montrer : pas de cover et pas de candidats (ou lecture seule sans cover) + if (!hasCover && (readOnly || uniqueCandidates.length === 0)) { + return null + } + + // Pas de cover mais des images dans la note → barre discrète pour en choisir une + if (!hasCover && canPick) { + return ( +
+
+ + + {t('notes.coverNoneHint') || 'Aucune image de couverture — choisissez une image de la note ou laissez vide.'} + +
+ + {pickerOpen && ( + { + onSetCover(url) + setPickerOpen(false) + }} + onClose={() => setPickerOpen(false)} + t={t} + /> + )} +
+ ) + } + + if (!hasCover || !coverUrl) return null + + return ( +
+ {title + + {!readOnly && ( +
+ {uniqueCandidates.length > 1 && ( + + )} + +
+ )} + + {pickerOpen && !readOnly && ( +
+ { + onSetCover(url) + setPickerOpen(false) + }} + onClose={() => setPickerOpen(false)} + t={t} + dark + /> +
+ )} +
+ ) +} + +function CoverPicker({ + candidates, + selected, + onSelect, + onClose, + t, + dark, +}: { + candidates: string[] + selected: string | null + onSelect: (url: string) => void + onClose: () => void + t: (key: string) => string + dark?: boolean +}) { + return ( +
+
+

+ {t('notes.coverPickLabel') || 'Image de couverture'} +

+ +
+
+ {candidates.map((url) => { + const isSelected = selected === url + return ( + + ) + })} +
+
+ ) +} diff --git a/memento-note/components/note-editor/note-editor-context.tsx b/memento-note/components/note-editor/note-editor-context.tsx index 2ed30f5..8bf8323 100644 --- a/memento-note/components/note-editor/note-editor-context.tsx +++ b/memento-note/components/note-editor/note-editor-context.tsx @@ -332,10 +332,29 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o setLinks(links.filter((_, i) => i !== index)) } + /** Images extraites du corps (TipTap / markdown) — pas de couverture auto */ + const contentImages = useMemo(() => { + if (isMarkdown) { + const urls = new Set() + for (const match of content.matchAll(/!\[.*?\]\((.*?)\)/g)) { + const src = match[1]?.trim() + if (src) urls.add(src) + } + return Array.from(urls) + } + return extractImagesFromHTML(content) + }, [content, isMarkdown]) + + /** + * allImages = galerie explicite + corps (pour IA, sélecteur de couverture). + * La couverture hero n'utilise PAS allImages[0] automatiquement. + */ const allImages = useMemo(() => { - const extracted = !isMarkdown ? extractImagesFromHTML(content) : [] - return Array.from(new Set([...images, ...extracted])) - }, [images, content, isMarkdown]) + return Array.from(new Set([...images, ...contentImages])) + }, [images, contentImages]) + + /** Couverture explicite = première entrée de note.images (jamais auto depuis le corps) */ + const coverImage = images[0] ?? null const resolveContentForSave = useCallback((): string => { if (!isMarkdown) { @@ -345,27 +364,32 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o return contentRef.current }, [isMarkdown]) - const resolveImagesForSave = useCallback((contentToSave: string): string[] => { - // Images présentes dans le contenu de l'éditeur (inline dans le HTML ou Markdown) - let contentImages: string[] = [] - if (contentToSave) { - if (!isMarkdown) { - contentImages = extractImagesFromHTML(contentToSave) - } else { - const urls = new Set() - const matches = contentToSave.matchAll(/!\[.*?\]\((.*?)\)/g) - for (const match of matches) { - const src = match[1]?.trim() - if (src) urls.add(src) - } - contentImages = Array.from(urls) - } - } - // Images "standalone" uploadées séparément (hors contenu éditeur), non supprimées - const standaloneImages = images.filter(url => !removedImageUrls.includes(url) && !contentImages.includes(url)) - // Fusion dédupliquée : images du contenu + images standalone conservées - return Array.from(new Set([...contentImages, ...standaloneImages])) - }, [isMarkdown, images, removedImageUrls]) + /** + * note.images = couverture / galerie **explicite** uniquement. + * Ne plus fusionner les images du corps : sinon, coller une image dans l'éditeur + * la plaçait sous le titre, et la supprimer du corps la laissait en "standalone" forever. + */ + const resolveImagesForSave = useCallback((_contentToSave: string): string[] => { + return images.filter((url) => !removedImageUrls.includes(url)) + }, [images, removedImageUrls]) + + const setCoverImage = useCallback((url: string) => { + setImages((prev) => { + const rest = prev.filter((u) => u !== url) + return [url, ...rest] + }) + setRemovedImageUrls((prev) => prev.filter((u) => u !== url)) + setIsDirty(true) + }, []) + + const clearCoverImage = useCallback(() => { + setImages((prev) => { + if (prev.length === 0) return prev + setRemovedImageUrls((r) => Array.from(new Set([...r, ...prev]))) + return [] + }) + setIsDirty(true) + }, []) const handleGenerateTitles = async () => { @@ -902,6 +926,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o isAnalyzingSuggestions, isMarkdown, allImages, + contentImages, + coverImage, colorClasses, quotaExceededFeature, autoSaveEnabled, @@ -911,13 +937,14 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o isGeneratingTitles, titleSuggestions, dismissedTitleSuggestions, isReformulating, reformulationModal, previousContentForCopilot, showReminderDialog, currentReminder, showLinkDialog, linkUrl, comparisonNotes, fusionNotes, dismissedTags, filteredSuggestions, - isAnalyzingSuggestions, isMarkdown, allImages, colorClasses, quotaExceededFeature, autoSaveEnabled + isAnalyzingSuggestions, isMarkdown, allImages, contentImages, coverImage, colorClasses, quotaExceededFeature, autoSaveEnabled ]) const actions: NoteEditorActions = useMemo(() => ({ setTitle: (t) => { setTitle(t); setIsDirty(true); setDismissedTitleSuggestions(true) }, setDismissedTitleSuggestions, setContent: (c) => { setContent(c); setIsDirty(true) }, + setContentImmediate: (c) => { setContentImmediate(c); setIsDirty(true) }, setCheckItems, handleCheckItem, handleUpdateCheckItem, @@ -930,6 +957,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o setImages, handleImageUpload, handleRemoveImage, + setCoverImage, + clearCoverImage, uploadImageFile, setLinks, handleAddLink, @@ -974,6 +1003,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o setQuotaExceededFeature, toggleAutoSave, }), [ + setContent, setContentImmediate, setCoverImage, clearCoverImage, handleCheckItem, handleUpdateCheckItem, handleAddCheckItem, handleRemoveCheckItem, handleSelectGhostTag, handleDismissGhostTag, handleRemoveLabel, handleImageUpload, handleRemoveImage, handleAddLink, handleRemoveLink, handleReminderSave, diff --git a/memento-note/components/note-editor/note-editor-full-page.tsx b/memento-note/components/note-editor/note-editor-full-page.tsx index 81043b9..301c4d7 100644 --- a/memento-note/components/note-editor/note-editor-full-page.tsx +++ b/memento-note/components/note-editor/note-editor-full-page.tsx @@ -4,7 +4,7 @@ import { useNoteEditorContext } from './note-editor-context' import { NoteEditorToolbar } from './note-editor-toolbar' import { NoteTitleBlock } from './note-title-block' import { NoteContentArea } from './note-content-area' -import { EditorImages } from '@/components/editor-images' +import { NoteCoverHero } from './note-cover-hero' import { ComparisonModal } from '@/components/comparison-modal' import { FusionModal } from '@/components/fusion-modal' import { ReminderDialog } from '@/components/reminder-dialog' @@ -119,16 +119,15 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) { )}
- {/* Hero image — show first note image if present */} - {state.allImages.length > 0 && ( -
- {state.title} -
- )} + {/* Couverture explicite — pas d'auto-promo des images collées dans le corps */} + {/* Content area — max-w-4xl for wider reading column */}
diff --git a/memento-note/components/note-editor/note-editor-toolbar.tsx b/memento-note/components/note-editor/note-editor-toolbar.tsx index 836fb6d..22f9b2b 100644 --- a/memento-note/components/note-editor/note-editor-toolbar.tsx +++ b/memento-note/components/note-editor/note-editor-toolbar.tsx @@ -539,8 +539,9 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme {t('notes.backToCollection')} -
- +
+ {/* 1. Status */} + {state.isSaving ? <>{t('notes.saving')} : state.isDirty @@ -557,165 +558,25 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme )} - {state.isMarkdown && !readOnly && ( + {/* 2. Save (primary) */} + {!readOnly && ( )} - {state.isMarkdown && !readOnly && ( - - )} - - - - - - {!readOnly && ( -
- - {showEduMenu && ( - <> -
setShowEduMenu(false)} /> -
- - -
- - )} -
- )} - - {!readOnly && voiceSupported && ( - - )} - - {!readOnly && onToggleAttachments && ( - - )} - - {!readOnly && ( -
- -
- )} - + {/* 3. Publish */} {!readOnly && ( @@ -904,6 +765,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme )} + {/* 4. Share */} {!readOnly && ( + + {/* 6. Brainstorm */} + + + {/* 7. Education (flashcards / exercises) */} + {!readOnly && ( +
+ + {showEduMenu && ( + <> +
setShowEduMenu(false)} /> +
+ + +
+ + )} +
+ )} + + {/* 8. Voice / attachments / markdown tools */} + {!readOnly && voiceSupported && ( + + )} + + {!readOnly && onToggleAttachments && ( + + )} + + {state.isMarkdown && !readOnly && ( + + )} + + {state.isMarkdown && !readOnly && ( + + )} + + {/* 9. Overflow menu */} {!readOnly && ( diff --git a/memento-note/components/note-editor/types.ts b/memento-note/components/note-editor/types.ts index dea66e1..7a7b211 100644 --- a/memento-note/components/note-editor/types.ts +++ b/memento-note/components/note-editor/types.ts @@ -52,6 +52,10 @@ export interface NoteEditorState { isMarkdown: boolean allImages: string[] + /** Images extraites du corps de la note (pas la couverture) */ + contentImages: string[] + /** URL de couverture explicite (images[0]), null si aucune */ + coverImage: string | null colorClasses: typeof NOTE_COLORS[keyof typeof NOTE_COLORS] quotaExceededFeature: string | null } @@ -61,6 +65,8 @@ export interface NoteEditorActions { setDismissedTitleSuggestions: (dismissed: boolean) => void setContent: (content: string) => void + /** Immediate content state (no 800ms debounce) — image paste, conversions, etc. */ + setContentImmediate: (content: string) => void setCheckItems: (items: CheckItem[]) => void handleCheckItem: (id: string) => void @@ -76,6 +82,10 @@ export interface NoteEditorActions { setImages: (images: string[]) => void handleImageUpload: (e: React.ChangeEvent) => void handleRemoveImage: (index: number) => void + /** Définit l'image de couverture (sous le titre) sans retirer l'image du corps */ + setCoverImage: (url: string) => void + /** Retire la couverture (les images du corps restent) */ + clearCoverImage: () => void uploadImageFile: (file: File) => Promise setLinks: (links: LinkMetadata[]) => void diff --git a/memento-note/components/rich-text-editor.tsx b/memento-note/components/rich-text-editor.tsx index 634564d..bc59bc3 100644 --- a/memento-note/components/rich-text-editor.tsx +++ b/memento-note/components/rich-text-editor.tsx @@ -70,10 +70,11 @@ import { FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight, Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus, SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database, - ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2 + ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2, Trash2 } from 'lucide-react' import { cn } from '@/lib/utils' import { toast } from 'sonner' +import { NodeSelection } from '@tiptap/pm/state' export interface RichTextEditorHandle { getEditor: () => Editor | null @@ -88,6 +89,8 @@ export interface RichTextEditorHandle { export interface RichTextEditorProps { content?: string onChange?: (content: string) => void + /** Immediate parent state update (image paste, structural inserts) — skips typing debounce. */ + onChangeImmediate?: (content: string) => void className?: string placeholder?: string onImageUpload?: (file: File) => Promise @@ -137,6 +140,11 @@ const TRANSLATE_TARGET_API_VALUES = ['Francais', 'English', 'Espanol', 'Deutsch' const AI_REFORMULATE_FALLBACK = '__RICH_TEXT_AI_FALLBACK__' const CustomImage = Image.extend({ + // Treat image as a single selectable unit (Backspace/Delete must remove it) + atom: true, + selectable: true, + draggable: true, + addAttributes() { return { ...this.parent?.(), @@ -149,22 +157,98 @@ const CustomImage = Image.extend({ } } } - } + }, + + addKeyboardShortcuts() { + const deleteSelectedImage = () => { + const { selection } = this.editor.state + if (selection instanceof NodeSelection && selection.node.type.name === 'image') { + return this.editor.chain().focus().deleteSelection().run() + } + // Cursor just after an image block → Backspace removes it + const { $from, empty } = selection + if (!empty) return false + const before = $from.nodeBefore + if (before?.type.name === 'image') { + const from = $from.pos - before.nodeSize + return this.editor.chain().focus().deleteRange({ from, to: $from.pos }).run() + } + // Cursor at start of paragraph right after image (common after paste under title) + if ($from.parentOffset === 0 && $from.depth >= 1) { + const index = $from.index($from.depth - 1) + if (index > 0) { + const prev = $from.node($from.depth - 1).child(index - 1) + if (prev.type.name === 'image') { + const pos = $from.before($from.depth) - prev.nodeSize + return this.editor.chain().focus().deleteRange({ from: pos, to: pos + prev.nodeSize }).run() + } + } + } + return false + } + + return { + Backspace: deleteSelectedImage, + Delete: () => { + const { selection } = this.editor.state + if (selection instanceof NodeSelection && selection.node.type.name === 'image') { + return this.editor.chain().focus().deleteSelection().run() + } + // Cursor just before an image → Delete removes it + const { $from, empty } = selection + if (!empty) return false + const after = $from.nodeAfter + if (after?.type.name === 'image') { + return this.editor.chain().focus().deleteRange({ from: $from.pos, to: $from.pos + after.nodeSize }).run() + } + return false + }, + } + }, }) +/** Insert / toggle a bullet list without the toggle-off → re-insert glitch. */ +function insertBulletList(editor: Editor) { + // Already a bullet list → toggle off (do not re-insert) + if (editor.isActive('bulletList')) { + editor.chain().focus().toggleBulletList().run() + return + } + // Convert from other list types if needed + if (editor.isActive('orderedList')) editor.chain().focus().toggleOrderedList().run() + if (editor.isActive('taskList')) editor.chain().focus().toggleTaskList().run() + const ok = editor.chain().focus().toggleBulletList().run() + if (!ok) { + editor.chain().focus().insertContent('
').run() + } +} + +function insertOrderedList(editor: Editor) { + if (editor.isActive('orderedList')) { + editor.chain().focus().toggleOrderedList().run() + return + } + if (editor.isActive('bulletList')) editor.chain().focus().toggleBulletList().run() + if (editor.isActive('taskList')) editor.chain().focus().toggleTaskList().run() + const ok = editor.chain().focus().toggleOrderedList().run() + if (!ok) { + editor.chain().focus().insertContent('
').run() + } +} + const slashCommands: SlashItem[] = [ - // Basic blocks + // Basic blocks — lists BEFORE table so defaults / shortcuts never collide with Tableau { title: 'Text', description: 'Plain paragraph', icon: Pilcrow, category: 'Basic blocks', shortcut: '¶', command: (e) => e.chain().focus().setParagraph().run() }, { title: 'Heading 1', description: 'Big section heading', icon: Heading1, category: 'Basic blocks', shortcut: '#', command: (e) => e.chain().focus().toggleHeading({ level: 1 }).run() }, { title: 'Heading 2', description: 'Medium section heading', icon: Heading2, category: 'Basic blocks', shortcut: '##', command: (e) => e.chain().focus().toggleHeading({ level: 2 }).run() }, { title: 'Heading 3', description: 'Small section heading', icon: Heading3, category: 'Basic blocks', shortcut: '###', command: (e) => e.chain().focus().toggleHeading({ level: 3 }).run() }, - { title: 'Table', description: 'Insert a simple table', icon: () => TBL, category: 'Basic blocks', command: (e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() }, - { title: 'Bullet List', description: 'Unordered list', icon: List, category: 'Basic blocks', shortcut: '-', command: (e) => e.chain().focus().toggleBulletList().run() }, - { title: 'Numbered List', description: 'Ordered numbered list', icon: ListOrdered, category: 'Basic blocks', shortcut: '1.', command: (e) => e.chain().focus().toggleOrderedList().run() }, + { title: 'Bullet List', description: 'Unordered list', icon: List, category: 'Basic blocks', shortcut: '-', command: (e) => insertBulletList(e) }, + { title: 'Numbered List', description: 'Ordered numbered list', icon: ListOrdered, category: 'Basic blocks', shortcut: '1.', command: (e) => insertOrderedList(e) }, { title: 'To-do List', description: 'Checkboxes for tasks', icon: CheckSquare, category: 'Basic blocks', shortcut: '[]', command: (e) => e.chain().focus().toggleTaskList().run() }, { title: 'Quote', description: 'Capture a quote', icon: Quote, category: 'Basic blocks', shortcut: '>', command: (e) => e.chain().focus().toggleBlockquote().run() }, { title: 'Code Block', description: 'Code snippet', icon: CodeXml, category: 'Basic blocks', shortcut: '```', command: (e) => e.chain().focus().toggleCodeBlock().run() }, { title: 'Divider', description: 'Horizontal separator', icon: Minus, category: 'Basic blocks', shortcut: '---', command: (e) => e.chain().focus().setHorizontalRule().run() }, + { title: 'Table', description: 'Insert a simple table', icon: () => TBL, category: 'Basic blocks', command: (e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() }, // Media { title: 'Image', description: 'Embed image from URL', icon: ImageIcon, category: 'Media', isImage: true, command: () => { } }, // Formatting @@ -300,7 +384,7 @@ function useImageInsert() { } export const RichTextEditor = forwardRef( - function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId, notebookId, noteTitle, sourceUrl }, ref) { + function RichTextEditor({ content, onChange, onChangeImmediate, className, placeholder, onImageUpload, noteId, notebookId, noteTitle, sourceUrl }, ref) { const { t } = useLanguage() const { requestAiConsent } = useAiConsent() const imageInsert = useImageInsert() @@ -361,11 +445,22 @@ export const RichTextEditor = forwardRef(null) const onChangeRef = useRef(onChange) onChangeRef.current = onChange + const onChangeImmediateRef = useRef(onChangeImmediate) + onChangeImmediateRef.current = onChangeImmediate const htmlDebounceRef = useRef | null>(null) - const emitContentChange = useCallback((html: string) => { + const emitContentChange = useCallback((html: string, opts?: { immediate?: boolean }) => { lastEmittedContent.current = html - onChangeRef.current?.(html) + // Cancel pending debounced HTML emit so it cannot overwrite with stale doc + if (htmlDebounceRef.current) { + clearTimeout(htmlDebounceRef.current) + htmlDebounceRef.current = null + } + if (opts?.immediate && onChangeImmediateRef.current) { + onChangeImmediateRef.current(html) + } else { + onChangeRef.current?.(html) + } }, []) // Debounced version for onUpdate — avoids calling getHTML() on every keystroke @@ -581,6 +676,7 @@ export const RichTextEditor = forwardRef item.type.startsWith('image/')) if (!hasImage) return false event.preventDefault() + event.stopPropagation() const imageFiles = items .filter(item => item.type.startsWith('image/')) .map(item => item.getAsFile()) @@ -592,20 +688,82 @@ export const RichTextEditor = forwardRef { + // Click on img → NodeSelection so Backspace/Delete and bubble toolbar work + const target = event.target as HTMLElement | null + if (!target || target.tagName !== 'IMG') return false + if (!view.editable) return false + try { + let imagePos: number | null = null + const nodeAt = view.state.doc.nodeAt(pos) + if (nodeAt?.type.name === 'image') { + imagePos = pos + } else { + const $pos = view.state.doc.resolve(pos) + if ($pos.nodeAfter?.type.name === 'image') { + imagePos = $pos.pos + } else if ($pos.nodeBefore?.type.name === 'image') { + imagePos = $pos.pos - $pos.nodeBefore.nodeSize + } else { + // Walk up: pos may sit inside / around the leaf + for (let d = $pos.depth; d > 0; d--) { + if ($pos.node(d).type.name === 'image') { + imagePos = $pos.before(d) + break + } + } + } + } + if (imagePos == null) { + // Last resort: posAtDOM on the img element + const domPos = view.posAtDOM(target, 0) + const $dom = view.state.doc.resolve(domPos) + if ($dom.nodeAfter?.type.name === 'image') imagePos = $dom.pos + else if (view.state.doc.nodeAt(domPos)?.type.name === 'image') imagePos = domPos + } + if (imagePos == null) return false + const node = view.state.doc.nodeAt(imagePos) + if (!node || node.type.name !== 'image') return false + const tr = view.state.tr.setSelection(NodeSelection.create(view.state.doc, imagePos)) + view.dispatch(tr) + view.focus() + return true + } catch { + return false + } + }, }, - onUpdate: ({ editor: e }) => { + onUpdate: ({ editor: e, transaction }) => { + // Image removed → immediate parent sync so a stale content prop cannot resurrect it + if (transaction.docChanged && (lastEmittedContent.current || '').includes(' { if (editor && content !== undefined && content !== lastEmittedContent.current) { + // While the user is editing, never clobber local deletes (e.g. removed image) + // with a stale parent `content` that has not caught up yet. + if (editor.isFocused) { + return + } const html = content || '' lastEmittedContent.current = html queueMicrotask(() => { @@ -774,7 +937,7 @@ export const RichTextEditor = forwardRef { @@ -1521,9 +1684,47 @@ function BubbleToolbar({ editor, onSuggestCharts }: { editor: Editor | null; onS {editorState.isImage && ( <>
- - - + + + + )} {aiOpen && ( @@ -1728,45 +1929,52 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor: const menuInteracting = useRef(false) const [frequentCommands, setFrequentCommands] = useState([]) + // Resolve by English command title — NEVER by array index (reorder-safe) + const sc = (enTitle: string) => { + const cmd = slashCommands.find(c => c.title === enTitle) + if (!cmd) throw new Error(`Slash command missing: ${enTitle}`) + return cmd + } + const localCommands: SlashMenuItem[] = [ - { ...slashCommands[0], title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), categoryId: 'text' }, - { ...slashCommands[1], title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), categoryId: 'text' }, - { ...slashCommands[2], title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), categoryId: 'text' }, - { ...slashCommands[3], title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), categoryId: 'text' }, - { ...slashCommands[4], title: t('richTextEditor.slashTable'), description: t('richTextEditor.slashTableDesc'), categoryId: 'data' }, - { ...slashCommands[5], title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), categoryId: 'text' }, - { ...slashCommands[6], title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), categoryId: 'text' }, - { ...slashCommands[7], title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), categoryId: 'text' }, - { ...slashCommands[8], title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), categoryId: 'text' }, - { ...slashCommands[9], title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), categoryId: 'text' }, - { ...slashCommands[10], title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), categoryId: 'text' }, - { ...slashCommands[11], title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), categoryId: 'media' }, - { ...slashCommands[12], title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), categoryId: 'text' }, - { ...slashCommands[13], title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), categoryId: 'text' }, - { ...slashCommands[14], title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), categoryId: 'text' }, - { ...slashCommands[15], title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), categoryId: 'ai' }, - { ...slashCommands[16], title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), categoryId: 'ai' }, - { ...slashCommands[17], title: t('richTextEditor.slashImprove'), description: t('richTextEditor.slashImproveDesc'), categoryId: 'ai' }, - { ...slashCommands[18], title: t('richTextEditor.slashExpand'), description: t('richTextEditor.slashExpandDesc'), categoryId: 'ai' }, - { ...slashCommands[19], title: t('richTextEditor.bold'), description: t('richTextEditor.bold'), categoryId: 'text' }, - { ...slashCommands[20], title: t('richTextEditor.italic'), description: t('richTextEditor.italic'), categoryId: 'text' }, - { ...slashCommands[21], title: t('richTextEditor.underline'), description: t('richTextEditor.underline'), categoryId: 'text' }, - { ...slashCommands[22], title: t('richTextEditor.strike'), description: t('richTextEditor.strike'), categoryId: 'text' }, - { ...slashCommands[23], title: t('richTextEditor.highlight'), description: t('richTextEditor.highlight'), categoryId: 'text' }, - { ...slashCommands[24], title: t('richTextEditor.slashSuperscript'), description: t('richTextEditor.slashSuperscriptDesc'), categoryId: 'text' }, - { ...slashCommands[25], title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), categoryId: 'text' }, - { ...slashCommands[26], title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), categoryId: 'ai' }, - { ...slashCommands[27], title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), categoryId: 'ai' }, - { ...slashCommands[28], title: t('richTextEditor.slashCharts') || 'Graphiques IA', description: t('richTextEditor.slashChartsDesc') || 'IA suggère des graphiques', categoryId: 'ai' }, - { ...slashCommands[29], title: t('richTextEditor.slashLivingBlock') || 'Bloc vivant', description: t('richTextEditor.slashLivingBlockDesc') || 'Insérer depuis une autre note', categoryId: 'embed' }, - { ...slashCommands[30], title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'data', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'tableau', 'structured', 'structuree', 'structurée'] }, - { ...slashCommands[31], title: t('richTextEditor.slashToggle'), description: t('richTextEditor.slashToggleDesc'), categoryId: 'text', slashKeywords: ['toggle', 'accordion', 'replier', 'deroulant', 'déroulant', 'section'] }, - { ...slashCommands[32], title: t('richTextEditor.slashCallout'), description: t('richTextEditor.slashCalloutDesc'), categoryId: 'text', slashKeywords: ['callout', 'encadre', 'encadré', 'info', 'alerte', 'astuce', 'tip', 'warning'] }, - { ...slashCommands[33], title: t('richTextEditor.slashOutline'), description: t('richTextEditor.slashOutlineDesc'), categoryId: 'text', slashKeywords: ['outline', 'sommaire', 'toc', 'table', 'matieres', 'matières', 'plan'] }, - { ...slashCommands[34], title: t('richTextEditor.slashLinkPreview'), description: t('richTextEditor.slashLinkPreviewDesc'), categoryId: 'embed', slashKeywords: ['link', 'lien', 'url', 'preview', 'apercu', 'aperçu', 'embed', 'card', 'carte'] }, - { ...slashCommands[35], title: t('richTextEditor.slashMath'), description: t('richTextEditor.slashMathDesc'), categoryId: 'text', slashKeywords: ['math', 'maths', 'equation', 'équation', 'formula', 'formule', 'latex', 'katex'] }, - { ...slashCommands[36], title: t('richTextEditor.slashColumns'), description: t('richTextEditor.slashColumnsDesc'), categoryId: 'text', slashKeywords: ['columns', 'colonnes', 'cols', 'layout', 'mise', 'page', 'cote', 'côte'] }, - { ...slashCommands[37], title: t('richTextEditor.slashAiWriter') || 'Écrire avec l\'IA', description: t('richTextEditor.slashAiWriterDesc') || 'Générer du contenu au curseur', categoryId: 'ai', slashKeywords: ['ecrire', 'écrire', 'write', 'ia', 'ai', 'generer', 'générer', 'rediger', 'rédiger'] }, + { ...sc('Text'), title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), categoryId: 'text' }, + { ...sc('Heading 1'), title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), categoryId: 'text' }, + { ...sc('Heading 2'), title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), categoryId: 'text' }, + { ...sc('Heading 3'), title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), categoryId: 'text' }, + { ...sc('Bullet List'), title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), categoryId: 'text', slashKeywords: ['bullet', 'liste', 'puce', 'ul', 'puces'] }, + { ...sc('Numbered List'), title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), categoryId: 'text', slashKeywords: ['numbered', 'numérotée', 'numerotee', 'ol', '1.'] }, + { ...sc('To-do List'), title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), categoryId: 'text', slashKeywords: ['todo', 'tache', 'tâche', 'checkbox'] }, + { ...sc('Quote'), title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), categoryId: 'text' }, + { ...sc('Code Block'), title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), categoryId: 'text' }, + { ...sc('Divider'), title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), categoryId: 'text' }, + { ...sc('Table'), title: t('richTextEditor.slashTable'), description: t('richTextEditor.slashTableDesc'), categoryId: 'data', slashKeywords: ['table', 'tableau', 'grid', 'grille'] }, + { ...sc('Image'), title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), categoryId: 'media' }, + { ...sc('Align Left'), title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), categoryId: 'text' }, + { ...sc('Align Center'), title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), categoryId: 'text' }, + { ...sc('Align Right'), title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), categoryId: 'text' }, + { ...sc('Clarifier'), title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), categoryId: 'ai' }, + { ...sc('Raccourcir'), title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), categoryId: 'ai' }, + { ...sc('Améliorer'), title: t('richTextEditor.slashImprove'), description: t('richTextEditor.slashImproveDesc'), categoryId: 'ai' }, + { ...sc('Développer'), title: t('richTextEditor.slashExpand'), description: t('richTextEditor.slashExpandDesc'), categoryId: 'ai' }, + { ...sc('Bold'), title: t('richTextEditor.bold'), description: t('richTextEditor.bold'), categoryId: 'text' }, + { ...sc('Italic'), title: t('richTextEditor.italic'), description: t('richTextEditor.italic'), categoryId: 'text' }, + { ...sc('Underline'), title: t('richTextEditor.underline'), description: t('richTextEditor.underline'), categoryId: 'text' }, + { ...sc('Strike'), title: t('richTextEditor.strike'), description: t('richTextEditor.strike'), categoryId: 'text' }, + { ...sc('Highlight'), title: t('richTextEditor.highlight'), description: t('richTextEditor.highlight'), categoryId: 'text' }, + { ...sc('Superscript'), title: t('richTextEditor.slashSuperscript'), description: t('richTextEditor.slashSuperscriptDesc'), categoryId: 'text' }, + { ...sc('Subscript'), title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), categoryId: 'text' }, + { ...sc('Diagramme'), title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), categoryId: 'ai' }, + { ...sc('Présentation'), title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), categoryId: 'ai' }, + { ...sc('Suggest Charts'), title: t('richTextEditor.slashCharts') || 'Graphiques IA', description: t('richTextEditor.slashChartsDesc') || 'IA suggère des graphiques', categoryId: 'ai' }, + { ...sc('Living Block'), title: t('richTextEditor.slashLivingBlock') || 'Bloc vivant', description: t('richTextEditor.slashLivingBlockDesc') || 'Insérer depuis une autre note', categoryId: 'embed' }, + { ...sc('Database'), title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'data', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'structured', 'structuree', 'structurée'] }, + { ...sc('Toggle'), title: t('richTextEditor.slashToggle'), description: t('richTextEditor.slashToggleDesc'), categoryId: 'text', slashKeywords: ['toggle', 'accordion', 'replier', 'deroulant', 'déroulant', 'section'] }, + { ...sc('Callout'), title: t('richTextEditor.slashCallout'), description: t('richTextEditor.slashCalloutDesc'), categoryId: 'text', slashKeywords: ['callout', 'encadre', 'encadré', 'info', 'alerte', 'astuce', 'tip', 'warning'] }, + { ...sc('Outline'), title: t('richTextEditor.slashOutline'), description: t('richTextEditor.slashOutlineDesc'), categoryId: 'text', slashKeywords: ['outline', 'sommaire', 'toc', 'matieres', 'matières', 'plan'] }, + { ...sc('Link Preview'), title: t('richTextEditor.slashLinkPreview'), description: t('richTextEditor.slashLinkPreviewDesc'), categoryId: 'embed', slashKeywords: ['link', 'lien', 'url', 'preview', 'apercu', 'aperçu', 'embed', 'card', 'carte'] }, + { ...sc('Math'), title: t('richTextEditor.slashMath'), description: t('richTextEditor.slashMathDesc'), categoryId: 'text', slashKeywords: ['math', 'maths', 'equation', 'équation', 'formula', 'formule', 'latex', 'katex'] }, + { ...sc('Columns'), title: t('richTextEditor.slashColumns'), description: t('richTextEditor.slashColumnsDesc'), categoryId: 'text', slashKeywords: ['columns', 'colonnes', 'cols', 'layout', 'mise', 'page', 'cote', 'côte'] }, + { ...sc("Écrire avec l'IA"), title: t('richTextEditor.slashAiWriter') || 'Écrire avec l\'IA', description: t('richTextEditor.slashAiWriterDesc') || 'Générer du contenu au curseur', categoryId: 'ai', slashKeywords: ['ecrire', 'écrire', 'write', 'ia', 'ai', 'generer', 'générer', 'rediger', 'rédiger'] }, { title: t('richTextEditor.slashNoteLink'), description: t('richTextEditor.slashNoteLinkDesc'), @@ -1870,7 +2078,10 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor: toastAi(err) } finally { setAiLoading(false) } - } else if (item.title === 'Suggest Charts') { + } else if ( + item.title === 'Suggest Charts' + || item.title === (t('richTextEditor.slashCharts') || 'Graphiques IA') + ) { deleteSlashText(); closeMenu(); onSuggestCharts() } else if (item.title === t('richTextEditor.slashDatabase')) { deleteSlashText(); closeMenu() diff --git a/memento-note/components/wizard/ai-notebook-wizard.tsx b/memento-note/components/wizard/ai-notebook-wizard.tsx index 30636f5..9f301d1 100644 --- a/memento-note/components/wizard/ai-notebook-wizard.tsx +++ b/memento-note/components/wizard/ai-notebook-wizard.tsx @@ -68,7 +68,7 @@ export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void; level: t(LEVELS[level]), count, language, - notebookName: topic.trim(), + // Server generates a short title from the topic; do not force raw prompt as name }), }) diff --git a/memento-note/lib/ai/services/chunk-indexing.service.ts b/memento-note/lib/ai/services/chunk-indexing.service.ts index ee85e5b..ce98977 100644 --- a/memento-note/lib/ai/services/chunk-indexing.service.ts +++ b/memento-note/lib/ai/services/chunk-indexing.service.ts @@ -19,6 +19,7 @@ * - Pas de race condition (verrou par noteId) */ +import { createHash } from 'crypto' import PQueue from 'p-queue' import { prisma } from '@/lib/prisma' import { embeddingService } from './embedding.service' @@ -78,7 +79,16 @@ export class ChunkIndexingService { ): Promise { const start = Date.now() const plain = prepareNoteTextForEmbedding(title, content) - const newChunks = chunkNoteContent(noteId, plain) + // Index even very short notes (title-only or one-liners) + let newChunks = chunkNoteContent(noteId, plain) + if (newChunks.length === 0 && plain.trim().length > 0) { + // Force a single fragment when paragraph split yields nothing + const fragmentId = createHash('sha256') + .update(`${noteId}::${plain}`) + .digest('hex') + .slice(0, 32) + newChunks = [{ fragmentId, content: plain, chunkIndex: 0, charCount: plain.length }] + } const newFragmentIds = new Set(newChunks.map((c) => c.fragmentId)) const existing = await prisma.noteEmbeddingChunk.findMany({ diff --git a/memento-note/lib/ai/services/notebook-wizard.service.ts b/memento-note/lib/ai/services/notebook-wizard.service.ts index ccc31c3..4aff824 100644 --- a/memento-note/lib/ai/services/notebook-wizard.service.ts +++ b/memento-note/lib/ai/services/notebook-wizard.service.ts @@ -9,12 +9,29 @@ export interface GeneratedNote { } export interface GeneratedCarnet { + /** Short notebook title (not the raw user prompt). */ + notebookName?: string notes: GeneratedNote[] schemaProperties: Array<{ name: string; type: string; options?: string[] }> } export type WizardProfile = 'student' | 'teacher' | 'engineer' +/** Word-count targets by level — Expert was failing (timeouts / truncated JSON). */ +function wordTargetsForLevel(level?: string): { min: number; max: number; label: string } { + const l = (level || '').toLowerCase() + if (/expert|avancé|advanced|fort/.test(l)) { + return { min: 350, max: 700, label: 'expert (350–700 mots/note — dense mais JSON-fiable)' } + } + if (/intermédiaire|intermediate|moyen/.test(l)) { + return { min: 250, max: 500, label: 'intermédiaire (250–500 mots/note)' } + } + if (/débutant|beginner|facile|easy/.test(l)) { + return { min: 150, max: 350, label: 'débutant (150–350 mots/note)' } + } + return { min: 200, max: 450, label: 'standard (200–450 mots/note)' } +} + export class NotebookWizardService { async generateCarnet( profile: WizardProfile, @@ -22,14 +39,52 @@ export class NotebookWizardService { options?: { level?: string; count?: number; language?: string } ): Promise { const lang = options?.language || 'fr' - const count = options?.count || 6 + // Cap count in expert mode to avoid oversized responses + const level = options?.level + const isExpert = /expert/i.test(level || '') + const count = Math.min(options?.count || 6, isExpert ? 5 : 10) - const prompts = this.buildPrompt(profile, topic, lang, count, options?.level) + const prompts = this.buildPrompt(profile, topic, lang, count, level) const config = await getSystemConfig() const provider = getChatProvider(config) - const raw = await provider.generateText(prompts) + let raw: string + try { + raw = await provider.generateText(prompts) + } catch (err) { + // Retry once with lighter prompt if expert/full generation fails + if (isExpert) { + const light = this.buildPrompt(profile, topic, lang, Math.min(count, 4), 'Avancé') + raw = await provider.generateText(light) + } else { + throw err + } + } - return this.parseResponse(raw, profile, lang) + const result = this.parseResponse(raw, profile, lang) + if (!result.notebookName?.trim()) { + result.notebookName = this.deriveNotebookName(topic, result.notes, lang) + } + return result + } + + /** Fallback short name when the model omits notebookName. */ + private deriveNotebookName(topic: string, notes: GeneratedNote[], lang: string): string { + const fromNote = notes[0]?.title?.trim() + if (fromNote && fromNote.length <= 80 && fromNote.length >= 3) { + // Prefer first chapter title cleaned of numbering + return fromNote.replace(/^\d+[\.\):\-–]\s*/, '').slice(0, 80) + } + const cleaned = topic + .replace(/\s+/g, ' ') + .trim() + .replace(/^(je veux|i want|please|peux-tu|can you|crée|create|génère|generate)\b[\s,:-]*/i, '') + if (cleaned.length > 0 && cleaned.length <= 60) return cleaned + if (cleaned.length > 60) { + const cut = cleaned.slice(0, 57) + const lastSpace = cut.lastIndexOf(' ') + return (lastSpace > 20 ? cut.slice(0, lastSpace) : cut) + '…' + } + return lang === 'fr' ? 'Nouveau carnet' : 'New notebook' } private buildPrompt( @@ -41,6 +96,7 @@ export class NotebookWizardService { ): string { const langName = lang === 'fr' ? 'français' : lang === 'en' ? 'English' : lang === 'fa' ? 'فارسی' : lang === 'ar' ? 'العربية' : lang === 'de' ? 'Deutsch' : lang === 'es' ? 'Español' : lang === 'it' ? 'Italiano' : lang === 'pt' ? 'Português' : lang === 'ru' ? 'Русский' : lang === 'zh' ? '中文' : lang === 'ja' ? '日本語' : lang === 'ko' ? '한국어' : lang === 'nl' ? 'Nederlands' : lang === 'pl' ? 'Polski' : lang === 'hi' ? 'हिन्दी' : lang + const words = wordTargetsForLevel(level) const schemaLabels = this.getSchemaLabels(lang) const profileContext = { student: `Tu crées des notes de cours pour un étudiant. Le contenu doit être pédagogique, clair, avec des exemples.`, @@ -50,31 +106,36 @@ export class NotebookWizardService { return `Tu es un expert pédagogue et créateur de contenu de haut niveau. ${profileContext} -Sujet : "${topic}" +Sujet (intention de l'utilisateur, NE PAS recopier tel quel comme titre de carnet) : "${topic}" ${level ? `Niveau : ${level}` : ''} Langue : ${langName} Nombre de notes à créer : ${count} +Profondeur cible : ${words.label} -CRÉE ${count} NOTES COMPLÈTES ET DÉTAILLÉES sur ce sujet. Chaque note doit faire ENTRE 800 ET 1500 MOTS minimum. C'est non négociable. +CRÉE ${count} NOTES COMPLÈTES sur ce sujet. Chaque note : environ ${words.min}–${words.max} mots (qualité > longueur extrême). + +IMPORTANT — NOM DU CARNET : +- Fournis "notebookName" : un titre COURT et PERTINENT (3–8 mots, max 60 caractères). +- Exemple : pour un long message sur la thermo HVAC → "Modélisation thermodynamique HVAC" +- N'utilise JAMAIS la phrase complète de l'utilisateur comme nom. Le contenu doit être : -- COMPLET et APPROFONDI — pas de résumé superficiel -- Structuré avec des titres

et

pour les sections -- Des paragraphes développés avec des explications détaillées -- Des exemples concrets et des cas d'usage -- Des définitions précises dans des encadrés callout -${profile === 'student' ? '- Des "Points clés à retenir" en callout tip\n- Des "Pièges à éviter" en callout danger\n- Des exemples d\'application' : ''} -${profile === 'teacher' ? '- Des objectifs pédagogiques en début de note\n- Des sections "Exercices" avec 5 questions détaillées\n- Des corrigés indicatifs' : ''} -${profile === 'engineer' ? '- Des spécifications techniques précises\n- Des tableaux comparatifs\n- Des références aux normes et standards' : ''} +- Clair et structuré avec

et

+- Des exemples concrets +- Des définitions dans des callouts +${profile === 'student' ? '- Points clés en callout tip\n- Pièges à éviter en callout danger' : ''} +${profile === 'teacher' ? '- Objectifs pédagogiques\n- Section Exercices (3–5 questions)' : ''} +${profile === 'engineer' ? '- Spécifications techniques\n- Tableaux comparatifs si utile' : ''} -FORMAT DE SORTIE — JSON UNIQUEMENT : +FORMAT DE SORTIE — JSON UNIQUEMENT (pas de markdown hors du bloc) : \`\`\`json { + "notebookName": "Titre court du carnet", "notes": [ { - "title": "Titre détaillé et descriptif", + "title": "Titre de chapitre descriptif", "difficulty": "facile", - "content": "

Introduction

Plusieurs paragraphes détaillés...

..." + "content": "

Introduction

...

..." } ], "schemaProperties": [ @@ -84,31 +145,17 @@ FORMAT DE SORTIE — JSON UNIQUEMENT : } \`\`\` -BLOCS HTML DISPONIBLES — UTILISE-LES ABONDAMMENT : +BLOCS HTML DISPONIBLES : -1. **Callout** (encadré coloré) : -

Définition importante...

- Types : info, warning, tip, success, danger +1. Callout :

...

(info|warning|tip|success|danger) +2. Toggle :

Titre

Contenu

+3. Math :
— JAMAIS $$ +4. Colonnes :
...
+5. Sommaire :
+6. HTML :

/

,

,

    /
      /
    1. , ,
      ,
      
       
      -2. **Toggle** (section repliable pour détails) :
      -   

      Cliquer pour voir les détails

      Contenu détaillé...

      - -3. **Math** (formules LaTeX) — UTILISE LES BALISES DIRECTEMENT, pas de $$ : - Block :
      - Inline : x^2 - N'UTILISE JAMAIS $$ ou $ comme délimiteur — utilise TOUJOURS les balises HTML ci-dessus. - -4. **Colonnes** (comparaison côte à côte) : -

      Concept A...

      Concept B...

      - -5. **Sommaire** (début de note) : -
      - -6. HTML standard :

      /

      /

      ,

      ,

        /
          /
        1. ,

      ,
      ,
      
      -
      -IMPORTANT : Chaque note DOIT faire entre 800 et 1500 mots. Sois exhaustif. Développe chaque concept avec des exemples, des explications, et du contexte. N'écris pas de résumés courts.
      -
      -Les "difficulty" doivent varier : mélange facile/moyen/difficile.`
      +Les "difficulty" doivent varier : facile/moyen/difficile.
      +Réponds UNIQUEMENT avec le JSON valide (échappement correct des guillemets dans le HTML).`
         }
       
         private getSchemaLabels(lang: string) {
      @@ -193,7 +240,11 @@ Les "difficulty" doivent varier : mélange facile/moyen/difficile.`
             { name: defaultLabels.difficulty, type: 'select', options: [defaultLabels.easy, defaultLabels.medium, defaultLabels.hard] },
           ]
       
      -    return { notes, schemaProperties }
      +    const notebookName = typeof parsed.notebookName === 'string'
      +      ? parsed.notebookName.trim().slice(0, 80)
      +      : undefined
      +
      +    return { notebookName, notes, schemaProperties }
         }
       }
       
      diff --git a/memento-note/lib/image-cleanup.ts b/memento-note/lib/image-cleanup.ts
      index 890fc3e..2f62fa7 100644
      --- a/memento-note/lib/image-cleanup.ts
      +++ b/memento-note/lib/image-cleanup.ts
      @@ -20,14 +20,26 @@ export async function deleteImageFileSafely(imageUrl: string, excludeNoteId?: st
       
         try {
           const notes = await prisma.note.findMany({
      -      where: { images: { contains: imageUrl } },
      +      where: {
      +        OR: [
      +          { images: { contains: imageUrl } },
      +          { content: { contains: imageUrl } },
      +        ],
      +      },
             select: { id: true },
           })
           const otherRefs = notes.filter(n => n.id !== excludeNoteId)
           if (otherRefs.length > 0) return // File still referenced elsewhere
       
      -    const filePath = path.join(process.cwd(), 'data', imageUrl)
      +    // imageUrl = /uploads/notes/... → data/uploads/notes/...
      +    const filePath = path.join(process.cwd(), 'data', imageUrl.replace(/^\//, ''))
           await fs.unlink(filePath)
      +    // Remove ownership sidecar if present
      +    try {
      +      await fs.unlink(filePath + '.meta.json')
      +    } catch {
      +      /* no meta */
      +    }
         } catch {
           // File already gone or unreadable -- silently skip
         }
      diff --git a/memento-note/lib/text/note-chunking.ts b/memento-note/lib/text/note-chunking.ts
      index 2e69111..f807355 100644
      --- a/memento-note/lib/text/note-chunking.ts
      +++ b/memento-note/lib/text/note-chunking.ts
      @@ -14,7 +14,8 @@ import { createHash } from 'crypto'
       
       const CHUNK_TARGET_CHARS = 1000
       const CHUNK_OVERLAP_CHARS = 200
      -const MIN_FRAGMENT_CHARS = 10
      +/** Allow short notes (flashcards, reminders, one-liners) to be indexed. */
      +const MIN_FRAGMENT_CHARS = 1
       const MAX_PARAGRAPH_BEFORE_SPLIT = 1500
       
       export interface NoteChunk {
      diff --git a/memento-note/lib/upload-access.ts b/memento-note/lib/upload-access.ts
      index 4ea480d..6f25297 100644
      --- a/memento-note/lib/upload-access.ts
      +++ b/memento-note/lib/upload-access.ts
      @@ -1,37 +1,118 @@
      +import { readFile } from 'fs/promises'
      +import path from 'path'
       import { prisma } from './prisma'
       
      -/** Whether a note image upload may be served to the current viewer. */
      +const NOTES_UPLOAD_ROOT = path.join(process.cwd(), 'data', 'uploads', 'notes')
      +
      +export type UploadMeta = {
      +  userId: string
      +  createdAt: number
      +}
      +
      +/**
      + * Canonical URL form:
      + *   /uploads/notes/{userId}/{filename}  ← preferred (ownership in path)
      + * Legacy:
      + *   /uploads/notes/{filename}           ← meta sidecar or note content
      + */
      +
      +export function uploadMetaPathForRelative(relativeUnderNotes: string): string {
      +  // relativeUnderNotes e.g. "userId/uuid.png" or "uuid.png"
      +  return path.join(NOTES_UPLOAD_ROOT, `${relativeUnderNotes}.meta.json`)
      +}
      +
      +export async function writeUploadMeta(relativeUnderNotes: string, userId: string): Promise {
      +  const { writeFile, mkdir } = await import('fs/promises')
      +  const full = uploadMetaPathForRelative(relativeUnderNotes)
      +  await mkdir(path.dirname(full), { recursive: true })
      +  const meta: UploadMeta = { userId, createdAt: Date.now() }
      +  await writeFile(full, JSON.stringify(meta), 'utf8')
      +}
      +
      +async function readUploadMeta(relativeUnderNotes: string): Promise {
      +  try {
      +    const raw = await readFile(uploadMetaPathForRelative(relativeUnderNotes), 'utf8')
      +    const parsed = JSON.parse(raw) as UploadMeta
      +    if (parsed?.userId && typeof parsed.userId === 'string') return parsed
      +    return null
      +  } catch {
      +    return null
      +  }
      +}
      +
      +/**
      + * @param pathSegments path after `/uploads/` e.g. `['notes', userId, 'file.png']` or `['notes', 'file.png']`
      + */
       export async function canAccessUploadedNoteImage(
      -  filename: string,
      +  pathSegments: string[],
         userId: string | null | undefined,
       ): Promise {
      +  if (pathSegments.length < 2 || pathSegments[0] !== 'notes') return false
      +
      +  // notes/{userId}/{filename}
      +  if (pathSegments.length >= 3) {
      +    const ownerId = pathSegments[1]
      +    const filename = pathSegments[pathSegments.length - 1]
      +    const relative = pathSegments.slice(1).join('/') // userId/file.png
      +    const imagePath = `/uploads/notes/${relative}`
      +
      +    // 1) Path ownership — definitive for new uploads
      +    if (userId && ownerId === userId) return true
      +
      +    // 2) Meta (redundant but safe)
      +    if (userId) {
      +      const meta = await readUploadMeta(relative)
      +      if (meta?.userId === userId) return true
      +    }
      +
      +    // 3) Public note embeds this URL
      +    const published = await noteContainsImage({ imagePath, filename, isPublic: true })
      +    if (published) return true
      +
      +    if (!userId) return false
      +
      +    // 4) Private note owned by user embeds this URL
      +    return noteContainsImage({ imagePath, filename, userId })
      +  }
      +
      +  // Legacy: notes/{filename}
      +  const filename = pathSegments[1]
         const imagePath = `/uploads/notes/${filename}`
       
      -  const published = await prisma.note.findFirst({
      -    where: {
      -      isPublic: true,
      -      trashedAt: null,
      -      OR: [
      -        { content: { contains: imagePath } },
      -        { images: { contains: filename } },
      -      ],
      -    },
      -    select: { id: true },
      -  })
      +  if (userId) {
      +    const meta = await readUploadMeta(filename)
      +    if (meta?.userId === userId) return true
      +  }
      +
      +  const published = await noteContainsImage({ imagePath, filename, isPublic: true })
         if (published) return true
       
         if (!userId) return false
      +  return noteContainsImage({ imagePath, filename, userId })
      +}
       
      -  const owned = await prisma.note.findFirst({
      -    where: {
      -      userId,
      -      trashedAt: null,
      -      OR: [
      -        { content: { contains: imagePath } },
      -        { images: { contains: filename } },
      -      ],
      -    },
      +async function noteContainsImage(opts: {
      +  imagePath: string
      +  filename: string
      +  userId?: string
      +  isPublic?: boolean
      +}): Promise {
      +  // Match full URL only (not bare filename) to avoid accidental cross-access
      +  const where: Record = {
      +    trashedAt: null,
      +    OR: [
      +      { content: { contains: opts.imagePath } },
      +      { images: { contains: opts.imagePath } },
      +      // images JSON sometimes stored without leading path prefix
      +      { images: { contains: opts.filename } },
      +    ],
      +  }
      +  if (opts.isPublic) where.isPublic = true
      +  if (opts.userId) where.userId = opts.userId
      +
      +  const row = await prisma.note.findFirst({
      +    where,
           select: { id: true },
         })
      -  return !!owned
      +  return !!row
       }
      diff --git a/memento-note/locales/en.json b/memento-note/locales/en.json
      index 8338a5e..6a19ddf 100644
      --- a/memento-note/locales/en.json
      +++ b/memento-note/locales/en.json
      @@ -132,6 +132,11 @@
           "reminderPastError": "Reminder must be in the future",
           "reminderRemoved": "Reminder removed",
           "addImage": "Add image",
      +    "coverNoneHint": "No cover image — pick one from the note or leave empty.",
      +    "coverChoose": "Choose cover",
      +    "coverChange": "Change",
      +    "coverRemove": "Remove cover",
      +    "coverPickLabel": "Cover image",
           "addLink": "Add link",
           "linkAdded": "Link added",
           "linkMetadataFailed": "Could not fetch link metadata",
      diff --git a/memento-note/locales/fr.json b/memento-note/locales/fr.json
      index 738b16b..95eab55 100644
      --- a/memento-note/locales/fr.json
      +++ b/memento-note/locales/fr.json
      @@ -132,6 +132,11 @@
           "reminderPastError": "Le rappel doit être dans le futur",
           "reminderRemoved": "Rappel supprimé",
           "addImage": "Ajouter une image",
      +    "coverNoneHint": "Aucune image de couverture — choisissez une image de la note ou laissez vide.",
      +    "coverChoose": "Choisir une couverture",
      +    "coverChange": "Changer",
      +    "coverRemove": "Retirer la couverture",
      +    "coverPickLabel": "Image de couverture",
           "addLink": "Ajouter un lien",
           "linkAdded": "Lien ajouté",
           "linkMetadataFailed": "Impossible de récupérer les métadonnées du lien",
      diff --git a/memento-note/tests/unit/chunking.test.ts b/memento-note/tests/unit/chunking.test.ts
      index 48210bc..d928ed4 100644
      --- a/memento-note/tests/unit/chunking.test.ts
      +++ b/memento-note/tests/unit/chunking.test.ts
      @@ -7,9 +7,12 @@ describe('US-CHUNK-1 : Chunking sémantique', () => {
           expect(chunks.length).toBe(0)
         })
       
      -  test('note très courte (< 10 chars) → aucun fragment', () => {
      +  test('note très courte (one-liner) → 1 fragment indexable', () => {
      +    // MIN_FRAGMENT_CHARS = 1 : notes courtes doivent être indexées (recherche / Memory Echo)
           const chunks = chunkNoteContent('note1', 'Hello')
      -    expect(chunks.length).toBe(0)
      +    expect(chunks.length).toBe(1)
      +    expect(chunks[0].content).toBe('Hello')
      +    expect(chunks[0].charCount).toBe(5)
         })
       
         test('note courte (< 1000 chars) → 1 seul fragment', () => {