feat(notes): vues structurées tableau/kanban, flashcards et MCP robuste
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:
@@ -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)
|
||||
}
|
||||
22
memento-note/lib/structured-views/preferences.ts
Normal file
22
memento-note/lib/structured-views/preferences.ts
Normal 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)
|
||||
}
|
||||
167
memento-note/lib/structured-views/property-utils.ts
Normal file
167
memento-note/lib/structured-views/property-utils.ts
Normal 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)
|
||||
}
|
||||
68
memento-note/lib/structured-views/schema-serialize.ts
Normal file
68
memento-note/lib/structured-views/schema-serialize.ts
Normal 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
|
||||
}
|
||||
55
memento-note/lib/structured-views/types.ts
Normal file
55
memento-note/lib/structured-views/types.ts
Normal 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
|
||||
}
|
||||
39
memento-note/lib/structured-views/wizard-templates.ts
Normal file
39
memento-note/lib/structured-views/wizard-templates.ts
Normal 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'
|
||||
Reference in New Issue
Block a user