feat(notes): vues structurées tableau/kanban, flashcards et MCP robuste
Some checks failed
CI / Lint, Test & Build (push) Failing after 57s
CI / Deploy production (on server) (push) Has been skipped

Ajoute la base organisable par carnet (schéma, champs partagés, valeurs par note)
avec activation guidée, tableau éditable, kanban et suppression de colonnes.
Corrige le multiselect en vue tableau et enrichit sidebar, grille et i18n FR/EN.
Inclut aussi les améliorations flashcards SM-2, l'audit consentement IA et la
robustesse du serveur MCP (config, validation, rate-limit, métriques).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-05-24 23:03:16 +00:00
parent ecd7e57c2e
commit 0784c94242
63 changed files with 10133 additions and 619 deletions

View File

@@ -5,10 +5,12 @@ export interface DeckSummary {
id: string
name: string
notebookId: string | null
notebookName: string | null
totalCards: number
dueCount: number
masteredCount: number
lastReviewedAt: string | null
nextReviewAt: string | null
createdAt: string
}
@@ -17,6 +19,7 @@ export async function listDeckSummaries(userId: string): Promise<DeckSummary[]>
const decks = await prisma.flashcardDeck.findMany({
where: { userId },
include: {
notebook: { select: { name: true } },
flashcards: {
select: {
id: true,
@@ -36,19 +39,27 @@ export async function listDeckSummaries(userId: string): Promise<DeckSummary[]>
return decks.map((deck) => {
const totalCards = deck.flashcards.length
const dueCount = deck.flashcards.filter((c) => c.nextReviewAt <= now).length
const masteredCount = deck.flashcards.filter((c) => isCardMastered(c.interval)).length
// Maîtrisée = interval >= 7 jours (une semaine de bonne mémorisation)
const masteredCount = deck.flashcards.filter((c) => c.interval >= 7).length
const lastReview = deck.flashcards
.flatMap((c) => c.reviews.map((r) => r.reviewedAt))
.sort((a, b) => b.getTime() - a.getTime())[0]
// Date de la prochaine carte à réviser (la plus proche dans le futur)
const nextReviewAt = deck.flashcards
.map((c) => c.nextReviewAt)
.sort((a, b) => a.getTime() - b.getTime())[0] ?? null
return {
id: deck.id,
name: deck.name,
notebookId: deck.notebookId,
notebookName: deck.notebook?.name ?? null,
totalCards,
dueCount,
masteredCount,
lastReviewedAt: lastReview ? lastReview.toISOString() : null,
nextReviewAt: nextReviewAt ? nextReviewAt.toISOString() : null,
createdAt: deck.createdAt.toISOString(),
}
})

View File

@@ -1,4 +1,5 @@
import prisma from '@/lib/prisma'
import { prisma } from '@/lib/prisma'
import { Prisma } from '@prisma/client'
export async function getOrCreateDeckForNotebook(params: {
userId: string
@@ -28,6 +29,14 @@ export async function getOrCreateDeckForNotebook(params: {
notebookId,
name: notebook.name,
},
}).catch(async (error) => {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
const raced = await prisma.flashcardDeck.findFirst({
where: { userId, notebookId },
})
if (raced) return raced
}
throw error
})
}

View File

@@ -5,11 +5,15 @@ export const NOTES_VIEW_TYPE_COOKIE = 'memento-notes-view-type'
export const NOTES_LAYOUT_STORAGE_KEY = 'memento-notes-layout'
export const NOTES_VIEW_TYPE_STORAGE_KEY = 'memento-notes-view-type'
const LAYOUT_VALUES: NotesLayoutMode[] = ['grid', 'list', 'table']
const LAYOUT_VALUES: NotesLayoutMode[] = ['grid', 'list', 'table', 'kanban', 'gallery']
const VIEW_TYPE_VALUES: NotesViewType[] = ['notes', 'tasks']
export function parseNotesLayoutMode(value: string | undefined | null): NotesLayoutMode {
if (value && (LAYOUT_VALUES as string[]).includes(value)) return value as NotesLayoutMode
if (value && (LAYOUT_VALUES as string[]).includes(value)) {
const mode = value as NotesLayoutMode
if (mode === 'gallery') return 'grid'
return mode
}
return 'list'
}

View File

@@ -10,19 +10,27 @@ const prismaClientSingleton = () => {
})
}
/** Dev hot-reload can keep an old PrismaClient missing newly generated models. */
function needsFreshPrismaClient(client: PrismaClient | undefined): boolean {
if (!client) return true
return typeof (client as PrismaClient & { flashcard?: unknown }).flashcard === 'undefined'
}
declare const globalThis: {
prismaGlobal: ReturnType<typeof prismaClientSingleton>;
} & typeof global;
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
let prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
// Log current model keys to verify availability
if (process.env.NODE_ENV !== 'production') {
if (needsFreshPrismaClient(globalThis.prismaGlobal)) {
prisma = prismaClientSingleton()
}
globalThis.prismaGlobal = prisma
const models = Object.keys(prisma).filter(k => !k.startsWith('_') && !k.startsWith('$'))
console.log('[Prisma] Models loaded:', models.join(', '))
}
export { prisma }
export default prisma
if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma

View File

@@ -0,0 +1,71 @@
import type { NotebookSchemaPayload } from '@/lib/structured-views/types'
export type BootstrapStructuredTarget = 'table' | 'kanban'
export type BootstrapStructuredLabels = {
statusName: string
statusOptions: string[]
}
export type BootstrapStructuredActions = {
getSchema: () => NotebookSchemaPayload | null
enableStructuredMode: () => Promise<NotebookSchemaPayload | null>
addProperty: (name: string, type: string, options?: string[]) => Promise<NotebookSchemaPayload | null>
setKanbanGroupProperty: (propertyId: string | null) => Promise<void>
}
function pickGroupProperty(schema: NotebookSchemaPayload, statusName: string) {
return (
schema.properties.find((p) => p.type === 'select' && p.name === statusName) ??
schema.properties.find((p) => p.type === 'select') ??
null
)
}
/** Active une base organisable avec champs par défaut (Statut) si nécessaire. */
export async function bootstrapStructuredNotebook(
target: BootstrapStructuredTarget,
labels: BootstrapStructuredLabels,
actions: BootstrapStructuredActions,
): Promise<NotebookSchemaPayload> {
let schema = actions.getSchema()
if (!schema) {
schema = await actions.enableStructuredMode()
}
if (!schema) {
throw new Error('enable_failed')
}
const selectProps = schema.properties.filter((p) => p.type === 'select')
const needsDefaultStatus =
target === 'kanban' ? selectProps.length === 0 : schema.properties.length === 0
if (needsDefaultStatus) {
schema =
(await actions.addProperty(labels.statusName, 'select', labels.statusOptions)) ?? schema
}
if (target === 'kanban') {
const groupProp = pickGroupProperty(schema, labels.statusName)
if (!groupProp) {
throw new Error('kanban_needs_select')
}
if (schema.viewSettings.kanbanGroupPropertyId !== groupProp.id) {
await actions.setKanbanGroupProperty(groupProp.id)
schema = {
...schema,
viewSettings: { ...schema.viewSettings, kanbanGroupPropertyId: groupProp.id },
}
}
}
return schema
}
/** Ajoute un champ Statut + colonnes kanban sur une base déjà active. */
export async function ensureKanbanStatusField(
labels: BootstrapStructuredLabels,
actions: BootstrapStructuredActions,
): Promise<NotebookSchemaPayload> {
return bootstrapStructuredNotebook('kanban', labels, actions)
}

View File

@@ -0,0 +1,22 @@
import type { StructuredViewMode } from './types'
const MODES: StructuredViewMode[] = ['list', 'table', 'kanban', 'gallery']
export function structuredViewStorageKey(notebookId: string) {
return `memento-structured-view-${notebookId}`
}
export function parseStructuredViewMode(value: string | null | undefined): StructuredViewMode {
if (value && (MODES as string[]).includes(value)) return value as StructuredViewMode
return 'list'
}
export function getStructuredViewPreference(notebookId: string): StructuredViewMode {
if (typeof window === 'undefined') return 'list'
return parseStructuredViewMode(localStorage.getItem(structuredViewStorageKey(notebookId)))
}
export function setStructuredViewPreference(notebookId: string, mode: StructuredViewMode) {
if (typeof window === 'undefined') return
localStorage.setItem(structuredViewStorageKey(notebookId), mode)
}

View File

@@ -0,0 +1,167 @@
import type {
ColumnFilter,
ColumnSort,
NotePropertyValues,
PropertyType,
SchemaProperty,
} from './types'
export function parsePropertyOptions(raw: string | null | undefined): string[] {
if (!raw) return []
try {
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []
} catch {
return []
}
}
export function serializePropertyValue(type: PropertyType, value: unknown): string | null {
if (value === null || value === undefined || value === '') {
return type === 'checkbox' ? JSON.stringify(false) : null
}
if (type === 'checkbox') {
return JSON.stringify(Boolean(value))
}
if (type === 'number') {
const n = typeof value === 'number' ? value : Number(value)
return Number.isFinite(n) ? JSON.stringify(n) : null
}
if (type === 'multiselect') {
const arr = Array.isArray(value) ? value : []
return JSON.stringify(arr.filter((v) => typeof v === 'string'))
}
return JSON.stringify(String(value))
}
export function parseStoredPropertyValue(type: PropertyType, raw: string | null | undefined): unknown {
if (raw == null || raw === '') {
if (type === 'checkbox') return false
if (type === 'multiselect') return []
return null
}
try {
const parsed = JSON.parse(raw)
if (type === 'checkbox') return Boolean(parsed)
if (type === 'number') return typeof parsed === 'number' ? parsed : Number(parsed)
if (type === 'multiselect') return Array.isArray(parsed) ? parsed : []
return parsed
} catch {
return raw
}
}
export function formatPropertyDisplay(type: PropertyType, value: unknown): string {
if (value == null || value === '') return '—'
if (type === 'checkbox') return value ? '✓' : '—'
if (type === 'multiselect') {
return Array.isArray(value) ? value.join(', ') : String(value)
}
if (type === 'date' && typeof value === 'string') {
const d = new Date(value)
return Number.isNaN(d.getTime()) ? String(value) : d.toLocaleDateString()
}
return String(value)
}
export function comparePropertyValues(
type: PropertyType,
a: unknown,
b: unknown,
direction: 'asc' | 'desc',
): number {
const emptyA = a == null || a === '' || (Array.isArray(a) && a.length === 0)
const emptyB = b == null || b === '' || (Array.isArray(b) && b.length === 0)
if (emptyA && emptyB) return 0
if (emptyA) return direction === 'asc' ? 1 : -1
if (emptyB) return direction === 'asc' ? -1 : 1
let cmp = 0
if (type === 'number') {
cmp = Number(a) - Number(b)
} else if (type === 'date') {
cmp = new Date(String(a)).getTime() - new Date(String(b)).getTime()
} else if (type === 'checkbox') {
cmp = Number(Boolean(a)) - Number(Boolean(b))
} else if (type === 'multiselect') {
cmp = formatPropertyDisplay(type, a).localeCompare(formatPropertyDisplay(type, b))
} else {
cmp = String(a).localeCompare(String(b), undefined, { sensitivity: 'base' })
}
return direction === 'asc' ? cmp : -cmp
}
export function matchesFilter(
type: PropertyType,
value: unknown,
filter: ColumnFilter,
): boolean {
const { operator, value: filterValue } = filter
const empty = value == null || value === '' || (Array.isArray(value) && value.length === 0)
if (operator === 'empty') return empty
if (empty) return false
const haystack = formatPropertyDisplay(type, value).toLowerCase()
const needle = (filterValue ?? '').toLowerCase()
if (operator === 'equals') {
if (type === 'multiselect' && Array.isArray(value)) {
return value.some((v) => String(v).toLowerCase() === needle)
}
return haystack === needle
}
return haystack.includes(needle)
}
export function sortNotesWithProperties<T extends { id: string; title?: string | null; updatedAt: string | Date }>(
notes: T[],
valuesByNote: Record<string, NotePropertyValues>,
sort: ColumnSort,
properties: SchemaProperty[],
): T[] {
const prop = properties.find((p) => p.id === sort.propertyId)
const copy = [...notes]
copy.sort((a, b) => {
if (sort.propertyId === 'title') {
const ta = (a.title ?? '').toLowerCase()
const tb = (b.title ?? '').toLowerCase()
const cmp = ta.localeCompare(tb)
return sort.direction === 'asc' ? cmp : -cmp
}
if (sort.propertyId === 'updatedAt') {
const cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
return sort.direction === 'asc' ? cmp : -cmp
}
if (!prop) return 0
const va = valuesByNote[a.id]?.[prop.id]
const vb = valuesByNote[b.id]?.[prop.id]
return comparePropertyValues(prop.type, va, vb, sort.direction)
})
return copy
}
export function filterNotesWithProperties<T extends { id: string }>(
notes: T[],
valuesByNote: Record<string, NotePropertyValues>,
filters: ColumnFilter[],
properties: SchemaProperty[],
): T[] {
if (filters.length === 0) return notes
return notes.filter((note) => {
const vals = valuesByNote[note.id] ?? {}
return filters.every((filter) => {
if (filter.propertyId === 'title') {
const title = (note as { title?: string | null }).title ?? ''
return matchesFilter('text', title, filter)
}
const prop = properties.find((p) => p.id === filter.propertyId)
if (!prop) return true
return matchesFilter(prop.type, vals[prop.id], filter)
})
})
}
export function isValidPropertyType(value: string): value is PropertyType {
return ['text', 'number', 'date', 'select', 'multiselect', 'checkbox'].includes(value)
}

View File

@@ -0,0 +1,68 @@
import type { NotebookViewSettings, NotebookSchemaPayload, SchemaProperty } from './types'
import { parsePropertyOptions, parseStoredPropertyValue } from './property-utils'
import { isValidPropertyType } from './property-utils'
type RawSchema = {
id: string
notebookId: string
viewSettings: string | null
properties: Array<{
id: string
name: string
type: string
options: string | null
position: number
}>
}
export function parseViewSettings(raw: string | null | undefined): NotebookViewSettings {
if (!raw) return {}
try {
const parsed = JSON.parse(raw) as NotebookViewSettings
return {
kanbanGroupPropertyId: parsed.kanbanGroupPropertyId ?? null,
}
} catch {
return {}
}
}
export function serializeSchema(raw: RawSchema | null): NotebookSchemaPayload | null {
if (!raw) return null
const properties: SchemaProperty[] = raw.properties
.filter((p) => isValidPropertyType(p.type))
.sort((a, b) => a.position - b.position)
.map((p) => ({
id: p.id,
name: p.name,
type: p.type as SchemaProperty['type'],
options: parsePropertyOptions(p.options),
position: p.position,
}))
return {
id: raw.id,
notebookId: raw.notebookId,
viewSettings: parseViewSettings(raw.viewSettings),
properties,
}
}
export function buildNoteValuesMap(
noteIds: string[],
rows: Array<{ noteId: string; propertyId: string; value: string | null; property: { type: string } }>,
): Record<string, Record<string, unknown>> {
const map: Record<string, Record<string, unknown>> = {}
for (const id of noteIds) {
map[id] = {}
}
for (const row of rows) {
if (!map[row.noteId]) map[row.noteId] = {}
if (!isValidPropertyType(row.property.type)) continue
map[row.noteId][row.propertyId] = parseStoredPropertyValue(
row.property.type,
row.value,
)
}
return map
}

View File

@@ -0,0 +1,55 @@
export const PROPERTY_TYPES = [
'text',
'number',
'date',
'select',
'multiselect',
'checkbox',
] as const
export type PropertyType = (typeof PROPERTY_TYPES)[number]
export const MAX_PROPERTIES_PER_NOTEBOOK = 15
export type StructuredViewMode = 'list' | 'table' | 'kanban' | 'gallery'
export type NotebookViewSettings = {
kanbanGroupPropertyId?: string | null
}
export type SchemaProperty = {
id: string
name: string
type: PropertyType
options: string[]
position: number
}
export type NotebookSchemaPayload = {
id: string
notebookId: string
viewSettings: NotebookViewSettings
properties: SchemaProperty[]
}
export type NotePropertyValues = Record<string, unknown>
export type StructuredNotebookData = {
schema: NotebookSchemaPayload | null
noteValues: Record<string, NotePropertyValues>
}
export type ColumnFilterOperator = 'contains' | 'equals' | 'empty'
export type ColumnFilter = {
propertyId: string
operator: ColumnFilterOperator
value?: string
}
export type SortDirection = 'asc' | 'desc'
export type ColumnSort = {
propertyId: 'title' | 'updatedAt' | string
direction: SortDirection
}

View File

@@ -0,0 +1,39 @@
import type { NotesLayoutMode } from '@/components/notes-list-views'
import type { PropertyType } from '@/lib/structured-views/types'
export type WizardGoal = 'tasks' | 'learning' | 'reading' | 'simple'
export type WizardFieldId = 'status' | 'dueDate' | 'level' | 'lastReview' | 'read' | 'source'
export type WizardFieldDef = {
id: WizardFieldId
type: PropertyType
hasOptions?: boolean
}
export const WIZARD_GOALS: WizardGoal[] = ['tasks', 'learning', 'reading', 'simple']
export const WIZARD_FIELDS_BY_GOAL: Record<WizardGoal, WizardFieldDef[]> = {
tasks: [
{ id: 'status', type: 'select', hasOptions: true },
{ id: 'dueDate', type: 'date' },
],
learning: [
{ id: 'level', type: 'select', hasOptions: true },
{ id: 'lastReview', type: 'date' },
],
reading: [
{ id: 'read', type: 'checkbox' },
{ id: 'source', type: 'text' },
],
simple: [],
}
export const WIZARD_DEFAULT_VIEW: Record<WizardGoal, NotesLayoutMode> = {
tasks: 'kanban',
learning: 'gallery',
reading: 'gallery',
simple: 'list',
}
export const KANBAN_GROUP_FIELD_ID: WizardFieldId = 'status'