diff --git a/memento-note/app/api/ai/notebook-wizard/route.ts b/memento-note/app/api/ai/notebook-wizard/route.ts new file mode 100644 index 0000000..6648138 --- /dev/null +++ b/memento-note/app/api/ai/notebook-wizard/route.ts @@ -0,0 +1,135 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/auth' +import prisma from '@/lib/prisma' +import { notebookWizardService, type WizardProfile } from '@/lib/ai/services/notebook-wizard.service' +import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements' + +export async function POST(request: NextRequest) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { profile, topic, level, count, language, notebookName, notebookIcon } = await request.json() + + if (!profile || !topic?.trim()) { + return NextResponse.json({ error: 'Profile and topic are required' }, { status: 400 }) + } + + try { + await checkEntitlementOrThrow(session.user.id, 'reformulate') + } catch (err) { + if (err instanceof QuotaExceededError) { + const isTierLocked = err.currentQuota === 0 + return NextResponse.json( + { error: isTierLocked ? 'feature_locked' : 'quota_exceeded', errorKey: isTierLocked ? 'ai.featureLocked' : 'ai.quotaExceeded', upgradeTier: err.upgradeTier }, + { status: 402 }, + ) + } + throw err + } + + const result = await notebookWizardService.generateCarnet( + profile as WizardProfile, + topic, + { level, count, language: language || 'fr' } + ) + + // 1. Create notebook + const notebook = await prisma.notebook.create({ + data: { + name: notebookName || topic, + icon: notebookIcon || '📚', + userId: session.user.id, + order: 0, + }, + }) + + // 2. Create schema + const schema = await prisma.notebookSchema.create({ + data: { notebookId: notebook.id }, + }) + + // 3. Create properties + const defaultProperties = [ + { name: 'Statut', type: 'select', options: ['À rĂ©viser', 'En cours', 'MaĂźtrisĂ©'] }, + { name: 'DifficultĂ©', type: 'select', options: ['Facile', 'Moyen', 'Difficile'] }, + ] + const propertiesToCreate = result.schemaProperties?.length ? result.schemaProperties : defaultProperties + + const propertyMap = new Map() + for (let i = 0; i < propertiesToCreate.length; i++) { + const p = propertiesToCreate[i] + const created = await prisma.notebookProperty.create({ + data: { + schemaId: schema.id, + name: p.name, + type: p.type, + options: p.options ? JSON.stringify(p.options) : null, + position: i, + }, + }) + propertyMap.set(p.name, created.id) + } + + // 4. Create notes + let noteIndex = 0 + for (const note of result.notes) { + const created = await prisma.note.create({ + data: { + title: note.title, + content: note.content, + userId: session.user.id, + notebookId: notebook.id, + type: 'richtext', + order: noteIndex++, + }, + }) + + // 5. Set default property values + if (note.difficulty && propertyMap.has('DifficultĂ©')) { + const difficultyMap: Record = { + facile: 'Facile', moyen: 'Moyen', difficile: 'Difficile', + easy: 'Facile', medium: 'Moyen', hard: 'Difficile', + } + const val = difficultyMap[note.difficulty?.toLowerCase() || ''] || 'Moyen' + await prisma.noteProperty.create({ + data: { noteId: created.id, propertyId: propertyMap.get('DifficultĂ©')!, value: val }, + }).catch(() => {}) + } + + if (propertyMap.has('Statut')) { + await prisma.noteProperty.create({ + data: { noteId: created.id, propertyId: propertyMap.get('Statut')!, value: 'À rĂ©viser' }, + }).catch(() => {}) + } + + // 6. Background embedding + void (async () => { + try { + const { embeddingService } = await import('@/lib/ai/services/embedding.service') + const { upsertNoteEmbedding } = await import('@/lib/embeddings') + const { chunkIndexingService } = await import('@/lib/ai/services/chunk-indexing.service') + const { embedding } = await embeddingService.generateNoteEmbedding(note.title, note.content) + await upsertNoteEmbedding(created.id, embedding) + await chunkIndexingService.indexNote(created.id, note.title, note.content) + } catch (e) { + console.error('[Wizard] Background embedding failed:', e) + } + })() + } + + incrementUsageAsync(session.user.id, 'reformulate') + + return NextResponse.json({ + notebookId: notebook.id, + notebookName: notebook.name, + noteCount: result.notes.length, + noteTitles: result.notes.map(n => n.title), + }) + } catch (error: any) { + console.error('[Notebook Wizard] Error:', error) + return NextResponse.json({ error: error.message || 'Failed to generate notebook' }, { status: 500 }) + } +} diff --git a/memento-note/components/note-editor/note-editor-toolbar.tsx b/memento-note/components/note-editor/note-editor-toolbar.tsx index 3cb2699..473a884 100644 --- a/memento-note/components/note-editor/note-editor-toolbar.tsx +++ b/memento-note/components/note-editor/note-editor-toolbar.tsx @@ -19,7 +19,7 @@ import { Badge } from '@/components/ui/badge' import { X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles, Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal, - Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff + Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff, Printer } from 'lucide-react' import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-dialog' import { NoteShareDialog } from './note-share-dialog' @@ -89,6 +89,124 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme } } + const handleExportPdf = async () => { + const editor = richTextEditorRef?.current?.getEditor() + if (!editor) return + const title = state.title || note.title || 'Note' + + toast.loading(t('richTextEditor.pdfExportLoading') || 'GĂ©nĂ©ration du PDF...', { id: 'pdf-export' }) + + try { + const editorEl = document.querySelector('.ProseMirror') as HTMLElement + if (!editorEl) throw new Error('Editor not found') + + // Clone the editor DOM to process it for print + const clone = editorEl.cloneNode(true) as HTMLElement + + // Remove all action buttons, toolbars, and UI elements + clone.querySelectorAll('button, .drag-handle, [contenteditable="false"], .opacity-0, .group-hover\\:opacity-100').forEach(el => el.remove()) + clone.querySelectorAll('[title]').forEach(el => { + const title = el.getAttribute('title') + if (title && (title.includes('Supprimer') || title.includes('Delete') || title.includes('DĂ©sactiver') || title.includes('Disable') || title.includes('Modifier') || title.includes('Edit'))) { + el.remove() + } + }) + + // Render KaTeX equations properly + const katex = (await import('katex')).default + clone.querySelectorAll('.math-equation-block').forEach(el => { + const latex = el.getAttribute('data-latex') || el.textContent || '' + try { + el.innerHTML = katex.renderToString(latex, { displayMode: true, throwOnError: false }) + } catch {} + }) + clone.querySelectorAll('.inline-math').forEach(el => { + const latex = el.getAttribute('data-latex') || el.textContent || '' + try { + el.innerHTML = katex.renderToString(latex, { displayMode: false, throwOnError: false }) + } catch {} + }) + + // Force show all toggle content + clone.querySelectorAll('[class*="hidden"]').forEach(el => { + (el as HTMLElement).style.display = 'block' + }) + + // Apply callout colors as inline styles (Tailwind classes won't work in print window) + clone.querySelectorAll('[data-callout-type]').forEach(el => { + const type = el.getAttribute('data-callout-type') + const colors: Record = { + info: { bg: '#eff6ff', border: '#93c5fd' }, + warning: { bg: '#fffbeb', border: '#fcd34d' }, + tip: { bg: '#faf5ff', border: '#c4b5fd' }, + success: { bg: '#f0fdf4', border: '#86efac' }, + danger: { bg: '#fef2f2', border: '#fca5a5' }, + } + const c = colors[type || 'info'] || colors.info + const inner = el.querySelector('div') + if (inner) { + (inner as HTMLElement).style.background = c.bg + (inner as HTMLElement).style.borderColor = c.border + } + }) + + const cloneHtml = clone.innerHTML + + const printWindow = window.open('', '_blank') + if (!printWindow) { + toast.error(t('richTextEditor.pdfExportBlocked') || 'Popup bloquĂ©', { id: 'pdf-export' }) + return + } + + printWindow.document.write(`${title} + +

