diff --git a/memento-note/app/api/notebooks/csv/route.ts b/memento-note/app/api/notebooks/csv/route.ts new file mode 100644 index 0000000..aa90eb6 --- /dev/null +++ b/memento-note/app/api/notebooks/csv/route.ts @@ -0,0 +1,202 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/auth' +import prisma from '@/lib/prisma' + +function escapeCSV(value: string): string { + if (!value) return '' + if (value.includes(',') || value.includes('"') || value.includes('\n')) { + return `"${value.replace(/"/g, '""')}"` + } + return value +} + +export async function GET(request: NextRequest) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const notebookId = request.nextUrl.searchParams.get('notebookId') + if (!notebookId) { + return NextResponse.json({ error: 'notebookId required' }, { status: 400 }) + } + + const notebook = await prisma.notebook.findFirst({ + where: { id: notebookId, userId: session.user.id }, + select: { name: true }, + }) + if (!notebook) { + return NextResponse.json({ error: 'Notebook not found' }, { status: 404 }) + } + + const schema = await prisma.notebookSchema.findUnique({ + where: { notebookId }, + include: { properties: { orderBy: { position: 'asc' } } }, + }) + + const notes = await prisma.note.findMany({ + where: { notebookId, trashedAt: null, userId: session.user.id }, + include: { properties: true }, + orderBy: { order: 'asc' }, + }) + + const propMap = new Map() + if (schema?.properties) { + schema.properties.forEach(p => propMap.set(p.id, p.name)) + } + + const headers = ['Titre', ...Array.from(propMap.values()), 'Créé le', 'Modifié le'] + const rows: string[] = [headers.map(escapeCSV).join(',')] + + for (const note of notes) { + const noteProps = new Map() + for (const np of note.properties) { + let val = np.value || '' + try { val = JSON.parse(val); if (Array.isArray(val)) val = val.join(', '); if (typeof val === 'object') val = String(val) } catch {} + noteProps.set(np.propertyId, String(val)) + } + + const row = [ + escapeCSV(note.title || ''), + ...Array.from(propMap.keys()).map(propId => escapeCSV(noteProps.get(propId) || '')), + escapeCSV(note.createdAt.toISOString().slice(0, 10)), + escapeCSV(note.updatedAt.toISOString().slice(0, 10)), + ] + rows.push(row.join(',')) + } + + const csv = '\uFEFF' + rows.join('\n') + const filename = `${notebook.name.replace(/[^a-z0-9]/gi, '_')}.csv` + + return new NextResponse(csv, { + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"`, + }, + }) + } catch (error: any) { + console.error('[CSV Export] Error:', error) + return NextResponse.json({ error: error.message }, { 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 { notebookId, csvData } = await request.json() + if (!notebookId || !csvData) { + return NextResponse.json({ error: 'notebookId and csvData required' }, { status: 400 }) + } + + // Parse CSV + const lines = csvData.trim().split('\n') + if (lines.length < 2) { + return NextResponse.json({ error: 'CSV must have at least a header and one row' }, { status: 400 }) + } + + const parseCSVLine = (line: string): string[] => { + const result: string[] = [] + let current = '' + let inQuotes = false + for (let i = 0; i < line.length; i++) { + const char = line[i] + if (char === '"') { + if (inQuotes && line[i + 1] === '"') { current += '"'; i++ } + else inQuotes = !inQuotes + } else if (char === ',' && !inQuotes) { + result.push(current.trim()) + current = '' + } else { + current += char + } + } + result.push(current.trim()) + return result + } + + const headers = parseCSVLine(lines[0].replace(/^\uFEFF/, '')) + const titleIdx = headers.findIndex(h => h.toLowerCase().includes('titre') || h.toLowerCase() === 'title') + const dataRows = lines.slice(1).filter(l => l.trim()) + + // Get or create schema + let schema = await prisma.notebookSchema.findUnique({ where: { notebookId } }) + if (!schema) { + schema = await prisma.notebookSchema.create({ data: { notebookId } }) + } + + // Map headers to property IDs (create properties for non-title columns) + const headerToPropId = new Map() + for (let i = 0; i < headers.length; i++) { + if (i === titleIdx) continue + const headerName = headers[i] + if (headerName.toLowerCase().includes('créé') || headerName.toLowerCase().includes('modifié') || + headerName.toLowerCase().includes('created') || headerName.toLowerCase().includes('modified')) continue + + const existing = await prisma.notebookProperty.findFirst({ + where: { schemaId: schema.id, name: headerName }, + }) + if (existing) { + headerToPropId.set(i, existing.id) + } else { + const prop = await prisma.notebookProperty.create({ + data: { + schemaId: schema.id, + name: headerName, + type: 'text', + position: await prisma.notebookProperty.count({ where: { schemaId: schema.id } }), + }, + }) + headerToPropId.set(i, prop.id) + } + } + + // Get max order + const maxOrderNote = await prisma.note.findFirst({ + where: { notebookId }, + orderBy: { order: 'desc' }, + select: { order: true }, + }) + let nextOrder = (maxOrderNote?.order ?? 0) + 1 + + let created = 0 + for (const line of dataRows) { + const cells = parseCSVLine(line) + const title = titleIdx >= 0 ? cells[titleIdx] : cells[0] || 'Sans titre' + + const note = await prisma.note.create({ + data: { + title, + content: '

', + userId: session.user.id, + notebookId, + order: nextOrder++, + }, + }) + + for (const [colIdx, propId] of headerToPropId) { + const value = cells[colIdx] + if (value) { + await prisma.noteProperty.create({ + data: { + noteId: note.id, + propertyId: propId, + value: JSON.stringify(value), + }, + }).catch(() => {}) + } + } + + created++ + } + + return NextResponse.json({ success: true, created }) + } catch (error: any) { + console.error('[CSV Import] Error:', error) + return NextResponse.json({ error: error.message }, { status: 500 }) + } +} diff --git a/memento-note/components/home-client.tsx b/memento-note/components/home-client.tsx index d1a68cc..dddaebe 100644 --- a/memento-note/components/home-client.tsx +++ b/memento-note/components/home-client.tsx @@ -20,7 +20,7 @@ import { import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast' import { Button } from '@/components/ui/button' -import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays, Wand2 } from 'lucide-react' +import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays, Wand2, Download, Upload } from 'lucide-react' import { emitNoteChange } from '@/lib/note-change-sync' import { useReminderCheck } from '@/hooks/use-reminder-check' import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion' @@ -129,7 +129,7 @@ export function HomeClient({ const [autoLabelOpen, setAutoLabelOpen] = useState(false) const [summaryDialogOpen, setSummaryDialogOpen] = useState(false) const [createSubNotebookOpen, setCreateSubNotebookOpen] = useState(false) - const [organizeNotebookOpen, setOrganizeNotebookOpen] = useState(false) + const [organizeNotebookOpen, setOrganizeNotebookOpen] = useState(false) // kept for compat — old dialog unused const [selectedTagIds, setSelectedTagIds] = useState([]) const [isTagsExpanded, setIsTagsExpanded] = useState(false) const [tagSearchQuery, setTagSearchQuery] = useState('') @@ -139,6 +139,35 @@ export function HomeClient({ const [showStudyPlanner, setShowStudyPlanner] = useState(false) const [showOrganizer, setShowOrganizer] = useState(false) + const handleExportCSV = useCallback(() => { + if (!searchParams.get('notebook')) return + window.open(`/api/notebooks/csv?notebookId=${searchParams.get('notebook')}`, '_blank') + }, [searchParams]) + + const handleImportCSV = useCallback(() => { + const input = document.createElement('input') + input.type = 'file' + input.accept = '.csv' + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0] + if (!file) return + const text = await file.text() + const res = await fetch(`/api/notebooks/csv?notebookId=${searchParams.get('notebook')}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ notebookId: searchParams.get('notebook'), csvData: text }), + }) + const data = await res.json() + if (res.ok) { + toast.success(`${data.created} notes importées !`) + window.location.reload() + } else { + toast.error(data.error || 'Erreur') + } + } + input.click() + }, [searchParams]) + const notebookFilter = searchParams.get('notebook') const schemaHook = useNotebookSchema(notebookFilter) const structuredModeActive = Boolean(notebookFilter && schemaHook.schema) @@ -927,17 +956,6 @@ export function HomeClient({ {t('notes.reorganize')} )} - - {currentNotebook && initialSettings.aiAssistantEnabled && ( - - )}
@@ -1026,45 +1044,49 @@ export function HomeClient({ )} - {searchParams.get('notebook') && ( - + + + + )} {searchParams.get('notebook') && ( - - )} - {searchParams.get('notebook') && ( - +
+ + +
)}