diff --git a/memento-note/app/api/ai/notebook-wizard/route.ts b/memento-note/app/api/ai/notebook-wizard/route.ts index d62f670..3a4d48f 100644 --- a/memento-note/app/api/ai/notebook-wizard/route.ts +++ b/memento-note/app/api/ai/notebook-wizard/route.ts @@ -33,6 +33,7 @@ export async function POST(request: NextRequest) { throw err } + // language = UI locale (fallback only). Content language = language of the topic string. const result = await notebookWizardService.generateCarnet( profile as WizardProfile, topic, @@ -47,13 +48,20 @@ export async function POST(request: NextRequest) { || topic.trim().slice(0, 60) ).slice(0, 80) + // Place new notebook at top of manual order (before existing min order) + const minOrder = await prisma.notebook.aggregate({ + where: { userId: session.user.id, trashedAt: null }, + _min: { order: true }, + }) + const nextOrder = (minOrder._min.order ?? 0) - 1 + // 1. Create notebook const notebook = await prisma.notebook.create({ data: { name: resolvedName, icon: notebookIcon || '📚', userId: session.user.id, - order: 0, + order: nextOrder, }, }) @@ -95,6 +103,8 @@ export async function POST(request: NextRequest) { notebookId: notebook.id, type: 'richtext', order: noteIndex++, + autoGenerated: true, + aiProvider: 'notebook-wizard', }, }) diff --git a/memento-note/components/memory-echo-section.tsx b/memento-note/components/memory-echo-section.tsx index 6e75e89..1c2591c 100644 --- a/memento-note/components/memory-echo-section.tsx +++ b/memento-note/components/memory-echo-section.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect, useCallback, type ReactNode } from 'react' +import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react' import { ChevronDown, ChevronUp, Sparkles, Link2, X, Loader2, HelpCircle } from 'lucide-react' import { LinkedNotePreviewDialog } from '@/components/linked-note-preview-dialog' import { Button } from '@/components/ui/button' @@ -12,7 +12,6 @@ import { toast } from 'sonner' import { useNoteEditorContext } from '@/components/note-editor/note-editor-context' import { stripHtmlToPlainText } from '@/lib/text/plain-text' import { detectTextDirection } from '@/lib/clip/rtl-content' -import { SEMANTIC_SIMILARITY_FLOOR_CLIP } from '@/lib/ai/semantic-proximity' interface ConnectionData { noteId: string @@ -106,6 +105,9 @@ export function MemoryEchoSection({ const [embeddingId, setEmbeddingId] = useState(null) const [helpOpen, setHelpOpen] = useState(false) const [previewTarget, setPreviewTarget] = useState(null) + const sectionRef = useRef(null) + const [sectionInView, setSectionInView] = useState(false) + const [cueDismissed, setCueDismissed] = useState(false) useEffect(() => { let cancelled = false @@ -113,6 +115,8 @@ export function MemoryEchoSection({ setConsentRequired(false) setConnections([]) setRetroRefs([]) + setCueDismissed(false) + setSectionInView(false) const load = async () => { try { @@ -154,18 +158,24 @@ export function MemoryEchoSection({ } }, [noteId]) - // Scroll doux vers la section quand une forte connexion apparaît (une fois par note) + // Observe section visibility — never auto-scroll; cue only when off-screen useEffect(() => { - if (isLoading || connections.length === 0) return - const top = connections[0] - if (!top || top.similarity < SEMANTIC_SIMILARITY_FLOOR_CLIP) return - const key = `memory-echo-scroll-${noteId}` - if (sessionStorage.getItem(key)) return - sessionStorage.setItem(key, '1') - requestAnimationFrame(() => { - document.getElementById('memory-echo-section')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) - }) - }, [isLoading, connections, noteId]) + const el = sectionRef.current + if (!el) return + + const observer = new IntersectionObserver( + ([entry]) => { + setSectionInView(entry.isIntersecting && entry.intersectionRatio > 0.08) + }, + { root: null, threshold: [0, 0.08, 0.2, 0.5] }, + ) + observer.observe(el) + return () => observer.disconnect() + }, [noteId, isLoading, connections.length, retroRefs.length, consentRequired, isVisible]) + + const scrollToSection = useCallback(() => { + sectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + }, []) const handleEmbed = useCallback(async (conn: ConnectionData) => { setEmbeddingId(conn.noteId) @@ -251,8 +261,29 @@ export function MemoryEchoSection({ const topConnection = connections[0] const restConnections = connections.slice(1) + // Floating cue: signal activity below without stealing the title viewport + const showBottomCue = + !cueDismissed && + !sectionInView && + (isLoading || hasConnections || hasRetro || consentRequired) + + const cueLabel = isLoading + ? t('memoryEcho.editorSection.bottomCueLoading') + : hasConnections + ? connections.length === 1 + ? t('memoryEcho.editorSection.bottomCueFoundOne') + : t('memoryEcho.editorSection.bottomCueFound', { count: connections.length }) + : hasRetro + ? t('memoryEcho.editorSection.bottomCueRetro', { count: retroRefs.length }) + : t('memoryEcho.editorSection.bottomCueConsent') + return ( -
+ <> +
{isLoading && (
)}
+ + {showBottomCue && ( +
+ + +
+ )} + ) } 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 301c4d7..5756c4b 100644 --- a/memento-note/components/note-editor/note-editor-full-page.tsx +++ b/memento-note/components/note-editor/note-editor-full-page.tsx @@ -23,7 +23,7 @@ import { NoteAttachments } from '@/components/note-attachments' import { DocumentQAOverlay } from '@/components/document-qa-overlay' import { useLanguage } from '@/lib/i18n' import { useAiConsent } from '@/components/legal/ai-consent-provider' -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { WikilinksBacklinksPanel } from '@/components/wikilinks-backlinks-panel' import { MemoryEchoSection } from '@/components/memory-echo-section' @@ -40,6 +40,14 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) { const [attachmentsCount, setAttachmentsCount] = useState(0) const [uploadTrigger, setUploadTrigger] = useState(0) const [comparisonSimilarity, setComparisonSimilarity] = useState() + const scrollRootRef = useRef(null) + + // Always open a note at the top (title visible) — never inherit previous scroll + useEffect(() => { + const el = scrollRootRef.current + if (!el) return + el.scrollTop = 0 + }, [note.id]) const fetchNotesByIds = async (noteIds: string[]) => { const notes = await Promise.all(noteIds.map(async (id) => { @@ -67,7 +75,10 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) { {/* ── outer container ── */}
{/* ── main scrollable column ── */} -
+
{/* TOOLBAR */} setUploadTrigger(v => v + 1)} attachmentsCount={attachmentsCount} /> diff --git a/memento-note/components/note-editor/note-editor-toolbar.tsx b/memento-note/components/note-editor/note-editor-toolbar.tsx index 22f9b2b..a21b056 100644 --- a/memento-note/components/note-editor/note-editor-toolbar.tsx +++ b/memento-note/components/note-editor/note-editor-toolbar.tsx @@ -117,6 +117,9 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme const { state: voiceState, toggle: toggleVoice, isSupported: voiceSupported } = useVoiceTranscription({ onTranscript: handleTranscript, + onError: (message) => { + toast.error(message) + }, }) // ── Markdown export ─────────────────────────────────────────────────────── diff --git a/memento-note/components/sidebar.tsx b/memento-note/components/sidebar.tsx index e9b0e75..c0e8fc8 100644 --- a/memento-note/components/sidebar.tsx +++ b/memento-note/components/sidebar.tsx @@ -48,6 +48,7 @@ import { Notebook, Note } from '@/lib/types' import { toast } from 'sonner' import { motion, AnimatePresence } from 'motion/react' import { getNoteDisplayTitle } from '@/lib/note-preview' +import { listAiCreatedNotebookIds, isRecentlyCreated } from '@/lib/ai-created-highlight' import dynamic from 'next/dynamic' const CreateNotebookDialog = dynamic(() => import('./create-notebook-dialog').then(m => ({ default: m.CreateNotebookDialog })), { ssr: false }) @@ -79,14 +80,20 @@ function NoteLink({ title, isActive, isPinned, + autoGenerated, + isRecent, onClick, }: { title: string isActive: boolean isPinned?: boolean + /** Note créée par l'IA (agents, wizard, …) */ + autoGenerated?: boolean + /** Créée récemment (< 48h) — point discret « nouveau » */ + isRecent?: boolean onClick: () => void }) { - const { language } = useLanguage() + const { language, t } = useLanguage() const slideX = language === 'fa' || language === 'ar' ? 10 : -10 return ( - + {autoGenerated ? ( + + ) : ( + + )} {title} + {autoGenerated && ( + + {t('sidebar.aiBadge') || 'IA'} + + )} + {isRecent && !autoGenerated && ( + + )} {isPinned && } ) @@ -347,6 +378,7 @@ function SidebarCarnetItem({ onDelete, onTogglePin, isPinned, + isAiCreated, children, isDragging, dragHandleProps, @@ -358,7 +390,7 @@ function SidebarCarnetItem({ }: { carnet: { id: string; name: string; initial: string; isPrivate?: boolean } isActive: boolean - notes: { id: string; title: string; isPinned?: boolean }[] + notes: { id: string; title: string; isPinned?: boolean; autoGenerated?: boolean; isRecent?: boolean }[] activeNoteId: string | null onCarnetClick: () => void onNoteClick: (noteId: string, carnetId: string) => void @@ -367,6 +399,8 @@ function SidebarCarnetItem({ onDelete: () => void onTogglePin: () => void isPinned: boolean + /** Carnet créé via wizard IA (repère temporaire) */ + isAiCreated?: boolean children?: React.ReactNode isDragging?: boolean dragHandleProps?: React.HTMLAttributes @@ -436,8 +470,13 @@ function SidebarCarnetItem({ onDoubleClick={(e) => { e.stopPropagation(); onRename() }} className={cn( 'flex-1 flex items-center gap-2.5 px-2 py-1.5 rounded-lg transition-all duration-300 group/item cursor-pointer relative overflow-hidden', - isActive ? 'bg-white dark:bg-white/10 shadow-sm border border-border/40' : 'hover:bg-white/40 dark:hover:bg-white/5' + isActive + ? 'bg-white dark:bg-white/10 shadow-sm border border-border/40' + : isAiCreated + ? 'hover:bg-brand-accent/10 border border-brand-accent/20 bg-brand-accent/[0.06]' + : 'hover:bg-white/40 dark:hover:bg-white/5', )} + title={isAiCreated ? (t('sidebar.aiGeneratedNotebook') || 'Carnet créé avec l\'IA') : undefined} > {isActive && ( )} + {isAiCreated && !isActive && ( + + )}
{carnet.name} + {isAiCreated && ( + + + {t('sidebar.aiBadge') || 'IA'} + + )} {carnet.isPrivate && } {!isActive && hasActiveDescendant && ( @@ -573,6 +621,8 @@ function SidebarCarnetItem({ key={note.id} title={note.title} isPinned={note.isPinned} + autoGenerated={note.autoGenerated} + isRecent={note.isRecent} isActive={activeNoteId === note.id} onClick={() => onNoteClick(note.id, carnet.id)} /> @@ -635,7 +685,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any }) const [isRenaming, setIsRenaming] = useState(false) const [expandedIds, setExpandedIds] = useState>(new Set()) const [pinnedIds, setPinnedIds] = useState>(new Set()) - const [notebookNotes, setNotebookNotes] = useState>({}) + const [notebookNotes, setNotebookNotes] = useState>({}) + const [aiNotebookIds, setAiNotebookIds] = useState>(() => new Set()) const [activeView, setActiveView] = useState('notebooks') const [sortOrder, setSortOrder] = useState('newest') const [showSortMenu, setShowSortMenu] = useState(false) @@ -886,6 +937,18 @@ export function Sidebar({ className, user }: { className?: string; user?: any }) const notebookIdsKey = useMemo(() => notebooks.map(nb => nb.id).sort().join(','), [notebooks]) + // Repères carnets IA (localStorage TTL 7j) + useEffect(() => { + const refresh = () => setAiNotebookIds(listAiCreatedNotebookIds()) + refresh() + window.addEventListener('memento-ai-created-changed', refresh) + window.addEventListener('storage', refresh) + return () => { + window.removeEventListener('memento-ai-created-changed', refresh) + window.removeEventListener('storage', refresh) + } + }, []) + useEffect(() => { if (!notebookIdsKey) return let cancelled = false @@ -897,6 +960,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any }) id: n.id, title: getNoteDisplayTitle(n, t('notes.untitled')), isPinned: n.isPinned, + autoGenerated: Boolean(n.autoGenerated), + isRecent: isRecentlyCreated(n.createdAt, 48), })) return [nb.id, mapped] as const }) @@ -1261,6 +1326,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any }) onDelete={() => setDeletingNotebook(notebook)} onTogglePin={() => togglePin(notebook.id)} isPinned={pinnedIds.has(notebook.id)} + isAiCreated={aiNotebookIds.has(notebook.id)} isDragging={isDragging} level={level} isExpanded={isExpanded} @@ -1273,7 +1339,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any }) ) }) - }, [rootNotebooks, childNotebooks, filteredNotebookIds, currentNotebookId, currentNoteId, notebookNotes, draggedId, dropTarget, dropAction, expandedIds, pinnedIds, toggleExpand, handleCarnetClick, handleNoteClick, handleDragStart, handleDragEnd, handleDropOnNotebook, handleStartRename]) + }, [rootNotebooks, childNotebooks, filteredNotebookIds, currentNotebookId, currentNoteId, notebookNotes, draggedId, dropTarget, dropAction, expandedIds, pinnedIds, aiNotebookIds, toggleExpand, handleCarnetClick, handleNoteClick, handleDragStart, handleDragEnd, handleDropOnNotebook, handleStartRename]) return ( <> @@ -1977,8 +2043,34 @@ export function Sidebar({ className, user }: { className?: string; user?: any }) {showAiWizard && ( setShowAiWizard(false)} - onComplete={(notebookId: string) => { + onComplete={async (notebookId: string) => { + // 1) Marquer IA + expand dans la sidebar + try { + const { markAiCreatedNotebook } = await import('@/lib/ai-created-highlight') + markAiCreatedNotebook(notebookId) + } catch { /* ignore */ } + setAiNotebookIds((prev) => new Set(prev).add(notebookId)) + setExpandedIds((prev) => { + const next = new Set(prev) + next.add(notebookId) + return next + }) + // 2) Recharger la liste des carnets (sinon le carnet n'apparaît qu'après F5) + await refreshNotebooks() + // 3) Forcer le rechargement des notes de ce carnet dans la sidebar + try { + const notes = await getAllNotes(false, notebookId) + const mapped = notes.map((n: Note) => ({ + id: n.id, + title: getNoteDisplayTitle(n, t('notes.untitled')), + isPinned: n.isPinned, + autoGenerated: Boolean(n.autoGenerated), + isRecent: true, + })) + setNotebookNotes((prev) => ({ ...prev, [notebookId]: mapped })) + } catch { /* ignore */ } setShowAiWizard(false) + // 4) Ouvrir le carnet router.push(`/home?notebook=${notebookId}`) }} /> diff --git a/memento-note/components/wizard/ai-notebook-wizard.tsx b/memento-note/components/wizard/ai-notebook-wizard.tsx index 9f301d1..40f4c49 100644 --- a/memento-note/components/wizard/ai-notebook-wizard.tsx +++ b/memento-note/components/wizard/ai-notebook-wizard.tsx @@ -1,10 +1,11 @@ 'use client' -import { useState } from 'react' +import { useState, useRef } from 'react' import { GraduationCap, BookOpen, Wrench, Sparkles, Loader2, X, ArrowRight, ArrowLeft, Check, FileText } from 'lucide-react' import { useLanguage } from '@/lib/i18n' import { cn } from '@/lib/utils' import { toast } from 'sonner' +import { markAiCreatedNotebook } from '@/lib/ai-created-highlight' type WizardProfile = 'student' | 'teacher' | 'engineer' @@ -42,7 +43,7 @@ const PROFILES: ProfileOption[] = [ const LEVELS = ['wizard.levelBeginner', 'wizard.levelIntermediate', 'wizard.levelAdvanced', 'wizard.levelExpert'] -export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void; onComplete: (notebookId: string) => void }) { +export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void; onComplete: (notebookId: string) => void | Promise }) { const { t, language } = useLanguage() const [step, setStep] = useState<0 | 1 | 2>(0) const [profile, setProfile] = useState(null) @@ -52,6 +53,25 @@ export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void; const [loading, setLoading] = useState(false) const [progressMsg, setProgressMsg] = useState('') const [success, setSuccess] = useState<{ notebookId: string; notebookName: string; noteTitles: string[] } | null>(null) + const [opening, setOpening] = useState(false) + const completedRef = useRef(false) + + const finishAndOpen = async (notebookId: string, notebookName: string) => { + if (completedRef.current) return + completedRef.current = true + setOpening(true) + try { + markAiCreatedNotebook(notebookId) + const toastMsg = (t('wizard.createdToast') || 'Carnet « {name} » prêt — notes IA disponibles') + .replace('{name}', notebookName) + toast.success(toastMsg) + await onComplete(notebookId) + } catch (e: any) { + completedRef.current = false + setOpening(false) + toast.error(e?.message || t('general.error') || 'Erreur') + } + } const handleSubmit = async () => { if (!profile || !topic.trim()) return @@ -68,7 +88,6 @@ export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void; level: t(LEVELS[level]), count, language, - // Server generates a short title from the topic; do not force raw prompt as name }), }) @@ -87,14 +106,16 @@ export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void; return } - setProgressMsg(t('wizard.progressCreating') || 'Création du carnet et des notes...') - + const notebookId = data.notebookId as string + const notebookName = (data.notebookName || topic) as string setSuccess({ - notebookId: data.notebookId, - notebookName: data.notebookName || topic, + notebookId, + notebookName, noteTitles: data.noteTitles || [], }) setLoading(false) + // Ouvre tout de suite : refresh sidebar + navigation (plus besoin de F5) + void finishAndOpen(notebookId, notebookName) } catch (e: any) { toast.error(e.message || 'Erreur') setLoading(false) @@ -159,12 +180,19 @@ export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void; ))}
+

+ {t('wizard.autoOpenHint') || 'Ouverture automatique du carnet et mise à jour de la barre latérale…'} +

) : loading ? (
diff --git a/memento-note/hooks/use-voice-transcription.ts b/memento-note/hooks/use-voice-transcription.ts index fbe6857..907bba9 100644 --- a/memento-note/hooks/use-voice-transcription.ts +++ b/memento-note/hooks/use-voice-transcription.ts @@ -47,8 +47,22 @@ export function useVoiceTranscription({ onTranscript, onError, lang = 'fr-FR' }: } rec.onerror = (event: SpeechRecognitionErrorEvent) => { - console.error('[voice] SpeechRecognition error:', event.error) - onError?.(event.error === 'not-allowed' ? 'Microphone non autorisé.' : `Erreur: ${event.error}`) + // "not-allowed" = permission micro refusée — attendu, pas une erreur applicative + // "aborted" / "no-speech" = fin normale / silence — ne pas alarmer + const benign = event.error === 'not-allowed' || event.error === 'aborted' || event.error === 'no-speech' + if (!benign) { + console.warn('[voice] SpeechRecognition error:', event.error) + } + if (event.error === 'not-allowed') { + onError?.('Microphone non autorisé. Autorisez le micro dans le navigateur pour dicter.') + setState('idle') + return + } + if (event.error === 'aborted' || event.error === 'no-speech') { + setState('idle') + return + } + onError?.(`Erreur: ${event.error}`) setState('error') } diff --git a/memento-note/lib/ai-created-highlight.ts b/memento-note/lib/ai-created-highlight.ts new file mode 100644 index 0000000..812af4e --- /dev/null +++ b/memento-note/lib/ai-created-highlight.ts @@ -0,0 +1,59 @@ +/** + * Mise en évidence temporaire des carnets créés via IA (wizard, etc.). + * Les notes ont déjà `autoGenerated` en base ; les carnets n'ont pas ce flag + * → on stocke un repère local (TTL 7 jours) pour les retrouver facilement. + */ + +const STORAGE_KEY = 'memento-ai-created-notebooks' +const TTL_MS = 7 * 24 * 60 * 60 * 1000 + +type Entry = { id: string; at: number } + +function readEntries(): Entry[] { + if (typeof window === 'undefined') return [] + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) as Entry[] + if (!Array.isArray(parsed)) return [] + const now = Date.now() + return parsed.filter((e) => e?.id && typeof e.at === 'number' && now - e.at < TTL_MS) + } catch { + return [] + } +} + +function writeEntries(entries: Entry[]) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(entries.slice(0, 50))) + } catch { + /* quota / private mode */ + } +} + +/** Marque un carnet comme créé via IA (repère sidebar ~7 jours). */ +export function markAiCreatedNotebook(notebookId: string) { + if (!notebookId) return + const now = Date.now() + const next = [{ id: notebookId, at: now }, ...readEntries().filter((e) => e.id !== notebookId)] + writeEntries(next) + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('memento-ai-created-changed')) + } +} + +export function isAiCreatedNotebook(notebookId: string): boolean { + return readEntries().some((e) => e.id === notebookId) +} + +export function listAiCreatedNotebookIds(): Set { + return new Set(readEntries().map((e) => e.id)) +} + +/** Note créée récemment (pour point « nouveau » optionnel). */ +export function isRecentlyCreated(createdAt: Date | string | null | undefined, hours = 48): boolean { + if (!createdAt) return false + const t = typeof createdAt === 'string' ? new Date(createdAt).getTime() : createdAt.getTime() + if (Number.isNaN(t)) return false + return Date.now() - t < hours * 60 * 60 * 1000 +} diff --git a/memento-note/lib/ai/services/notebook-wizard.service.ts b/memento-note/lib/ai/services/notebook-wizard.service.ts index 4aff824..c3d1305 100644 --- a/memento-note/lib/ai/services/notebook-wizard.service.ts +++ b/memento-note/lib/ai/services/notebook-wizard.service.ts @@ -1,11 +1,12 @@ import { getChatProvider } from '../factory' import { getSystemConfig } from '@/lib/config' import { preprocessMathInHtml } from '@/lib/text/math-preprocess' +import { LanguageDetectionService } from './language-detection.service' export interface GeneratedNote { title: string content: string - difficulty?: 'facile' | 'moyen' | 'difficile' + difficulty?: 'facile' | 'moyen' | 'difficile' | 'easy' | 'medium' | 'hard' } export interface GeneratedCarnet { @@ -13,149 +14,355 @@ export interface GeneratedCarnet { notebookName?: string notes: GeneratedNote[] schemaProperties: Array<{ name: string; type: string; options?: string[] }> + /** Language actually used for generation (ISO 639-1). */ + contentLanguage?: string } export type WizardProfile = 'student' | 'teacher' | 'engineer' -/** Word-count targets by level — Expert was failing (timeouts / truncated JSON). */ +const LANG_NAMES: Record = { + fr: 'français', + en: 'English', + fa: 'فارسی', + ar: 'العربية', + de: 'Deutsch', + es: 'Español', + it: 'Italiano', + pt: 'Português', + ru: 'Русский', + zh: '中文', + ja: '日本語', + ko: '한국어', + nl: 'Nederlands', + pl: 'Polski', + hi: 'हिन्दी', +} + +/** Word-count targets by level */ 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)' } + return { min: 400, max: 800, label: 'expert (400–800 words per note)' } } if (/intermédiaire|intermediate|moyen/.test(l)) { - return { min: 250, max: 500, label: 'intermédiaire (250–500 mots/note)' } + return { min: 300, max: 550, label: 'intermediate (300–550 words per note)' } } if (/débutant|beginner|facile|easy/.test(l)) { - return { min: 150, max: 350, label: 'débutant (150–350 mots/note)' } + return { min: 250, max: 450, label: 'beginner (250–450 words per note)' } } - return { min: 200, max: 450, label: 'standard (200–450 mots/note)' } + return { min: 300, max: 550, label: 'standard (300–550 words per note)' } +} + +function countWords(html: string): number { + const plain = html + .replace(/<[^>]+>/g, ' ') + .replace(/&[a-z]+;/gi, ' ') + .replace(/\s+/g, ' ') + .trim() + if (!plain) return 0 + return plain.split(/\s+/).filter(Boolean).length } export class NotebookWizardService { + private languageDetection = new LanguageDetectionService() + + /** + * Langue de sortie = langue du **sujet / requête** (pas l'UI), sauf si + * la requête est trop courte / ambiguë (tinyld confond souvent fr↔it). + * + * Ex. UI=fr + "I want a list of…" → en + * UI=fr + "calcul différentiel" → fr (accents / UI, pas "it" de tinyld) + */ + async resolveOutputLanguage(topic: string, uiLanguage?: string): Promise { + const ui = (uiLanguage || 'fr').slice(0, 2).toLowerCase() + const text = (topic || '').trim() + if (!text) return ui + + const wordCount = text.split(/\s+/).filter(Boolean).length + + // Scripts non latins : fiables même sur peu de mots + if (/[\u0600-\u06FF]/.test(text)) { + if (/\b(است|های|می‌|برای|که|این)\b/.test(text)) return 'fa' + return 'ar' + } + if (/[\u4e00-\u9fff]/.test(text)) return 'zh' + if (/[\u3040-\u30ff]/.test(text)) return 'ja' + if (/[\uac00-\ud7af]/.test(text)) return 'ko' + if (/[\u0400-\u04ff]/.test(text)) return 'ru' + if (/[\u0900-\u097f]/.test(text)) return 'hi' + + // Anglais explicite (phrases courantes) + if (/\b(i want|i need|i'd like|please|make me|list of|how to|for a student|for students|write in english)\b/i.test(text)) { + return 'en' + } + // Français explicite + if (/\b(je veux|j'aimerais|peux-tu|créer|génère|cours de|pour un étudiant|pour les étudiants|en français)\b/i.test(text)) { + return 'fr' + } + + // Accents / digrammes typiquement français → fr (évite "calcul différentiel" → it) + if (/[àâäéèêëïîôùûüçœæ]/i.test(text) || /\b(différentiel|différentielle|mathématiques|géométrie|algèbre|fonction|limite|dérivée)\b/i.test(text)) { + return 'fr' + } + + // Allemand (umlauts / ß) + if (/[äöüß]/i.test(text)) return 'de' + // Espagnol (ñ, ¿, ¡) + if (/[ñ¿¡]/i.test(text)) return 'es' + + // Sujet court (≤ 4 mots) : tinyld est peu fiable (ex. "calcul différentiel" → it @ 5%) + // → on suit l'UI sauf signal anglais fort déjà géré ci-dessus + if (wordCount <= 4) { + return ui + } + + // Texte plus long : détection, mais on ignore un résultat peu crédible + try { + const { language, confidence } = await this.languageDetection.detectLanguage(text) + const code = (language || '').slice(0, 2).toLowerCase() + if (code && code !== 'unknown' && confidence >= 0.75) { + // Romance court : si UI=fr et détecté it/es/pt/ro, rester en fr (faux positifs fréquents) + if (ui === 'fr' && ['it', 'es', 'pt', 'ro'].includes(code) && wordCount < 12) { + return 'fr' + } + return code + } + } catch { + /* fall through */ + } + + return ui + } + + private langName(code: string): string { + return LANG_NAMES[code] || code + } + + private languageRuleBlock(lang: string): string { + const name = this.langName(lang) + return `CRITICAL LANGUAGE RULE (non-negotiable): +- The USER REQUEST language is: ${name} (code: ${lang}). +- The app UI language is IRRELEVANT — do NOT follow UI language. +- Write ALL human-readable text in ${name}: notebookName, note titles, HTML body, callouts, examples, exercises, table headers, difficulty free-text if any. +- JSON keys stay in English (notebookName, notes, title, content, difficulty, schemaProperties). +- Do NOT mix languages. Do NOT answer in French if the request is English (and vice versa). +- Math LaTeX may stay standard (symbols), but surrounding explanations must be in ${name}.` + } + async generateCarnet( profile: WizardProfile, topic: string, options?: { level?: string; count?: number; language?: string } ): Promise { - const lang = options?.language || 'fr' - // Cap count in expert mode to avoid oversized responses + const uiLang = options?.language || 'fr' + const contentLang = await this.resolveOutputLanguage(topic, uiLang) const level = options?.level const isExpert = /expert/i.test(level || '') - const count = Math.min(options?.count || 6, isExpert ? 5 : 10) + // Fewer notes + richer content (quality over stuffing one JSON) + const count = Math.min(options?.count || 6, isExpert ? 5 : 6) + const words = wordTargetsForLevel(level) - const prompts = this.buildPrompt(profile, topic, lang, count, level) const config = await getSystemConfig() const provider = getChatProvider(config) - 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 + + // Phase 1 — outline only (name + titles) + const plan = await this.generatePlan(provider, profile, topic, contentLang, count, level) + const schemaLabels = this.getSchemaLabels(contentLang) + + // Phase 2 — one note at a time (avoids shallow multi-note dumps) + const notes: GeneratedNote[] = [] + for (let i = 0; i < plan.titles.length; i++) { + const title = plan.titles[i] + const difficulty = i % 3 === 0 ? 'facile' : i % 3 === 1 ? 'moyen' : 'difficile' + let note = await this.generateOneNote(provider, { + profile, + topic, + title, + lang: contentLang, + level, + words, + difficulty, + noteIndex: i + 1, + totalNotes: plan.titles.length, + }) + // Retry once if too short + if (countWords(note.content) < Math.max(120, Math.floor(words.min * 0.55))) { + note = await this.generateOneNote(provider, { + profile, + topic, + title, + lang: contentLang, + level, + words, + difficulty, + noteIndex: i + 1, + totalNotes: plan.titles.length, + forceLonger: true, + }) } + notes.push(note) } - const result = this.parseResponse(raw, profile, lang) - if (!result.notebookName?.trim()) { - result.notebookName = this.deriveNotebookName(topic, result.notes, lang) + return { + notebookName: plan.notebookName || this.deriveNotebookName(topic, notes, contentLang), + notes, + schemaProperties: [ + { name: schemaLabels.status, type: 'select', options: [schemaLabels.statusTodo, schemaLabels.statusInProgress, schemaLabels.statusDone] }, + { name: schemaLabels.difficulty, type: 'select', options: [schemaLabels.easy, schemaLabels.medium, schemaLabels.hard] }, + ], + contentLanguage: contentLang, + } + } + + private async generatePlan( + provider: { generateText: (p: string) => Promise }, + profile: WizardProfile, + topic: string, + lang: string, + count: number, + level?: string, + ): Promise<{ notebookName: string; titles: string[] }> { + const prompt = `${this.languageRuleBlock(lang)} + +You are planning a ${profile} notebook on the user request below. +User request / topic: "${topic}" +${level ? `Level: ${level}` : ''} +Produce a short JSON plan only. + +Return ONLY: +\`\`\`json +{ + "notebookName": "3–8 words, short, in ${this.langName(lang)}", + "titles": ["chapter title 1", "chapter title 2", "... exactly ${count} titles"] +} +\`\`\` + +Rules: +- Exactly ${count} titles, progressive learning order. +- Titles in ${this.langName(lang)} only. +- notebookName ≠ full user sentence.` + + const raw = await provider.generateText(prompt) + const parsed = this.parseJsonObject(raw) + const titles = Array.isArray(parsed?.titles) + ? parsed.titles.map((t: unknown) => String(t || '').trim()).filter(Boolean).slice(0, count) + : [] + while (titles.length < count) { + titles.push(lang === 'fr' ? `Chapitre ${titles.length + 1}` : `Chapter ${titles.length + 1}`) + } + return { + notebookName: String(parsed?.notebookName || '').trim().slice(0, 80), + titles, + } + } + + private async generateOneNote( + provider: { generateText: (p: string) => Promise }, + opts: { + profile: WizardProfile + topic: string + title: string + lang: string + level?: string + words: { min: number; max: number; label: string } + difficulty: string + noteIndex: number + totalNotes: number + forceLonger?: boolean + }, + ): Promise { + const { profile, topic, title, lang, level, words, difficulty, noteIndex, totalNotes, forceLonger } = opts + const profileExtra = { + student: `Pedagogical note for a student: worked examples, tip + danger callouts, at least 2 exercises with brief solution hints.`, + teacher: `Lesson plan style: learning objectives, main content, 4–5 exercises, indicative answers.`, + engineer: `Technical documentation: precise definitions, comparison table or specs, practical examples.`, + }[profile] + + const prompt = `${this.languageRuleBlock(lang)} + +You write ONE complete notebook chapter as HTML for a ${profile}. +${profileExtra} + +Overall topic: "${topic}" +Chapter title: "${title}" +Chapter ${noteIndex}/${totalNotes} +${level ? `Level: ${level}` : ''} +Target length: ${words.label} +${forceLonger ? `WARNING: previous draft was TOO SHORT. Write at least ${words.min} words of real explanation — not a summary.` : ''} + +Mandatory structure (all section headings and text in ${this.langName(lang)}): +1. Short intro (why this chapter matters) +2. Core definitions / concepts with

/

+3. At least one worked example (step by step) +4. Callouts: info definition + tip + danger (using HTML blocks below) +5. At least one math equation if the topic is mathematical +6. Closing: key takeaways list +7. For student/teacher: short exercises section + +HTML blocks: +- Callout:

...

+- Math:
(never $$) +- Toggle optional:

title

body

+- table, ul/ol allowed + +Return ONLY: +\`\`\`json +{ + "title": "${title.replace(/"/g, '\\"')}", + "difficulty": "${difficulty}", + "content": "

...

...

..." +} +\`\`\` + +content must be rich HTML, NOT empty, NOT a 2-paragraph summary. +Escape quotes inside JSON properly.` + + const raw = await provider.generateText(prompt) + const parsed = this.parseJsonObject(raw) + const content = preprocessMathInHtml(String(parsed?.content || raw || '

')) + return { + title: String(parsed?.title || title).trim() || title, + content, + difficulty: (parsed?.difficulty as GeneratedNote['difficulty']) || (difficulty as GeneratedNote['difficulty']), + } + } + + private parseJsonObject(raw: string): any { + const jsonMatch = raw.match(/```json\s*([\s\S]+?)\s*```/) + let jsonStr = jsonMatch ? jsonMatch[1] : raw + const start = jsonStr.indexOf('{') + const end = jsonStr.lastIndexOf('}') + if (start >= 0 && end > start) jsonStr = jsonStr.slice(start, end + 1) + try { + return JSON.parse(jsonStr) + } catch { + try { + const fixed = jsonStr + .replace(/\\(?!["\\\/bfnrtu])/g, '\\\\') + .replace(/\n/g, '\\n') + return JSON.parse(fixed) + } catch { + return null + } } - 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, '') + .replace(/^(je veux|i want|i need|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( - profile: WizardProfile, - topic: string, - lang: string, - count: number, - level?: string - ): 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.`, - teacher: `Tu crées la structure d'un cours pour un professeur. Chaque note est un chapitre avec des sections à remplir, des objectifs, et des exercices.`, - engineer: `Tu crées une documentation technique. Le contenu doit être précis, structuré, avec des spécifications et des références.`, - }[profile] - - return `Tu es un expert pédagogue et créateur de contenu de haut niveau. ${profileContext} - -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 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 : -- 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 (pas de markdown hors du bloc) : -\`\`\`json -{ - "notebookName": "Titre court du carnet", - "notes": [ - { - "title": "Titre de chapitre descriptif", - "difficulty": "facile", - "content": "

Introduction

...

..." - } - ], - "schemaProperties": [ - { "name": "${schemaLabels.status}", "type": "select", "options": ["${schemaLabels.statusTodo}", "${schemaLabels.statusInProgress}", "${schemaLabels.statusDone}"] }, - { "name": "${schemaLabels.difficulty}", "type": "select", "options": ["${schemaLabels.easy}", "${schemaLabels.medium}", "${schemaLabels.hard}"] } - ] -} -\`\`\` - -BLOCS HTML DISPONIBLES : - -1. Callout :

...

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

Titre

Contenu

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

/

,

,

    /
      /
    1. , ,
      ,
      
      -
      -Les "difficulty" doivent varier : facile/moyen/difficile.
      -Réponds UNIQUEMENT avec le JSON valide (échappement correct des guillemets dans le HTML).`
      +    return lang === 'fr' ? 'Nouveau carnet' : lang === 'en' ? 'New notebook' : 'Notebook'
         }
       
         private getSchemaLabels(lang: string) {
      @@ -178,74 +385,6 @@ Réponds UNIQUEMENT avec le JSON valide (échappement correct des guillemets dan
           }
           return map[lang] || map.en
         }
      -
      -  private parseResponse(raw: string, profile: WizardProfile, lang?: string): GeneratedCarnet {
      -    const jsonMatch = raw.match(/```json\s*([\s\S]+?)\s*```/)
      -    let jsonStr = jsonMatch ? jsonMatch[1] : raw
      -
      -    // Extract the JSON object boundaries
      -    const start = jsonStr.indexOf('{')
      -    const end = jsonStr.lastIndexOf('}')
      -    if (start >= 0 && end > start) {
      -      jsonStr = jsonStr.slice(start, end + 1)
      -    }
      -
      -    let parsed: any
      -    try {
      -      parsed = JSON.parse(jsonStr)
      -    } catch {
      -      // Fix common AI JSON issues: unescaped backslashes in LaTeX
      -      try {
      -        const fixed = jsonStr
      -          .replace(/\\(?!["\\\/bfnrtu])/g, '\\\\')  // Escape lone backslashes (LaTeX \frac etc.)
      -          .replace(/\n/g, '\\n')  // Fix raw newlines in strings
      -        parsed = JSON.parse(fixed)
      -      } catch {
      -        try {
      -          // Last resort: extract notes manually with regex
      -          const notes: GeneratedNote[] = []
      -          const titleMatches = [...jsonStr.matchAll(/"title"\s*:\s*"([^"]+)"/g)]
      -          const contentMatches = [...jsonStr.matchAll(/"content"\s*:\s*"([\s\S]*?)"(?:,|\s*})/g)]
      -          for (let i = 0; i < titleMatches.length; i++) {
      -            notes.push({
      -              title: titleMatches[i][1],
      -              content: contentMatches[i]?.[1]?.replace(/\\n/g, '\n').replace(/\\"/g, '"') || '

      ', - difficulty: i % 3 === 0 ? 'facile' : i % 3 === 1 ? 'moyen' : 'difficile', - }) - } - if (notes.length === 0) throw new Error('No notes found in response') - const fallbackLabels = this.getSchemaLabels(lang || 'fr') - return { - notes, - schemaProperties: [ - { name: fallbackLabels.status, type: 'select', options: [fallbackLabels.statusTodo, fallbackLabels.statusInProgress, fallbackLabels.statusDone] }, - { name: fallbackLabels.difficulty, type: 'select', options: [fallbackLabels.easy, fallbackLabels.medium, fallbackLabels.hard] }, - ], - } - } catch { - throw new Error('Failed to parse AI response') - } - } - } - - const notes: GeneratedNote[] = (parsed.notes || []).map((n: any) => ({ - title: String(n.title || 'Sans titre'), - content: preprocessMathInHtml(String(n.content || '

      ')), - difficulty: n.difficulty || undefined, - })) - - const defaultLabels = this.getSchemaLabels(lang || 'fr') - const schemaProperties = parsed.schemaProperties || [ - { name: defaultLabels.status, type: 'select', options: [defaultLabels.statusTodo, defaultLabels.statusInProgress, defaultLabels.statusDone] }, - { name: defaultLabels.difficulty, type: 'select', options: [defaultLabels.easy, defaultLabels.medium, defaultLabels.hard] }, - ] - - const notebookName = typeof parsed.notebookName === 'string' - ? parsed.notebookName.trim().slice(0, 80) - : undefined - - return { notebookName, notes, schemaProperties } - } } export const notebookWizardService = new NotebookWizardService() diff --git a/memento-note/locales/en.json b/memento-note/locales/en.json index 6a19ddf..19a29fe 100644 --- a/memento-note/locales/en.json +++ b/memento-note/locales/en.json @@ -65,6 +65,10 @@ "freezePinnedNotebook": "Pin notebook sidebar order", "unfreezePinnedNotebook": "Unpin notebook sidebar order", "newSubNotebook": "New sub-notebook", + "aiBadge": "AI", + "aiGeneratedNote": "AI-generated note", + "aiGeneratedNotebook": "Notebook created with AI — recently", + "recentNote": "Recently created", "notebookEmpty": "Empty", "renameNotebook": "Rename", "sharedNotebookBadge": "· Shared", @@ -846,7 +850,14 @@ "hideAll": "Hide extra connections ({count})", "retroTitle": "Notes that cite this content", "retroDescription": "This passage is quoted in {count} other note(s):", - "consentRequired": "Enable AI processing in Settings → AI to see semantic connections for this note." + "consentRequired": "Enable AI processing in Settings → AI to see semantic connections for this note.", + "bottomCueLoading": "Memory Echo is scanning below…", + "bottomCueFound": "{count} related notes below", + "bottomCueFoundOne": "1 related note below", + "bottomCueRetro": "Cited in {count} note(s) below", + "bottomCueConsent": "AI connections available below", + "bottomCueScroll": "Scroll to Memory Echo", + "bottomCueDismiss": "Dismiss" }, "fusion": { "title": "🔗 Intelligent Fusion", @@ -2733,6 +2744,9 @@ "created": "Notebook created:", "notesCreated": "notes created", "openNotebook": "Open notebook", + "openingNotebook": "Opening…", + "autoOpenHint": "Opening the notebook and refreshing the sidebar…", + "createdToast": "Notebook « {name} » ready — AI notes available", "studyPlanner": "Study Plan", "studyPlannerDesc": "AI creates a revision plan based on spaced repetition.", "examDate": "Exam date", @@ -4335,6 +4349,9 @@ "created": "Notebook created:", "notesCreated": "notes created", "openNotebook": "Open notebook", + "openingNotebook": "Opening…", + "autoOpenHint": "Opening the notebook and refreshing the sidebar…", + "createdToast": "Notebook « {name} » ready — AI notes available", "studyPlanner": "Study Plan", "studyPlannerDesc": "AI creates a revision plan based on spaced repetition.", "examDate": "Exam date", diff --git a/memento-note/locales/fr.json b/memento-note/locales/fr.json index 95eab55..9713397 100644 --- a/memento-note/locales/fr.json +++ b/memento-note/locales/fr.json @@ -65,6 +65,10 @@ "freezePinnedNotebook": "Figer l'état du carnet", "unfreezePinnedNotebook": "Défiger l'état du carnet", "newSubNotebook": "Nouveau sous-carnet", + "aiBadge": "IA", + "aiGeneratedNote": "Note générée par l'IA", + "aiGeneratedNotebook": "Carnet créé avec l'IA — récemment", + "recentNote": "Note récente", "notebookEmpty": "Vide", "renameNotebook": "Renommer", "sharedNotebookBadge": "· partagé", @@ -852,7 +856,14 @@ "hideAll": "Masquer les connexions ({count})", "retroTitle": "Notes qui citent ce contenu", "retroDescription": "Ce passage est repris dans {count} autre(s) note(s) :", - "consentRequired": "Activez le traitement IA dans Paramètres → IA pour voir les connexions sémantiques de cette note." + "consentRequired": "Activez le traitement IA dans Paramètres → IA pour voir les connexions sémantiques de cette note.", + "bottomCueLoading": "Memory Echo analyse en bas…", + "bottomCueFound": "{count} notes liées plus bas", + "bottomCueFoundOne": "1 note liée plus bas", + "bottomCueRetro": "Cité dans {count} note(s) plus bas", + "bottomCueConsent": "Connexions IA disponibles plus bas", + "bottomCueScroll": "Aller à Memory Echo", + "bottomCueDismiss": "Masquer" }, "fusion": { "title": "🔗 Fusion intelligente", @@ -2739,6 +2750,9 @@ "created": "Carnet créé :", "notesCreated": "notes créées", "openNotebook": "Ouvrir le carnet", + "openingNotebook": "Ouverture…", + "autoOpenHint": "Ouverture automatique du carnet et mise à jour de la barre latérale…", + "createdToast": "Carnet « {name} » prêt — notes IA disponibles", "studyPlanner": "Planning de révision", "studyPlannerDesc": "L'IA crée un planning de révision basé sur la répétition espacée.", "examDate": "Date de l'examen", @@ -4341,6 +4355,9 @@ "created": "Carnet créé :", "notesCreated": "notes créées", "openNotebook": "Ouvrir le carnet", + "openingNotebook": "Ouverture…", + "autoOpenHint": "Ouverture automatique du carnet et mise à jour de la barre latérale…", + "createdToast": "Carnet « {name} » prêt — notes IA disponibles", "studyPlanner": "Planning de révision", "studyPlannerDesc": "L'IA crée un planning de révision basé sur la répétition espacée.", "examDate": "Date de l'examen",