${title}

${cloneHtml}`) + printWindow.document.close() + + setTimeout(() => { + printWindow.focus() + printWindow.print() + toast.success(t('richTextEditor.pdfExportSuccess') || 'PDF prĂȘt !', { id: 'pdf-export' }) + }, 800) + } catch (e: any) { + toast.error(e.message || 'Erreur export PDF', { id: 'pdf-export' }) + } + } + // ── Markdown import ─────────────────────────────────────────────────────── const openMarkdownImport = () => { const input = document.createElement('input') @@ -334,6 +452,10 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme {t('richTextEditor.exportMarkdown')} + + + {t('richTextEditor.exportPdf') || 'Exporter en PDF'} + {t('richTextEditor.importMarkdown')} diff --git a/memento-note/components/rich-text-editor.tsx b/memento-note/components/rich-text-editor.tsx index 4a1d344..fab2e27 100644 --- a/memento-note/components/rich-text-editor.tsx +++ b/memento-note/components/rich-text-editor.tsx @@ -746,11 +746,15 @@ export const RichTextEditor = forwardRef { if (editor && content !== undefined && content !== lastEmittedContent.current) { - editor.commands.setContent(content || '') - lastEmittedContent.current = content || '' - // TipTap #7338 : dir explicite rtl sur listes (pas auto) aprĂšs chargement HTML - requestAnimationFrame(() => { - applyClipRtlDirection(editor, { sourceUrl }) + const html = content || '' + lastEmittedContent.current = html + queueMicrotask(() => { + if (!editor.isDestroyed) { + editor.commands.setContent(html) + requestAnimationFrame(() => { + applyClipRtlDirection(editor, { sourceUrl }) + }) + } }) } if (content !== undefined) { diff --git a/memento-note/components/sidebar.tsx b/memento-note/components/sidebar.tsx index 11e634e..22ea3e4 100644 --- a/memento-note/components/sidebar.tsx +++ b/memento-note/components/sidebar.tsx @@ -49,6 +49,7 @@ import { toast } from 'sonner' import { motion, AnimatePresence } from 'motion/react' import { getNoteDisplayTitle } from '@/lib/note-preview' import { CreateNotebookDialog } from './create-notebook-dialog' +import { AiNotebookWizard } from './wizard/ai-notebook-wizard' import { NotificationPanel } from './notification-panel' import { DropdownMenu, @@ -423,7 +424,7 @@ function SidebarCarnetItem({ onClick={onCarnetClick} 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', + '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' )} > @@ -454,7 +455,15 @@ function SidebarCarnetItem({ )} -
+ {/* Compteur de notes (toujours visible, Ă  droite du nom) */} + {notes.length > 0 && ( + + {notes.length} + + )} + + {/* Boutons d'action en absolu, toujours Ă  droite */} +
{isPinned && ( @@ -481,12 +490,6 @@ function SidebarCarnetItem({ > - - {notes.length > 0 && ( - - {notes.length} - - )}
@@ -586,6 +589,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any }) const { notebooks, trashNotebook, updateNotebookOrderOptimistic, moveNotebookToParent, refreshNotebooks } = useNotebooks() const { open: openSearch } = useSearchModal() const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) + const [showAiWizard, setShowAiWizard] = useState(false) const [createParentId, setCreateParentId] = useState(null) const [renamingNotebook, setRenamingNotebook] = useState(null) const [renameValue, setRenameValue] = useState('') @@ -1360,6 +1364,14 @@ export function Sidebar({ className, user }: { className?: string; user?: any }) > +
- +
+ + +
diff --git a/memento-note/components/wizard/ai-notebook-wizard.tsx b/memento-note/components/wizard/ai-notebook-wizard.tsx new file mode 100644 index 0000000..30636f5 --- /dev/null +++ b/memento-note/components/wizard/ai-notebook-wizard.tsx @@ -0,0 +1,317 @@ +'use client' + +import { useState } 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' + +type WizardProfile = 'student' | 'teacher' | 'engineer' + +interface ProfileOption { + id: WizardProfile + icon: typeof GraduationCap + titleKey: string + descKey: string + placeholderKey: string +} + +const PROFILES: ProfileOption[] = [ + { + id: 'student', + icon: GraduationCap, + titleKey: 'wizard.profileStudent', + descKey: 'wizard.profileStudentDesc', + placeholderKey: 'wizard.topicStudentPlaceholder', + }, + { + id: 'teacher', + icon: BookOpen, + titleKey: 'wizard.profileTeacher', + descKey: 'wizard.profileTeacherDesc', + placeholderKey: 'wizard.topicTeacherPlaceholder', + }, + { + id: 'engineer', + icon: Wrench, + titleKey: 'wizard.profileEngineer', + descKey: 'wizard.profileEngineerDesc', + placeholderKey: 'wizard.topicEngineerPlaceholder', + }, +] + +const LEVELS = ['wizard.levelBeginner', 'wizard.levelIntermediate', 'wizard.levelAdvanced', 'wizard.levelExpert'] + +export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void; onComplete: (notebookId: string) => void }) { + const { t, language } = useLanguage() + const [step, setStep] = useState<0 | 1 | 2>(0) + const [profile, setProfile] = useState(null) + const [topic, setTopic] = useState('') + const [level, setLevel] = useState(1) + const [count, setCount] = useState(6) + const [loading, setLoading] = useState(false) + const [progressMsg, setProgressMsg] = useState('') + const [success, setSuccess] = useState<{ notebookId: string; notebookName: string; noteTitles: string[] } | null>(null) + + const handleSubmit = async () => { + if (!profile || !topic.trim()) return + setLoading(true) + setProgressMsg(t('wizard.progressGenerating') || 'Génération du contenu par l\'IA...') + try { + setProgressMsg(t('wizard.progressCalling') || 'Appel de l\'IA...') + const res = await fetch('/api/ai/notebook-wizard', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + profile, + topic: topic.trim(), + level: t(LEVELS[level]), + count, + language, + notebookName: topic.trim(), + }), + }) + + setProgressMsg(t('wizard.progressParsing') || 'Analyse de la réponse...') + const data = await res.json() + + if (!res.ok) { + if (data.errorKey === 'ai.featureLocked') { + toast.error(t('ai.featureLocked') || 'Cette fonctionnalité nécessite un plan supérieur') + } else if (data.errorKey === 'ai.quotaExceeded') { + toast.error(t('ai.quotaExceeded') || 'Quota IA dépassé') + } else { + toast.error(data.error || 'Erreur') + } + setLoading(false) + return + } + + setProgressMsg(t('wizard.progressCreating') || 'Création du carnet et des notes...') + + setSuccess({ + notebookId: data.notebookId, + notebookName: data.notebookName || topic, + noteTitles: data.noteTitles || [], + }) + setLoading(false) + } catch (e: any) { + toast.error(e.message || 'Erreur') + setLoading(false) + } + } + + const selectedProfile = PROFILES.find(p => p.id === profile) + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+ +

{t('wizard.title')}

+
+ +
+ + {/* Progress bar */} +
+ {[0, 1, 2].map(s => ( +
+
+ {s + 1} +
+ {s < 2 &&
} +
+ ))} +
+ + {/* Content */} +
+ {success ? ( +
+
+ +
+
+

+ {t('wizard.created') || 'Carnet créé :'} {success.notebookName} +

+

+ {success.noteTitles.length} {t('wizard.notesCreated') || 'notes créées'} +

+
+
+ {success.noteTitles.map((title, i) => ( +
+ + {title} +
+ ))} +
+ +
+ ) : loading ? ( +
+ +

+ {progressMsg} +

+
+ {['Toggle', 'Callout', 'Math', 'Colonnes', 'Sommaire', 'Link Preview'].map(tag => ( + + {tag} + + ))} +
+
+ ) : step === 0 ? ( + /* Step 1: Choose profile */ +
+

{t('wizard.chooseProfile')}

+ {PROFILES.map(p => ( + + ))} +
+ ) : step === 1 ? ( + /* Step 2: Topic + options */ +
+
+ + setTopic(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' && topic.trim()) { setStep(2) } }} + placeholder={selectedProfile ? t(selectedProfile.placeholderKey) : ''} + autoFocus + className="w-full rounded-lg border border-border bg-background px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-brand-accent/30" + /> +
+ +
+ +
+ {LEVELS.map((lvlKey, i) => ( + + ))} +
+
+ +
+ + setCount(Number(e.target.value))} + className="w-full accent-brand-accent" + /> +
+ 3 + 12 +
+
+ +
+ + +
+
+ ) : ( + /* Step 3: Confirm */ +
+
+
+ {selectedProfile && } + {selectedProfile && t(selectedProfile.titleKey)} +
+
+
{t('wizard.topic')}{topic}
+
{t('wizard.level')}{t(LEVELS[level])}
+
{t('wizard.noteCount')}{count} {t('wizard.notes')}
+
+
+ +
+

+ {t('wizard.confirmHint') || 'L\'IA va créer un carnet avec des notes riches : encadrés, sections repliables, formules mathématiques, colonnes de comparaison, et plus.'} +

+
+ +
+ + +
+
+ )} +
+
+
+ ) +} diff --git a/memento-note/lib/ai/services/notebook-wizard.service.ts b/memento-note/lib/ai/services/notebook-wizard.service.ts new file mode 100644 index 0000000..81880c1 --- /dev/null +++ b/memento-note/lib/ai/services/notebook-wizard.service.ts @@ -0,0 +1,212 @@ +import { getChatProvider } from '../factory' +import { getSystemConfig } from '@/lib/config' + +export interface GeneratedNote { + title: string + content: string + difficulty?: 'facile' | 'moyen' | 'difficile' +} + +export interface GeneratedCarnet { + notes: GeneratedNote[] + schemaProperties: Array<{ name: string; type: string; options?: string[] }> +} + +export type WizardProfile = 'student' | 'teacher' | 'engineer' + +export class NotebookWizardService { + async generateCarnet( + profile: WizardProfile, + topic: string, + options?: { level?: string; count?: number; language?: string } + ): Promise { + const lang = options?.language || 'fr' + const count = options?.count || 6 + + const prompts = this.buildPrompt(profile, topic, lang, count, options?.level) + const config = await getSystemConfig() + const provider = getChatProvider(config) + const raw = await provider.generateText(prompts) + + return this.parseResponse(raw, profile) + } + + 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 + + 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 : "${topic}" +${level ? `Niveau : ${level}` : ''} +Langue : ${langName} +Nombre de notes Ă  crĂ©er : ${count} + +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. + +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' : ''} + +FORMAT DE SORTIE — JSON UNIQUEMENT : +\`\`\`json +{ + "notes": [ + { + "title": "Titre dĂ©taillĂ© et descriptif", + "difficulty": "facile", + "content": "

Introduction

Plusieurs paragraphes détaillés...

..." + } + ], + "schemaProperties": [ + { "name": "Statut", "type": "select", "options": ["À rĂ©viser", "En cours", "MaĂźtrisĂ©"] }, + { "name": "DifficultĂ©", "type": "select", "options": ["Facile", "Moyen", "Difficile"] } + ] +} +\`\`\` + +BLOCS HTML DISPONIBLES — UTILISE-LES ABONDAMMENT : + +1. **Callout** (encadrĂ© colorĂ©) : +

Définition importante...

+ Types : info, warning, tip, success, danger + +2. **Toggle** (section repliable pour détails) : +

Cliquer pour voir les détails

Contenu détaillé...

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

Concept A...

Concept B...

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

/

/

,

,

    /
      /
    1. , ,
      ,
      
      +
      +IMPORTANT : Chaque note DOIT faire entre 800 et 1500 mots. Sois exhaustif. Développe chaque concept avec des exemples, des explications, et du contexte. N'écris pas de résumés courts.
      +
      +Les "difficulty" doivent varier : mélange facile/moyen/difficile.`
      +  }
      +
      +  private parseResponse(raw: string, profile: WizardProfile): 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') + return { + notes, + schemaProperties: [ + { name: 'Statut', type: 'select', options: ['À rĂ©viser', 'En cours', 'MaĂźtrisĂ©'] }, + { name: 'DifficultĂ©', type: 'select', options: ['Facile', 'Moyen', 'Difficile'] }, + ], + } + } 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 schemaProperties = parsed.schemaProperties || [ + { name: 'Statut', type: 'select', options: ['À rĂ©viser', 'En cours', 'MaĂźtrisĂ©'] }, + { name: 'DifficultĂ©', type: 'select', options: ['Facile', 'Moyen', 'Difficile'] }, + ] + + return { notes, schemaProperties } + } +} + +export const notebookWizardService = new NotebookWizardService() + +/** + * Convertit les dĂ©limiteurs LaTeX ($$...$$ et $...$) en nƓuds TipTap + * pour que les Ă©quations s'affichent correctement au chargement. + */ +function preprocessMathInHtml(html: string): string { + let result = html + + // 1. \[...\] → block math + result = result.replace(/\\\[([\s\S]+?)\\\]/g, (_, latex) => { + const escaped = latex.trim().replace(/"/g, '"') + return `

      ` + }) + + // 2. $$...$$ → block math + result = result.replace(/\$\$([\s\S]+?)\$\$/g, (_, latex) => { + const escaped = latex.trim().replace(/"/g, '"') + return `

      ` + }) + + // 3. \(...\) → inline math + result = result.replace(/\\\(([\s\S]+?)\\\)/g, (_, latex) => { + const escaped = latex.trim().replace(/"/g, '"') + return `${escaped}` + }) + + // 4. $...$ → inline math (only single $ not followed by another $) + result = result.replace(/(? { + const escaped = latex.trim().replace(/"/g, '"') + return `${escaped}` + }) + + // 5. Clean up empty

      tags + result = result.replace(/

      \s*<\/p>/g, '') + + return result +} diff --git a/memento-note/locales/en.json b/memento-note/locales/en.json index 8cd651e..1d27685 100644 --- a/memento-note/locales/en.json +++ b/memento-note/locales/en.json @@ -2451,6 +2451,36 @@ "columnsAdd": "Add a column", "columnsDelete": "Delete columns", "columnsLabel": "columns", + "wizardTitle": "Create a notebook with AI", + "wizardChooseProfile": "What's your profile?", + "wizardProfileStudent": "Student", + "wizardProfileStudentDesc": "AI creates a structured course notebook with summaries, formulas and key takeaways", + "wizardProfileTeacher": "Teacher", + "wizardProfileTeacherDesc": "AI generates a course structure with chapters, exercises and learning objectives", + "wizardProfileEngineer": "Engineer / Professional", + "wizardProfileEngineerDesc": "AI creates organized technical documentation with specs and references", + "wizardTopicStudentPlaceholder": "e.g: Thermodynamics, Calculus, French Revolution...", + "wizardTopicTeacherPlaceholder": "e.g: Math 101, AP Physics, Algorithms...", + "wizardTopicEngineerPlaceholder": "e.g: Microservices architecture, Network security, ISO 27001...", + "wizardTopic": "Topic", + "wizardLevel": "Level", + "wizardLevelBeginner": "Beginner", + "wizardLevelIntermediate": "Intermediate", + "wizardLevelAdvanced": "Advanced", + "wizardLevelExpert": "Expert", + "wizardNoteCount": "Number of notes", + "wizardNotes": "notes", + "wizardConfirmHint": "AI will create a notebook with rich notes: callouts, collapsible sections, math formulas, comparison columns, outlines and links.", + "wizardGenerate": "Generate notebook", + "wizardLoading": "AI is creating your notebook with structured notes...", + "wizardProgressGenerating": "Generating content with AI...", + "wizardProgressCalling": "Calling AI...", + "wizardProgressParsing": "Parsing AI response...", + "wizardProgressCreating": "Creating notebook and notes...", + "wizardSuccess": "Notebook created successfully!", + "wizardCreated": "Notebook created:", + "wizardNotesCreated": "notes created", + "wizardOpenNotebook": "Open notebook", "calloutDelete": "Delete callout", "calloutUnwrap": "Disable callout", "calloutInfo": "Information", @@ -2524,6 +2554,10 @@ "Japonais": "Japanese" }, "exportMarkdown": "Export as Markdown", + "exportPdf": "Export as PDF", + "pdfExportBlocked": "Popup blocked — allow popups to export as PDF", + "pdfExportLoading": "Generating PDF...", + "pdfExportSuccess": "PDF ready!", "importMarkdown": "Import Markdown", "markdownExportSuccess": "Note exported as Markdown", "markdownExportError": "Failed to export note", diff --git a/memento-note/locales/fr.json b/memento-note/locales/fr.json index 01bd3eb..622f8a7 100644 --- a/memento-note/locales/fr.json +++ b/memento-note/locales/fr.json @@ -2455,6 +2455,36 @@ "columnsAdd": "Ajouter une colonne", "columnsDelete": "Supprimer les colonnes", "columnsLabel": "colonnes", + "wizardTitle": "CrĂ©er un carnet avec l'IA", + "wizardChooseProfile": "Quel est votre profil ?", + "wizardProfileStudent": "Étudiant", + "wizardProfileStudentDesc": "L'IA crĂ©e un carnet de cours structurĂ© avec rĂ©sumĂ©s, formules et encadrĂ©s Ă  retenir", + "wizardProfileTeacher": "Professeur", + "wizardProfileTeacherDesc": "L'IA gĂ©nĂšre la structure d'un cours avec chapitres, exercices et objectifs pĂ©dagogiques", + "wizardProfileEngineer": "IngĂ©nieur / Professionnel", + "wizardProfileEngineerDesc": "L'IA crĂ©e une documentation technique organisĂ©e avec spĂ©cifications et rĂ©fĂ©rences", + "wizardTopicStudentPlaceholder": "Ex: Thermodynamique, Calcul diffĂ©rentiel, Histoire de la RĂ©volution...", + "wizardTopicTeacherPlaceholder": "Ex: MathĂ©matiques L1, Physique-Chimie Terminale, Algorithmique...", + "wizardTopicEngineerPlaceholder": "Ex: Architecture microservices, SĂ©curitĂ© rĂ©seau, Norme ISO 27001...", + "wizardTopic": "Sujet", + "wizardLevel": "Niveau", + "wizardLevelBeginner": "DĂ©butant", + "wizardLevelIntermediate": "IntermĂ©diaire", + "wizardLevelAdvanced": "AvancĂ©", + "wizardLevelExpert": "Expert", + "wizardNoteCount": "Nombre de notes", + "wizardNotes": "notes", + "wizardConfirmHint": "L'IA va crĂ©er un carnet avec des notes riches : encadrĂ©s, sections repliables, formules mathĂ©matiques, colonnes de comparaison, sommaires et liens.", + "wizardGenerate": "GĂ©nĂ©rer le carnet", + "wizardLoading": "L'IA crĂ©e votre carnet avec des notes structurĂ©es...", + "wizardProgressGenerating": "GĂ©nĂ©ration du contenu par l'IA...", + "wizardProgressCalling": "Appel de l'IA...", + "wizardProgressParsing": "Analyse de la rĂ©ponse de l'IA...", + "wizardProgressCreating": "CrĂ©ation du carnet et des notes...", + "wizardSuccess": "Carnet créé avec succĂšs !", + "wizardCreated": "Carnet créé :", + "wizardNotesCreated": "notes créées", + "wizardOpenNotebook": "Ouvrir le carnet", "calloutDelete": "Supprimer l'encadrĂ©", "calloutUnwrap": "DĂ©sactiver l'encadrĂ©", "calloutInfo": "Information", @@ -2528,6 +2558,10 @@ "Japonais": "Japonais" }, "exportMarkdown": "Exporter en Markdown", + "exportPdf": "Exporter en PDF", + "pdfExportBlocked": "Popup bloquĂ© — autorisez les popups pour exporter en PDF", + "pdfExportLoading": "GĂ©nĂ©ration du PDF...", + "pdfExportSuccess": "PDF prĂȘt !", "importMarkdown": "Importer un Markdown", "markdownExportSuccess": "Note exportĂ©e en Markdown", "markdownExportError": "Échec de l'export de la note",