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>
168 lines
5.4 KiB
TypeScript
168 lines
5.4 KiB
TypeScript
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)
|
|
}
|