diff --git a/docs/user-stories.md b/docs/user-stories.md index 809b292..4a39e59 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -14,11 +14,11 @@ | **US-LIVING-BLOCKS** | Blocs Vivants (Transclusion Bidirectionnelle) | ✅ **LIVRÉ** | `tiptap-unique-id-extension.ts`, `tiptap-live-block-extension.tsx`, `block-picker.tsx`, `app/api/blocks/*`, migration `LiveBlockRef` | | **US-MEMORY-ECHO** | Résonance Sémantique + Embed depuis Echo | ✅ **LIVRÉ** | `memory-echo-section.tsx`, `/api/notes/[id]/live-block-refs`, `/api/blocks/resolve` | | **US-INFO-RÉSEAU** | Panneau Info + Réseau Local | ✅ **LIVRÉ** | `note-network-tab.tsx`, `sync-note-links.ts`, migration `NoteLink`, picker `[[` | -| **US-CLIPPER** | Web Clipper | 🚧 **En cours** | `extension/`, `/api/clip/*`, migration `sourceUrl`, badge panneau Info | +| **US-CLIPPER** | Web Clipper | ✅ **LIVRÉ** | `extension/`, `/api/clip/*`, migration `sourceUrl`, badge panneau Info | | **US-GRAPH** | Graphe de Connaissance Global enrichi | ✅ **LIVRÉ** | `note-graph-view.tsx` — filtres liens, seuil sémantique, focus voisinage, couleurs carnets, double-clic ouverture | | **US-INSIGHTS** | Clusters Sémantiques + Bridge Notes | 🚧 **EN COURS** | clusters en base mais page masquait les résultats périmés — correction affichage | | **US-TEMPORAL** | Prédictions d'accès temporelles | ⏳ À faire | — | -| **US-FLASHCARDS** | Révision IA — Répétition espacée SM-2 | ⏳ À faire | — | +| **US-FLASHCARDS** | Révision IA — Répétition espacée SM-2 | ✅ **LIVRÉ** | `/revision`, `/api/flashcards/*`, SM-2, génération IA depuis l'éditeur | | **US-STRUCTURED-VIEWS** | Vues Structurées (Tableau/Kanban/Galerie) | ⏳ À faire | — | --- diff --git a/memento-note/app/(main)/revision/page.tsx b/memento-note/app/(main)/revision/page.tsx new file mode 100644 index 0000000..d0b91bb --- /dev/null +++ b/memento-note/app/(main)/revision/page.tsx @@ -0,0 +1,16 @@ +'use client' + +import dynamic from 'next/dynamic' + +const RevisionView = dynamic( + () => import('@/components/flashcards/revision-view').then((m) => m.RevisionView), + { ssr: false }, +) + +export default function RevisionPage() { + return ( +
+ +
+ ) +} diff --git a/memento-note/app/api/ai/suggest-charts/route.ts b/memento-note/app/api/ai/suggest-charts/route.ts index c900f6e..8e37ed0 100644 --- a/memento-note/app/api/ai/suggest-charts/route.ts +++ b/memento-note/app/api/ai/suggest-charts/route.ts @@ -157,7 +157,7 @@ Response format (COPY this structure): let parsed: SuggestChartsResponse try { // Clean the response - remove markdown code blocks - let cleanText = text + const cleanText = text .replace(/```json\n?/gi, '') .replace(/```\n?/gi, '') .trim() diff --git a/memento-note/app/api/flashcards/[id]/review/route.ts b/memento-note/app/api/flashcards/[id]/review/route.ts new file mode 100644 index 0000000..cd7389c --- /dev/null +++ b/memento-note/app/api/flashcards/[id]/review/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/auth' +import prisma from '@/lib/prisma' +import { computeSm2Update } from '@/lib/flashcards/sm2' + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: cardId } = await params + const body = await request.json() + const grade = typeof body.grade === 'number' ? body.grade : Number(body.grade) + + if (!Number.isFinite(grade) || grade < 1 || grade > 4) { + return NextResponse.json({ error: 'Grade must be 1–4' }, { status: 400 }) + } + + const card = await prisma.flashcard.findFirst({ + where: { + id: cardId, + deck: { userId: session.user.id }, + }, + }) + if (!card) { + return NextResponse.json({ error: 'Card not found' }, { status: 404 }) + } + + const updated = computeSm2Update(grade, { + easinessFactor: card.easinessFactor, + interval: card.interval, + }) + + const [savedCard] = await prisma.$transaction([ + prisma.flashcard.update({ + where: { id: cardId }, + data: { + easinessFactor: updated.easinessFactor, + interval: updated.interval, + nextReviewAt: updated.nextReviewAt, + }, + }), + prisma.flashcardReview.create({ + data: { cardId, grade: Math.round(grade) }, + }), + ]) + + return NextResponse.json({ + card: { + id: savedCard.id, + interval: savedCard.interval, + easinessFactor: savedCard.easinessFactor, + nextReviewAt: savedCard.nextReviewAt.toISOString(), + }, + }) + } catch (error) { + console.error('[flashcards/[id]/review]', error) + return NextResponse.json({ error: 'Review failed' }, { status: 500 }) + } +} diff --git a/memento-note/app/api/flashcards/decks/[id]/route.ts b/memento-note/app/api/flashcards/decks/[id]/route.ts new file mode 100644 index 0000000..27bfa94 --- /dev/null +++ b/memento-note/app/api/flashcards/decks/[id]/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/auth' +import { getDeckDetail } from '@/lib/flashcards/deck-queries' + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + const deck = await getDeckDetail(session.user.id, id) + if (!deck) { + return NextResponse.json({ error: 'Deck not found' }, { status: 404 }) + } + + const now = new Date() + const dueCount = deck.cards.filter((c) => c.due).length + const masteredCount = deck.cards.filter((c) => c.mastered).length + + return NextResponse.json({ + deck, + stats: { + total: deck.cards.length, + due: dueCount, + mastered: masteredCount, + }, + }) + } catch (error) { + console.error('[flashcards/decks/[id] GET]', error) + return NextResponse.json({ error: 'Failed to load deck' }, { status: 500 }) + } +} diff --git a/memento-note/app/api/flashcards/decks/route.ts b/memento-note/app/api/flashcards/decks/route.ts new file mode 100644 index 0000000..b8a0c1c --- /dev/null +++ b/memento-note/app/api/flashcards/decks/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/auth' +import { listDeckSummaries } from '@/lib/flashcards/deck-queries' +import { getOrCreateDeckForNotebook } from '@/lib/flashcards/deck-utils' + +export async function GET() { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const decks = await listDeckSummaries(session.user.id) + return NextResponse.json({ decks }) + } catch (error) { + console.error('[flashcards/decks GET]', error) + return NextResponse.json({ error: 'Failed to load decks' }, { status: 500 }) + } +} + +export async function POST(request: NextRequest) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const name = typeof body.name === 'string' ? body.name.trim() : '' + if (!name) { + return NextResponse.json({ error: 'Name required' }, { status: 400 }) + } + + const deck = await getOrCreateDeckForNotebook({ + userId: session.user.id, + notebookId: null, + manualName: name, + }) + + return NextResponse.json({ + deck: { + id: deck.id, + name: deck.name, + notebookId: deck.notebookId, + }, + }) + } catch (error) { + console.error('[flashcards/decks POST]', error) + return NextResponse.json({ error: 'Failed to create deck' }, { status: 500 }) + } +} diff --git a/memento-note/app/api/flashcards/generate/route.ts b/memento-note/app/api/flashcards/generate/route.ts new file mode 100644 index 0000000..646363c --- /dev/null +++ b/memento-note/app/api/flashcards/generate/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/auth' +import prisma from '@/lib/prisma' +import { getAISettings } from '@/app/actions/ai-settings' +import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements' +import { hasUserAiConsent } from '@/lib/consent/server-consent' +import { generateFlashcardsFromNote, type FlashcardStyle } from '@/lib/flashcards/generate-flashcards' +import { stripHtmlToText } from '@/lib/flashcards/deck-utils' + +export async function POST(request: NextRequest) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + if (!(await hasUserAiConsent())) { + return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 }) + } + + const userSettings = await getAISettings(session.user.id) + if (userSettings.paragraphRefactor === false) { + return NextResponse.json({ error: 'Feature disabled' }, { status: 403 }) + } + + const body = await request.json() + const noteId = typeof body.noteId === 'string' ? body.noteId : '' + const count = typeof body.count === 'number' ? body.count : 10 + const styleRaw = typeof body.style === 'string' ? body.style : 'qa' + const style: FlashcardStyle = ['qa', 'cloze', 'concept'].includes(styleRaw) + ? (styleRaw as FlashcardStyle) + : 'qa' + + if (!noteId) { + return NextResponse.json({ error: 'noteId required' }, { status: 400 }) + } + + const note = await prisma.note.findFirst({ + where: { id: noteId, userId: session.user.id, trashedAt: null }, + select: { id: true, title: true, content: true, language: true }, + }) + if (!note) { + return NextResponse.json({ error: 'Note not found' }, { status: 404 }) + } + + const textContent = stripHtmlToText(note.content) + if (textContent.length < 80) { + return NextResponse.json({ error: 'Not enough content to generate flashcards' }, { 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', + quotaExceeded: true, + }, { status: 402 }) + } + throw err + } + + const cards = await generateFlashcardsFromNote({ + title: note.title || 'Untitled', + textContent, + count, + style, + language: note.language || undefined, + }) + + incrementUsageAsync(session.user.id, 'reformulate') + + return NextResponse.json({ cards, noteId: note.id, style }) + } catch (error) { + console.error('[flashcards/generate]', error) + const message = error instanceof Error ? error.message : 'Generation failed' + return NextResponse.json({ error: message }, { status: 500 }) + } +} diff --git a/memento-note/app/api/flashcards/save/route.ts b/memento-note/app/api/flashcards/save/route.ts new file mode 100644 index 0000000..f1a49b3 --- /dev/null +++ b/memento-note/app/api/flashcards/save/route.ts @@ -0,0 +1,108 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/auth' +import prisma from '@/lib/prisma' +import { getOrCreateDeckForNotebook } from '@/lib/flashcards/deck-utils' +import type { FlashcardStyle } from '@/lib/flashcards/generate-flashcards' + +interface CardInput { + front: string + back: string + type?: string +} + +export async function POST(request: NextRequest) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const noteId = typeof body.noteId === 'string' ? body.noteId : null + const deckIdInput = typeof body.deckId === 'string' ? body.deckId : null + const cards = Array.isArray(body.cards) ? body.cards as CardInput[] : [] + + if (cards.length === 0) { + return NextResponse.json({ error: 'No cards to save' }, { status: 400 }) + } + + let notebookId: string | null = null + let fallbackDeckName: string | undefined + if (noteId) { + const note = await prisma.note.findFirst({ + where: { id: noteId, userId: session.user.id }, + select: { notebookId: true, title: true }, + }) + if (!note) { + return NextResponse.json({ error: 'Note not found' }, { status: 404 }) + } + notebookId = note.notebookId + if (!notebookId) { + fallbackDeckName = note.title?.trim() || 'General' + } + } + + let deckId = deckIdInput + if (!deckId) { + if (noteId) { + const existingFromNote = await prisma.flashcard.findFirst({ + where: { noteId, deck: { userId: session.user.id } }, + select: { deckId: true }, + }) + if (existingFromNote) { + deckId = existingFromNote.deckId + } + } + if (!deckId) { + const deck = await getOrCreateDeckForNotebook({ + userId: session.user.id, + notebookId, + manualName: fallbackDeckName, + }) + deckId = deck.id + } + } else { + const deck = await prisma.flashcardDeck.findFirst({ + where: { id: deckId, userId: session.user.id }, + }) + if (!deck) { + return NextResponse.json({ error: 'Deck not found' }, { status: 404 }) + } + } + + const sanitized = cards + .map((c) => ({ + front: typeof c.front === 'string' ? c.front.trim().slice(0, 500) : '', + back: typeof c.back === 'string' ? c.back.trim().slice(0, 800) : '', + type: (['qa', 'cloze', 'concept'].includes(c.type || '') ? c.type : 'qa') as FlashcardStyle, + })) + .filter((c) => c.front && c.back) + + if (sanitized.length === 0) { + return NextResponse.json({ error: 'No valid cards' }, { status: 400 }) + } + + await prisma.flashcard.createMany({ + data: sanitized.map((c) => ({ + deckId: deckId!, + noteId, + front: c.front, + back: c.back, + type: c.type, + })), + }) + + await prisma.flashcardDeck.update({ + where: { id: deckId! }, + data: { updatedAt: new Date() }, + }) + + return NextResponse.json({ + deckId, + savedCount: sanitized.length, + }) + } catch (error) { + console.error('[flashcards/save]', error) + return NextResponse.json({ error: 'Failed to save flashcards' }, { status: 500 }) + } +} diff --git a/memento-note/app/api/flashcards/stats/route.ts b/memento-note/app/api/flashcards/stats/route.ts new file mode 100644 index 0000000..a209998 --- /dev/null +++ b/memento-note/app/api/flashcards/stats/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from 'next/server' +import { auth } from '@/auth' +import prisma from '@/lib/prisma' +import { isCardMastered } from '@/lib/flashcards/sm2' + +export async function GET() { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = session.user.id + const now = new Date() + + const [reviews, allCards] = await Promise.all([ + prisma.flashcardReview.findMany({ + where: { card: { deck: { userId } } }, + select: { reviewedAt: true, grade: true }, + orderBy: { reviewedAt: 'desc' }, + take: 500, + }), + prisma.flashcard.findMany({ + where: { deck: { userId } }, + select: { id: true, interval: true, easinessFactor: true, front: true, deck: { select: { name: true } } }, + }), + ]) + + const heatmapMap = new Map() + for (const r of reviews) { + const day = r.reviewedAt.toISOString().slice(0, 10) + heatmapMap.set(day, (heatmapMap.get(day) || 0) + 1) + } + const heatmap = Array.from(heatmapMap.entries()) + .map(([date, count]) => ({ date, count })) + .sort((a, b) => a.date.localeCompare(b.date)) + .slice(-90) + + const totalCards = allCards.length + const masteredCount = allCards.filter((c) => isCardMastered(c.interval)).length + const retentionRate = totalCards > 0 ? Math.round((masteredCount / totalCards) * 100) : 0 + + const weekMs = 7 * 24 * 60 * 60 * 1000 + const retentionByWeek: { week: string; rate: number }[] = [] + for (let i = 7; i >= 0; i--) { + const weekEnd = new Date(now.getTime() - i * weekMs) + const weekStart = new Date(weekEnd.getTime() - weekMs) + const cardsAtWeek = allCards.filter((c) => c.interval >= 7 * (8 - i)) + const masteredAtWeek = cardsAtWeek.filter((c) => isCardMastered(c.interval)).length + const rate = cardsAtWeek.length > 0 + ? Math.round((masteredAtWeek / cardsAtWeek.length) * 100) + : retentionRate + retentionByWeek.push({ + week: weekStart.toISOString().slice(0, 10), + rate, + }) + } + + const difficultCards = [...allCards] + .sort((a, b) => a.easinessFactor - b.easinessFactor) + .slice(0, 5) + .map((c) => ({ + id: c.id, + front: c.front.slice(0, 120), + easinessFactor: c.easinessFactor, + deckName: c.deck.name, + })) + + return NextResponse.json({ + heatmap, + retentionRate, + retentionByWeek, + difficultCards, + totalReviews: reviews.length, + }) + } catch (error) { + console.error('[flashcards/stats]', error) + return NextResponse.json({ error: 'Failed to load stats' }, { status: 500 }) + } +} diff --git a/memento-note/app/api/graph/sync-all/route.ts b/memento-note/app/api/graph/sync-all/route.ts index 447a15a..8b719d4 100644 --- a/memento-note/app/api/graph/sync-all/route.ts +++ b/memento-note/app/api/graph/sync-all/route.ts @@ -51,7 +51,7 @@ export async function POST() { if (wikilinks.length === 0) continue for (const { title, snippet } of wikilinks) { - let targetNote = await prisma.note.findFirst({ + const targetNote = await prisma.note.findFirst({ where: { userId, title: { equals: title, mode: 'insensitive' }, diff --git a/memento-note/components/flashcards/flashcard-generate-dialog.tsx b/memento-note/components/flashcards/flashcard-generate-dialog.tsx new file mode 100644 index 0000000..7964b54 --- /dev/null +++ b/memento-note/components/flashcards/flashcard-generate-dialog.tsx @@ -0,0 +1,221 @@ +'use client' + +import { useState, useCallback } from 'react' +import { GraduationCap, Loader2, Sparkles, X } from 'lucide-react' +import { useLanguage } from '@/lib/i18n' +import { useAiConsent } from '@/components/legal/ai-consent-provider' +import { toast } from 'sonner' +import { cn } from '@/lib/utils' + +export type FlashcardStyle = 'qa' | 'cloze' | 'concept' + +export interface PreviewCard { + front: string + back: string + type: FlashcardStyle +} + +interface FlashcardGenerateDialogProps { + open: boolean + onClose: () => void + noteId: string + noteTitle: string + onSaved?: (deckId: string) => void +} + +export function FlashcardGenerateDialog({ + open, + onClose, + noteId, + noteTitle, + onSaved, +}: FlashcardGenerateDialogProps) { + const { t } = useLanguage() + const { requestAiConsent } = useAiConsent() + const [count, setCount] = useState(10) + const [style, setStyle] = useState('qa') + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [cards, setCards] = useState(null) + + const handleGenerate = useCallback(async () => { + if (!(await requestAiConsent())) return + setLoading(true) + try { + const res = await fetch('/api/flashcards/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ noteId, count, style }), + }) + const data = await res.json() + if (!res.ok) { + if (data.errorKey) { + toast.error(t(data.errorKey) || data.error) + } else { + toast.error(data.error || t('flashcards.generateFailed')) + } + return + } + setCards(data.cards || []) + } catch { + toast.error(t('flashcards.generateFailed')) + } finally { + setLoading(false) + } + }, [count, noteId, requestAiConsent, style, t]) + + const handleSave = useCallback(async () => { + if (!cards?.length) return + setSaving(true) + try { + const res = await fetch('/api/flashcards/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ noteId, cards }), + }) + const data = await res.json() + if (!res.ok) { + toast.error(data.error || t('flashcards.saveFailed')) + return + } + toast.success(t('flashcards.savedCount', { count: data.savedCount })) + onSaved?.(data.deckId) + onClose() + setCards(null) + } catch { + toast.error(t('flashcards.saveFailed')) + } finally { + setSaving(false) + } + }, [cards, noteId, onClose, onSaved, t]) + + const updateCard = (index: number, field: 'front' | 'back', value: string) => { + setCards((prev) => { + if (!prev) return prev + const next = [...prev] + next[index] = { ...next[index], [field]: value } + return next + }) + } + + if (!open) return null + + return ( +
+
e.stopPropagation()} + > +
+
+ +
+

{t('flashcards.generateTitle')}

+

{noteTitle}

+
+
+ +
+ +
+ {!cards ? ( + <> +
+ + setCount(Number(e.target.value))} + className="w-full accent-brand-accent" + /> +
+
+ +
+ {(['qa', 'cloze', 'concept'] as const).map((s) => ( + + ))} +
+
+ + ) : ( +
+

{t('flashcards.previewHint')}

+ {cards.map((card, i) => ( +
+ updateCard(i, 'front', e.target.value)} + className="w-full text-sm font-medium bg-transparent border-b border-border/40 pb-1 outline-none" + placeholder={t('flashcards.frontPlaceholder')} + /> +