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

@@ -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'