window.dispatchEvent(new CustomEvent('open-mobile-sidebar'))}
aria-label={t('settings.title')}
>
-
+
{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) => (
-
setResourceMode(mode.id as any)} className={cn("flex flex-col items-center justify-center p-3 rounded-xl border transition-all text-center", resourceMode === mode.id ? 'bg-brand-accent/5 border-brand-accent/30' : 'bg-white border-border hover:bg-slate-50 dark:hover:bg-white/5')}>
+ setResourceMode(mode.id as any)} className={cn("flex flex-col items-center justify-center p-3 rounded-xl border transition-all text-center", resourceMode === mode.id ? 'bg-brand-accent/5 border-brand-accent/30' : 'bg-card dark:bg-white/5 border-border hover:bg-slate-50 dark:hover:bg-white/10')}>
{mode.label}
{mode.sub}
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.'}
+
+
+
setPickerOpen((v) => !v)}
+ className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg border border-border bg-background hover:bg-muted transition-colors shrink-0"
+ >
+
+ {t('notes.coverChoose') || 'Choisir une couverture'}
+
+ {pickerOpen && (
+
{
+ onSetCover(url)
+ setPickerOpen(false)
+ }}
+ onClose={() => setPickerOpen(false)}
+ t={t}
+ />
+ )}
+
+ )
+ }
+
+ if (!hasCover || !coverUrl) return null
+
+ return (
+
+
+
+ {!readOnly && (
+
+ {uniqueCandidates.length > 1 && (
+ setPickerOpen((v) => !v)}
+ className="inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1.5 rounded-lg bg-black/60 text-white hover:bg-black/75 backdrop-blur-sm transition-colors"
+ title={t('notes.coverChoose') || 'Changer la couverture'}
+ >
+
+ {t('notes.coverChange') || 'Changer'}
+
+ )}
+
+
+
+
+ )}
+
+ {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'}
+
+
+ {t('notes.close') || 'Fermer'}
+
+
+
+ {candidates.map((url) => {
+ const isSelected = selected === url
+ return (
+
onSelect(url)}
+ className={cn(
+ 'relative shrink-0 h-16 w-24 rounded-lg overflow-hidden border-2 transition-all',
+ isSelected
+ ? 'border-brand-accent ring-2 ring-brand-accent/40'
+ : dark
+ ? 'border-white/20 hover:border-white/50'
+ : 'border-border hover:border-foreground/30',
+ )}
+ >
+
+ {isSelected && (
+
+
+
+ )}
+
+ )
+ })}
+
+
+ )
+}
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 && (
-
-
-
- )}
+ {/* 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 && (
actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
+ title={state.isDirty ? t('notes.saveNow') : t('notes.noModification')}
+ aria-label={state.isDirty ? t('notes.saveNoteAria') : t('notes.noChangesToSaveAria')}
+ onClick={() => actions.handleSaveInPlace()}
+ disabled={state.isSaving || !state.isDirty}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
- state.showMarkdownPreview
- ? 'bg-foreground text-background border-foreground'
- : 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
+ state.isDirty
+ ? 'bg-foreground text-background border-foreground hover:opacity-80'
+ : 'border-black/20 dark:border-white/20 text-foreground/40 cursor-not-allowed'
)}
>
-
+ {state.isSaving ? : }
)}
- {state.isMarkdown && !readOnly && (
-
- {isConverting ? : }
-
- )}
-
-
{ actions.setAiOpen(!state.aiOpen); actions.setInfoOpen(false) }}
- className={cn(
- 'p-1.5 rounded-full border transition-all duration-300',
- state.aiOpen
- ? 'bg-foreground text-background border-foreground'
- : 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
- )}
- >
-
-
-
-
{
- const title = note.title || ''
- const summary = state.content?.replace(/<[^>]*>/g, '').slice(0, 200) || ''
- const seed = title ? `${title}. ${summary}` : summary
- if (!seed.trim()) return
- window.open(`/brainstorm?seed=${encodeURIComponent(seed.slice(0, 300))}&sourceNoteId=${note.id}`, '_self')
- }}
- className="p-1.5 rounded-full border border-brand-accent/30 dark:border-brand-accent/50 text-brand-accent hover:bg-brand-accent/10 dark:hover:bg-brand-accent/20 transition-all"
- >
-
-
-
- {!readOnly && (
-
-
setShowEduMenu(v => !v)}
- className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
- >
-
-
- {showEduMenu && (
- <>
-
setShowEduMenu(false)} />
-
-
{ setShowEduMenu(false); setFlashcardsOpen(true) }}
- className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted transition-colors text-left"
- >
-
-
-
-
-
{t('flashcards.toolbarGenerate')}
-
{t('flashcards.toolbarGenerateHint') || 'Révision espacée SM-2'}
-
-
-
{ setShowEduMenu(false); handleGenerateExercises() }}
- disabled={generatingExercises}
- className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted transition-colors text-left border-t border-border/30"
- >
-
- {generatingExercises ?
:
}
-
-
-
{t('richTextEditor.generateExercises') || 'Générer des exercices'}
-
{t('richTextEditor.generateExercisesHint') || '5 exercices + corrigés'}
-
-
-
- >
- )}
-
- )}
-
- {!readOnly && voiceSupported && (
-
- {voiceState === 'listening' ? : }
-
- )}
-
- {!readOnly && onToggleAttachments && (
-
-
- {(attachmentsCount ?? 0) > 0 && (
-
- {attachmentsCount}
-
- )}
-
- )}
-
- {!readOnly && (
-
- actions.handleSaveInPlace()}
- disabled={state.isSaving || !state.isDirty}
- className={cn(
- 'p-1.5 rounded-full border transition-all duration-300',
- state.isDirty
- ? 'bg-foreground text-background border-foreground hover:opacity-80'
- : 'border-black/20 dark:border-white/20 text-foreground/40 cursor-not-allowed'
- )}
- >
- {state.isSaving ? : }
-
-
- )}
-
+ {/* 3. Publish */}
{!readOnly && (
@@ -904,6 +765,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
)}
+ {/* 4. Share */}
{!readOnly && (
)}
+ {/* 5. AI assistant */}
+ { actions.setAiOpen(!state.aiOpen); actions.setInfoOpen(false) }}
+ className={cn(
+ 'p-1.5 rounded-full border transition-all duration-300',
+ state.aiOpen
+ ? 'bg-foreground text-background border-foreground'
+ : 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
+ )}
+ >
+
+
+
+ {/* 6. Brainstorm */}
+ {
+ const title = note.title || ''
+ const summary = state.content?.replace(/<[^>]*>/g, '').slice(0, 200) || ''
+ const seed = title ? `${title}. ${summary}` : summary
+ if (!seed.trim()) return
+ window.open(`/brainstorm?seed=${encodeURIComponent(seed.slice(0, 300))}&sourceNoteId=${note.id}`, '_self')
+ }}
+ className="p-1.5 rounded-full border border-brand-accent/30 dark:border-brand-accent/50 text-brand-accent hover:bg-brand-accent/10 dark:hover:bg-brand-accent/20 transition-all"
+ >
+
+
+
+ {/* 7. Education (flashcards / exercises) */}
+ {!readOnly && (
+
+
setShowEduMenu(v => !v)}
+ className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
+ >
+
+
+ {showEduMenu && (
+ <>
+
setShowEduMenu(false)} />
+
+
{ setShowEduMenu(false); setFlashcardsOpen(true) }}
+ className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted transition-colors text-left"
+ >
+
+
+
+
+
{t('flashcards.toolbarGenerate')}
+
{t('flashcards.toolbarGenerateHint') || 'Révision espacée SM-2'}
+
+
+
{ setShowEduMenu(false); handleGenerateExercises() }}
+ disabled={generatingExercises}
+ className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted transition-colors text-left border-t border-border/30"
+ >
+
+ {generatingExercises ?
:
}
+
+
+
{t('richTextEditor.generateExercises') || 'Générer des exercices'}
+
{t('richTextEditor.generateExercisesHint') || '5 exercices + corrigés'}
+
+
+
+ >
+ )}
+
+ )}
+
+ {/* 8. Voice / attachments / markdown tools */}
+ {!readOnly && voiceSupported && (
+
+ {voiceState === 'listening' ? : }
+
+ )}
+
+ {!readOnly && onToggleAttachments && (
+
+
+ {(attachmentsCount ?? 0) > 0 && (
+
+ {attachmentsCount}
+
+ )}
+
+ )}
+
+ {state.isMarkdown && !readOnly && (
+
actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
+ className={cn(
+ 'p-1.5 rounded-full border transition-all duration-300',
+ state.showMarkdownPreview
+ ? 'bg-foreground text-background border-foreground'
+ : 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
+ )}
+ >
+
+
+ )}
+
+ {state.isMarkdown && !readOnly && (
+
+ {isConverting ? : }
+
+ )}
+
+ {/* 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 && (
<>
- editor.chain().focus().updateAttributes('image', { width: '25%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">25%
- editor.chain().focus().updateAttributes('image', { width: '50%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">50%
- editor.chain().focus().updateAttributes('image', { width: '100%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">100%
+ editor.chain().focus().updateAttributes('image', { width: '25%' }).run()}
+ className="notion-bubble-btn rounded-md text-xs font-medium px-1"
+ title="25%"
+ >
+ 25%
+
+ editor.chain().focus().updateAttributes('image', { width: '50%' }).run()}
+ className="notion-bubble-btn rounded-md text-xs font-medium px-1"
+ title="50%"
+ >
+ 50%
+
+ editor.chain().focus().updateAttributes('image', { width: '100%' }).run()}
+ className="notion-bubble-btn rounded-md text-xs font-medium px-1"
+ title="100%"
+ >
+ 100%
+
+ {
+ const { selection } = editor.state
+ if (selection instanceof NodeSelection && selection.node.type.name === 'image') {
+ editor.chain().focus().deleteSelection().run()
+ } else if (editor.isActive('image')) {
+ // Fallback: delete the nearest selected image node
+ editor.chain().focus().deleteSelection().run()
+ }
+ }}
+ className="notion-bubble-btn rounded-md text-red-600 dark:text-red-400"
+ title={t('richTextEditor.deleteImage') || t('common.delete') || 'Supprimer l\'image'}
+ aria-label={t('richTextEditor.deleteImage') || 'Supprimer l\'image'}
+ >
+
+
>
)}
{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é) :
-
- Types : info, warning, tip, success, danger
+1. Callout : (info|warning|tip|success|danger)
+2. Toggle :
+3. Math :
— JAMAIS $$
+4. Colonnes : ...
+5. Sommaire :
+6. HTML : /, ,
//, , ,
-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) :
-
-
-5. **Sommaire** (début de note) :
-
-
-6. HTML standard : //, ,
//, , ,
-
-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', () => {