# Technical Specifications - Notebooks & Labels Contextuels

**Project:** Keep - Notebooks & Labels Contextuels
**Date:** 2026-01-11
**Status:** DRAFT
**Author:** Winston (Architect)
**Based on:** Architecture Decision Document (validated 2026-01-11)

---

## Table of Contents

1. [Component: NotebooksContext](#1-component-notebooks-context)
2. [Component: NotebooksSidebar](#2-component-notebookssidebar)
3. [Service: ContextualAutoTagService](#3-service-contextualautotagservice)
4. [Service: NotebookSuggestionService](#4-service-notebooksuggestionservice)
5. [Migration: Database Migration Script](#5-migration-database-migration-script)
6. [Server Actions: Notebooks CRUD](#6-server-actions-notebooks-crud)

---

## 1. Component: NotebooksContext

**Purpose:** Global state management for notebooks, labels, and current selection
**Type:** React Context Provider
**Location:** `keep-notes/app/context/notebooks-context.tsx`

### 1.1 TypeScript Interface

```typescript
// app/context/notebooks-context.tsx

import type { Notebook, Label, Note } from '@/lib/types'

/**
 * Public interface for notebooks context
 */
export interface NotebooksContextValue {
  // ===== STATE =====
  /** All notebooks for the current user */
  notebooks: Notebook[]

  /** Currently selected notebook (null = Notes générales / Inbox) */
  currentNotebook: Notebook | null

  /** Labels for the currently selected notebook */
  currentLabels: Label[]

  /** Loading state for initial data fetch */
  isLoading: boolean

  /** Error state */
  error: string | null

  // ===== ACTIONS: NOTEBOOKS =====

  /**
   * Create a new notebook with optimistic UI
   * @param data - Notebook creation data
   * @returns Promise resolving to created notebook
   */
  createNotebookOptimistic: (data: CreateNotebookInput) => Promise<Notebook>

  /**
   * Update an existing notebook
   * @param notebookId - ID of notebook to update
   * @param data - Partial update data
   */
  updateNotebook: (notebookId: string, data: UpdateNotebookInput) => Promise<void>

  /**
   * Delete a notebook (notes become general)
   * @param notebookId - ID of notebook to delete
   */
  deleteNotebook: (notebookId: string) => Promise<void>

  /**
   * Update the order of notebooks (drag & drop result)
   * @param notebookIds - Ordered array of notebook IDs
   */
  updateNotebookOrderOptimistic: (notebookIds: string[]) => Promise<void>

  /**
   * Set the current notebook (navigation)
   * @param notebook - Notebook to select (null = general notes)
   */
  setCurrentNotebook: (notebook: Notebook | null) => void

  // ===== ACTIONS: LABELS =====

  /**
   * Create a label in the current notebook
   * @param data - Label creation data
   */
  createLabel: (data: CreateLabelInput) => Promise<Label>

  /**
   * Update a label
   * @param labelId - ID of label to update
   * @param data - Partial update data
   */
  updateLabel: (labelId: string, data: UpdateLabelInput) => Promise<void>

  /**
   * Delete a label
   * @param labelId - ID of label to delete
   */
  deleteLabel: (labelId: string) => Promise<void>

  // ===== ACTIONS: NOTES =====

  /**
   * Move a note to a notebook (or to general notes)
   * @param noteId - ID of note to move
   * @param notebookId - Target notebook ID (null = general notes)
   */
  moveNoteToNotebookOptimistic: (noteId: string, notebookId: string | null) => Promise<void>

  /**
   * Attach a label to a note
   * @param noteId - ID of note
   * @param labelId - ID of label to attach
   */
  attachLabelToNote: (noteId: string, labelId: string) => Promise<void>

  /**
   * Detach a label from a note
   * @param noteId - ID of note
   * @param labelId - ID of label to detach
   */
  detachLabelFromNote: (noteId: string, labelId: string) => Promise<void>

  // ===== ACTIONS: AI =====

  /**
   * Suggest a notebook for a note (IA1)
   * @param noteContent - Content of the note
   * @returns Suggested notebook or null
   */
  suggestNotebookForNote: (noteContent: string) => Promise<Notebook | null>

  /**
   * Suggest labels for a note in current notebook (IA2)
   * @param noteContent - Content of the note
   * @returns Array of suggested label names
   */
  suggestLabelsForNote: (noteContent: string) => Promise<string[]>

  /**
   * Organize multiple notes from inbox to notebooks (IA3)
   * @param noteIds - Array of note IDs to organize
   * @returns Map of noteId -> suggested notebook
   */
  organizeInboxBatch: (noteIds: string[]) => Promise<Map<string, Notebook | null>>
}

// ===== INPUT TYPES =====

export interface CreateNotebookInput {
  name: string
  icon?: string // emoji or icon name
  color?: string // hex color
}

export interface UpdateNotebookInput {
  name?: string
  icon?: string
  color?: string
}

export interface CreateLabelInput {
  name: string
  color?: string
}

export interface UpdateLabelInput {
  name?: string
  color?: string
}
```

### 1.2 Implementation

```typescript
// app/context/notebooks-context.tsx (continued)

'use client'

import { createContext, useContext, useState, useEffect, useMemo, useCallback, useOptimistic } from 'react'
import { useActionState } from 'react'
import {
  createNotebook,
  updateNotebook,
  deleteNotebook,
  updateNotebookOrder,
  createLabel,
  updateLabel,
  deleteLabel,
  moveNoteToNotebook,
  attachLabel,
  detachLabel,
  suggestNotebook,
  suggestLabels,
  organizeInbox,
} from '@/app/actions/notebooks'
import type { Notebook, Label } from '@/lib/types'

export const NotebooksContext = createContext<NotebooksContextValue | null>(null)

export function useNotebooks() {
  const context = useContext(NotebooksContext)
  if (!context) {
    throw new Error('useNotebooks must be used within NotebooksProvider')
  }
  return context
}

interface NotebooksProviderProps {
  children: React.ReactNode
  initialNotebooks?: Notebook[]
}

export function NotebooksProvider({ children, initialNotebooks = [] }: NotebooksProviderProps) {
  // ===== BASE STATE =====
  const [notebooks, setNotebooks] = useState<Notebook[]>(initialNotebooks)
  const [currentNotebook, setCurrentNotebook] = useState<Notebook | null>(null)
  const [isLoading, setIsLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)

  // ===== OPTIMISTIC STATE =====
  const [optimisticNotebooks, addOptimisticNotebook] = useOptimistic(
    notebooks,
    (state, action: OptimisticAction) => {
      switch (action.type) {
        case 'CREATE':
          return [...state, action.notebook]
        case 'UPDATE_ORDER':
          const orderMap = new Map(action.notebookIds.map((id, index) => [id, index]))
          return [...state].sort((a, b) => (orderMap.get(a.id) ?? 999) - (orderMap.get(b.id) ?? 999))
        case 'DELETE':
          return state.filter(nb => nb.id !== action.notebookId)
        default:
          return state
      }
    }
  )

  type OptimisticAction =
    | { type: 'CREATE'; notebook: Notebook }
    | { type: 'UPDATE_ORDER'; notebookIds: string[] }
    | { type: 'DELETE'; notebookId: string }

  // ===== DERIVED STATE =====
  const currentLabels = useMemo(() => {
    if (!currentNotebook) return []
    return notebooks
      .find(nb => nb.id === currentNotebook.id)
      ?.labels ?? []
  }, [currentNotebook, notebooks])

  // ===== DATA LOADING =====
  useEffect(() => {
    async function loadNotebooks() {
      setIsLoading(true)
      setError(null)
      try {
        const response = await fetch('/api/notebooks')
        if (!response.ok) throw new Error('Failed to load notebooks')
        const data = await response.json()
        setNotebooks(data.notebooks)
      } catch (err) {
        setError(err instanceof Error ? err.message : 'Unknown error')
      } finally {
        setIsLoading(false)
      }
    }

    loadNotebooks()
  }, [])

  // ===== ACTIONS: NOTEBOOKS =====

  const createNotebookOptimistic = useCallback(async (data: CreateNotebookInput) => {
    const tempId = `temp-${Date.now()}`
    const tempNotebook: Notebook = {
      id: tempId,
      name: data.name,
      icon: data.icon,
      color: data.color,
      order: notebooks.length,
      userId: '', // Will be filled by server
      notes: [],
      labels: [],
      createdAt: new Date(),
      updatedAt: new Date(),
    }

    // Optimistic update
    addOptimisticNotebook({ type: 'CREATE', notebook: tempNotebook })

    try {
      const result = await createNotebook(data)
      // Server will revalidate, causing a reload with real data
      return result
    } catch (err) {
      // Error: optimistic update will be reverted on next reload
      throw err
    }
  }, [notebooks.length, addOptimisticNotebook])

  const updateNotebookOrderOptimistic = useCallback(async (notebookIds: string[]) => {
    // Optimistic update
    addOptimisticNotebook({ type: 'UPDATE_ORDER', notebookIds })

    try {
      await updateNotebookOrder(notebookIds)
    } catch (err) {
      throw err
    }
  }, [addOptimisticNotebook])

  const deleteNotebook = useCallback(async (notebookId: string) => {
    try {
      await deleteNotebook(notebookId)
      // Server revalidation will reload data
    } catch (err) {
      throw err
    }
  }, [])

  const updateNotebook = useCallback(async (notebookId: string, data: UpdateNotebookInput) => {
    try {
      await updateNotebook(notebookId, data)
    } catch (err) {
      throw err
    }
  }, [])

  // ===== ACTIONS: LABELS =====

  const createLabel = useCallback(async (data: CreateLabelInput) => {
    if (!currentNotebook) {
      throw new Error('No notebook selected')
    }

    try {
      const result = await createLabel({ ...data, notebookId: currentNotebook.id })
      return result
    } catch (err) {
      throw err
    }
  }, [currentNotebook])

  const updateLabel = useCallback(async (labelId: string, data: UpdateLabelInput) => {
    try {
      await updateLabel(labelId, data)
    } catch (err) {
      throw err
    }
  }, [])

  const deleteLabel = useCallback(async (labelId: string) => {
    try {
      await deleteLabel(labelId)
    } catch (err) {
      throw err
    }
  }, [])

  // ===== ACTIONS: NOTES =====

  const moveNoteToNotebookOptimistic = useCallback(async (noteId: string, notebookId: string | null) => {
    try {
      await moveNoteToNotebook(noteId, notebookId)
      // Server revalidation
    } catch (err) {
      throw err
    }
  }, [])

  const attachLabelToNote = useCallback(async (noteId: string, labelId: string) => {
    try {
      await attachLabel(noteId, labelId)
    } catch (err) {
      throw err
    }
  }, [])

  const detachLabelFromNote = useCallback(async (noteId: string, labelId: string) => {
    try {
      await detachLabel(noteId, labelId)
    } catch (err) {
      throw err
    }
  }, [])

  // ===== ACTIONS: AI =====

  const suggestNotebookForNote = useCallback(async (noteContent: string) => {
    try {
      const result = await suggestNotebook(noteContent)
      return result
    } catch (err) {
      console.error('Failed to suggest notebook:', err)
      return null
    }
  }, [])

  const suggestLabelsForNote = useCallback(async (noteContent: string) => {
    if (!currentNotebook) return []

    try {
      const result = await suggestLabels(noteContent, currentNotebook.id)
      return result
    } catch (err) {
      console.error('Failed to suggest labels:', err)
      return []
    }
  }, [currentNotebook])

  const organizeInboxBatch = useCallback(async (noteIds: string[]) => {
    try {
      const result = await organizeInbox(noteIds)
      return result
    } catch (err) {
      console.error('Failed to organize inbox:', err)
      return new Map()
    }
  }, [])

  // ===== CONTEXT VALUE =====
  const value: NotebooksContextValue = useMemo(() => ({
    notebooks: optimisticNotebooks,
    currentNotebook,
    currentLabels,
    isLoading,
    error,
    createNotebookOptimistic,
    updateNotebook,
    deleteNotebook,
    updateNotebookOrderOptimistic,
    setCurrentNotebook,
    createLabel,
    updateLabel,
    deleteLabel,
    moveNoteToNotebookOptimistic,
    attachLabelToNote,
    detachLabelFromNote,
    suggestNotebookForNote,
    suggestLabelsForNote,
    organizeInboxBatch,
  }), [
    optimisticNotebooks,
    currentNotebook,
    currentLabels,
    isLoading,
    error,
    createNotebookOptimistic,
    updateNotebook,
    deleteNotebook,
    updateNotebookOrderOptimistic,
    createLabel,
    updateLabel,
    deleteLabel,
    moveNoteToNotebookOptimistic,
    attachLabelToNote,
    detachLabelFromNote,
    suggestNotebookForNote,
    suggestLabelsForNote,
    organizeInboxBatch,
  ])

  return (
    <NotebooksContext.Provider value={value}>
      {children}
    </NotebooksContext.Provider>
  )
}
```

### 1.3 Integration

```typescript
// app/providers.tsx

import { NotebooksProvider } from '@/app/context/notebooks-context'

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <NotebooksProvider>
      {children}
    </NotebooksProvider>
  )
}
```

### 1.4 Usage Example

```typescript
// components/notebooks-sidebar.tsx

'use client'

import { useNotebooks } from '@/app/context/notebooks-context'

export function NotebooksSidebar() {
  const {
    notebooks,
    currentNotebook,
    currentLabels,
    setCurrentNotebook,
    createNotebookOptimistic,
    isLoading,
  } = useNotebooks()

  if (isLoading) return <div>Loading...</div>

  return (
    <div className="sidebar">
      {/* Notebook list */}
      {notebooks.map(notebook => (
        <div
          key={notebook.id}
          onClick={() => setCurrentNotebook(notebook)}
          className={currentNotebook?.id === notebook.id ? 'active' : ''}
        >
          {notebook.icon} {notebook.name}
        </div>
      ))}

      {/* Current labels */}
      {currentLabels.map(label => (
        <div key={label.id}>{label.name}</div>
      ))}

      <button onClick={() => createNotebookOptimistic({ name: 'New Notebook' })}>
        + New Notebook
      </button>
    </div>
  )
}
```

### 1.5 Testing

```typescript
// __tests__/notebooks-context.test.tsx

import { renderHook, act, waitFor } from '@testing-library/react'
import { NotebooksProvider, useNotebooks } from '@/app/context/notebooks-context'

describe('NotebooksContext', () => {
  it('should load notebooks on mount', async () => {
    const wrapper = ({ children }) => <NotebooksProvider>{children}</NotebooksProvider>

    const { result } = renderHook(() => useNotebooks(), { wrapper })

    expect(result.current.isLoading).toBe(true)

    await waitFor(() => {
      expect(result.current.isLoading).toBe(false)
      expect(result.current.notebooks).toBeDefined()
    })
  })

  it('should create notebook optimistically', async () => {
    const wrapper = ({ children }) => <NotebooksProvider>{children}</NotebooksProvider>

    const { result } = renderHook(() => useNotebooks(), { wrapper })

    await act(async () => {
      await result.current.createNotebookOptimistic({ name: 'Test Notebook' })
    })

    // Optimistic update should be visible immediately
    expect(result.current.notebooks.some(nb => nb.name === 'Test Notebook')).toBe(true)
  })

  it('should derive current labels from current notebook', async () => {
    const wrapper = ({ children }) => (
      <NotebooksProvider initialNotebooks={[mockNotebookWithLabels]}>
        {children}
      </NotebooksProvider>
    )

    const { result } = renderHook(() => useNotebooks(), { wrapper })

    act(() => {
      result.current.setCurrentNotebook(mockNotebookWithLabels)
    })

    expect(result.current.currentLabels).toEqual(mockNotebookWithLabels.labels)
  })
})
```

### 1.6 Performance Considerations

- **useMemo** for derived state (currentLabels) to prevent unnecessary recalculations
- **useCallback** for all action handlers to maintain referential stability
- **useOptimistic** for instant UI feedback during server mutations
- **Debouncing** could be added to search/filter operations if needed
- **Pagination** for notebooks if user has > 100 notebooks

### 1.7 Edge Cases

| Edge Case | Handling |
|-----------|----------|
| No notebooks | Show empty state, prompt to create first notebook |
| Notebook deleted while selected | Automatically deselect (currentNotebook = null) |
| Label creation without notebook | Throw error ("No notebook selected") |
| Concurrent updates | Server wins, last write wins (optimistic) |
| Network error | Show error message, retry button |

---

## 2. Component: NotebooksSidebar

**Purpose:** Sidebar UI for displaying and managing notebooks with drag & drop
**Type:** React Client Component
**Location:** `keep-notes/app/components/notebooks-sidebar.tsx`

### 2.1 Props Interface

```typescript
interface NotebooksSidebarProps {
  /** Optional className for styling */
  className?: string

  /** Width of the sidebar (default: 256px) */
  width?: string

  /** Whether to show notebook counts */
  showCounts?: boolean

  /** Whether to show labels in sidebar */
  showLabels?: boolean
}
```

### 2.2 Implementation

```typescript
// app/components/notebooks-sidebar.tsx

'use client'

import { useEffect, useRef, useCallback, useState } from 'react'
import { useNotebooks } from '@/app/context/notebooks-context'
import { NoteItem } from './notebook-item'
import { LabelChip } from './label-chip'
import { CreateNotebookModal } from './create-notebook-modal'

export function NotebooksSidebar({
  className = '',
  width = 'w-64',
  showCounts = true,
  showLabels = true,
}: NotebooksSidebarProps) {
  const {
    notebooks,
    currentNotebook,
    currentLabels,
    setCurrentNotebook,
    createNotebookOptimistic,
    updateNotebookOrderOptimistic,
    deleteNotebook,
  } = useNotebooks()

  const notebooksGridRef = useRef<HTMLDivElement>(null)
  const notebooksMuuri = useRef<any>(null)
  const [isDragging, setIsDragging] = useState(false)
  const [showCreateModal, setShowCreateModal] = useState(false)
  const [hoveredNotebook, setHoveredNotebook] = useState<string | null>(null)

  // ===== MUURI DRAG & DROP =====
  const handleDragEnd = useCallback(async (grid: any) => {
    if (!grid) return

    const items = grid.getItems()
    const notebookIds = items
      .map((item: any) => item.getElement()?.getAttribute('data-id'))
      .filter((id: any): id is string => !!id)

    try {
      await updateNotebookOrderOptimistic(notebookIds)
    } catch (error) {
      console.error('Failed to update notebook order:', error)
    } finally {
      setIsDragging(false)
    }
  }, [updateNotebookOrderOptimistic])

  useEffect(() => {
    let isMounted = true

    const initNotebooksMuuri = async () => {
      // Dynamic import of Muuri
      const MuuriClass = (await import('muuri')).default

      if (!isMounted || !notebooksGridRef.current) return

      // Configuration synced with masonry-grid for consistency
      const layoutOptions = {
        dragEnabled: true,
        dragContainer: document.body,
        dragStartPredicate: {
          distance: 5, // More sensitive than masonry (sidebar more compact)
          delay: 0,
        },
        dragPlaceholder: {
          enabled: true,
          createElement: (item: any) => {
            const el = item.getElement().cloneNode(true)
            el.style.opacity = '0.5'
            el.style.position = 'fixed'
            return el
          },
        },
        dragAutoScroll: {
          targets: [window],
          speed: (item: any, target: any, intersection: any) => {
            return intersection * 20
          },
        },
        // Vertical layout for sidebar
        layout: {
          fillGaps: false,
          horizontal: false,
          alignRight: false,
          alignBottom: false,
          rounding: false,
        },
      }

      notebooksMuuri.current = new MuuriClass(notebooksGridRef.current, layoutOptions)
        .on('dragStart', () => setIsDragging(true))
        .on('dragEnd', () => handleDragEnd(notebooksMuuri.current))
    }

    initNotebooksMuuri()

    return () => {
      isMounted = false
      notebooksMuuri.current?.destroy()
      notebooksMuuri.current = null
    }
  }, [handleDragEnd])

  // Sync Muuri when notebooks change
  useEffect(() => {
    if (notebooksMuuri.current && !isDragging) {
      requestAnimationFrame(() => {
        notebooksMuuri.current?.refreshItems().layout()
      })
    }
  }, [notebooks, isDragging])

  // ===== EVENT HANDLERS =====
  const handleNotebookClick = useCallback((notebook: Notebook | null) => {
    setCurrentNotebook(notebook)
  }, [setCurrentNotebook])

  const handleNotebookHover = useCallback((notebookId: string | null) => {
    setHoveredNotebook(notebookId)
  }, [])

  const handleDeleteNotebook = useCallback(async (notebookId: string) => {
    if (confirm('Are you sure? Notes will become general.')) {
      await deleteNotebook(notebookId)
    }
  }, [deleteNotebook])

  const handleCreateNotebook = useCallback(async (data: CreateNotebookInput) => {
    await createNotebookOptimistic(data)
    setShowCreateModal(false)
  }, [createNotebookOptimistic])

  // ===== DROP TARGET FOR NOTES =====
  const handleNoteDrop = useCallback(async (noteId: string) => {
    if (!hoveredNotebook) return

    // Note dropped on a notebook - move it
    // This will be called from masonry-grid drag end
    await moveNoteToNotebook(noteId, hoveredNotebook)
  }, [hoveredNotebook])

  // Expose drop handler to parent
  useEffect(() => {
    // Store handler in window for cross-component communication
    // (alternative: use event bus or context)
    ;(window as any).notebooksSidebarDropHandler = handleNoteDrop
  }, [handleNoteDrop])

  // ===== RENDER =====
  return (
    <aside className={`notebooks-sidebar ${width} ${className}`}>
      {/* Header */}
      <div className="sidebar-header p-4 border-b">
        <h2 className="text-sm font-semibold">Notebooks</h2>
        <button
          onClick={() => setShowCreateModal(true)}
          className="text-xs text-blue-600 hover:underline"
        >
          + New
        </button>
      </div>

      {/* Notebook List */}
      <div
        ref={notebooksGridRef}
        className="notebooks-list p-2"
      >
        {/* "Notes générales" (Inbox) - always first */}
        <NoteItem
          notebook={null}
          isActive={!currentNotebook}
          onClick={() => handleNotebookClick(null)}
          onHover={() => handleNotebookHover(null)}
          count={notebooks.reduce((sum, nb) => sum + (nb.notes?.length || 0), 0)}
          showCount={showCounts}
        />

        {/* Notebooks (Muuri items) */}
        {notebooks.map(notebook => (
          <div
            key={notebook.id}
            data-id={notebook.id}
            onMouseEnter={() => handleNotebookHover(notebook.id)}
            onMouseLeave={() => handleNotebookHover(null)}
          >
            <NoteItem
              notebook={notebook}
              isActive={currentNotebook?.id === notebook.id}
              onClick={() => handleNotebookClick(notebook)}
              onDelete={() => handleDeleteNotebook(notebook.id)}
              count={notebook.notes?.length || 0}
              showCount={showCounts}
              isHovered={hoveredNotebook === notebook.id}
            />

            {/* Labels (shown when notebook is active or hovered) */}
            {showLabels && (currentNotebook?.id === notebook.id || hoveredNotebook === notebook.id) && (
              <div className="labels-list ml-4 mt-1">
                {notebook.labels?.map(label => (
                  <LabelChip
                    key={label.id}
                    label={label}
                    notebookId={notebook.id}
                  />
                ))}
              </div>
            )}
          </div>
        ))}
      </div>

      {/* Create Modal */}
      {showCreateModal && (
        <CreateNotebookModal
          onClose={() => setShowCreateModal(false)}
          onSubmit={handleCreateNotebook}
        />
      )}
    </aside>
  )
}

// ===== SUB-COMPONENT: NoteItem =====
interface NoteItemProps {
  notebook: Notebook | null
  isActive: boolean
  onClick: () => void
  onHover?: () => void
  onDelete?: () => void
  count?: number
  showCount?: boolean
  isHovered?: boolean
}

function NoteItem({
  notebook,
  isActive,
  onClick,
  onHover,
  onDelete,
  count = 0,
  showCount = true,
  isHovered = false,
}: NoteItemProps) {
  return (
    <div
      className={`
        notebook-item p-3 rounded cursor-pointer
        transition-colors duration-150
        ${isActive ? 'bg-blue-100 text-blue-900' : 'hover:bg-gray-100'}
        ${isHovered ? 'bg-gray-50' : ''}
      `}
      onClick={onClick}
      onMouseEnter={onHover}
    >
      <div className="flex items-center">
        <span className="notebook-icon mr-2">
          {notebook?.icon || '📝'}
        </span>
        <span className="notebook-name flex-1 text-sm">
          {notebook?.name || 'Notes générales'}
        </span>

        {/* Count */}
        {showCount && (
          <span className="notebook-count text-xs text-gray-500 ml-2">
            {count}
          </span>
        )}

        {/* Delete button (only for non-general notebooks) */}
        {notebook && isHovered && onDelete && (
          <button
            onClick={(e) => {
              e.stopPropagation()
              onDelete()
            }}
            className="delete-btn text-gray-400 hover:text-red-500 ml-2"
          >
            ×
          </button>
        )}
      </div>
    </div>
  )
}
```

### 2.3 Styles

```css
/* app/components/notebooks-sidebar.module.css */

.notebooks-sidebar {
  background: #f9fafb;
  border-right: 1px solid #e5e7eb;
  height: 100vh;
  overflow-y: auto;
}

.sidebar-header {
  position: sticky;
  top: 0;
  background: #f9fafb;
  z-index: 10;
}

.notebooks-list {
  min-height: 100px;
}

/* Muuri item styles */
.notebooks-list > div {
  position: absolute;
  width: 100%;
}

.notebook-item.muuri-item-dragging {
  z-index: 3;
  box-shadow: 0 10px 20px rgba(0,0,0,0.1);
}

.notebook-item.muuri-item-releasing {
  z-index: 2;
}

/* Drop zone indicator */
.notebook-item.drop-target {
  background: #dbeafe !important;
  border: 2px dashed #3b82f6;
}

/* Labels list */
.labels-list {
  animation: slideDown 0.2s ease-out;
}

@keyframes slideDown {
  from {
    opacity: 0;
    transform: translateY(-10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}
```

### 2.4 Testing

```typescript
// __tests__/notebooks-sidebar.test.tsx

import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { NotebooksSidebar } from '@/app/components/notebooks-sidebar'
import { NotebooksProvider } from '@/app/context/notebooks-context'

describe('NotebooksSidebar', () => {
  const mockNotebooks = [
    { id: '1', name: 'Work', icon: '💼', notes: [], labels: [] },
    { id: '2', name: 'Personal', icon: '🏠', notes: [], labels: [] },
  ]

  beforeEach(() => {
    // Mock Muuri
    global.muuri = jest.fn(() => ({
      on: jest.fn().mockReturnThis(),
      destroy: jest.fn(),
      refreshItems: jest.fn().mockReturnThis(),
      layout: jest.fn().mockReturnThis(),
      getItems: jest.fn(() => []),
    }))
  })

  it('should render notebooks list', () => {
    render(
      <NotebooksProvider initialNotebooks={mockNotebooks}>
        <NotebooksSidebar />
      </NotebooksProvider>
    )

    expect(screen.getByText('Notes générales')).toBeInTheDocument()
    expect(screen.getByText('Work')).toBeInTheDocument()
    expect(screen.getByText('Personal')).toBeInTheDocument()
  })

  it('should call setCurrentNotebook when notebook clicked', async () => {
    render(
      <NotebooksProvider initialNotebooks={mockNotebooks}>
        <NotebooksSidebar />
      </NotebooksProvider>
    )

    fireEvent.click(screen.getByText('Work'))

    await waitFor(() => {
      // Verify current notebook changed
      // (would need to check context value)
    })
  })

  it('should show delete button on hover', async () => {
    render(
      <NotebooksProvider initialNotebooks={mockNotebooks}>
        <NotebooksSidebar />
      </NotebooksProvider>
    )

    const workItem = screen.getByText('Work')

    fireEvent.mouseEnter(workItem)

    await waitFor(() => {
      expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument()
    })
  })
})
```

### 2.5 Performance Considerations

- **requestAnimationFrame** for Muuri layout updates (smooth 60fps)
- **Conditional rendering** of labels (only when active/hovered)
- **Debouncing** of hover events if needed
- **Virtual scrolling** if user has > 50 notebooks (future optimization)

### 2.6 Edge Cases

| Edge Case | Handling |
|-----------|----------|
| No notebooks | Show empty state with CTA to create first notebook |
| Drag to invalid position | Muuri auto-corrects to nearest valid position |
| Drop during network error | Show error, keep optimistic state until resolved |
| Very long notebook names | CSS truncate with ellipsis, tooltip on hover |
| Very many notebooks (>50) | Add scroll + search/filter |

---

## 3. Service: ContextualAutoTagService

**Purpose:** Suggest contextual labels for notes within a specific notebook
**Type:** AI Service (Server-Side)
**Location:** `keep-notes/lib/ai/services/contextual-auto-tag.service.ts`

### 3.1 Interface

```typescript
// lib/ai/services/contextual-auto-tag.service.ts

import { prisma } from '@/lib/prisma'
import { autoTagService } from './auto-tag.service'
import type { Label, Note } from '@/lib/types'

export class ContextualAutoTagService {
  constructor(private notebookId: string) {}

  /**
   * Suggest labels for a note, filtered by current notebook
   * @param noteContent - Content of the note
   * @param maxSuggestions - Maximum number of suggestions (default: 5)
   * @returns Array of label names
   */
  async suggestLabels(noteContent: string, maxSuggestions = 5): Promise<string[]> {
    // 1. Get all labels in the current notebook
    const existingLabels = await prisma.label.findMany({
      where: { notebookId: this.notebookId },
      select: { name: true },
    })

    if (existingLabels.length === 0) {
      // No labels in notebook - suggest creating first label
      return await this.suggestNewLabel(noteContent)
    }

    // 2. Get AI suggestions (from existing service)
    const allSuggestions = await autoTagService.suggestTags(noteContent)

    // 3. Filter to only include labels that exist in this notebook
    const contextualSuggestions = allSuggestions
      .filter(suggestion =>
        existingLabels.some(label => label.name === suggestion)
      )
      .slice(0, maxSuggestions)

    // 4. If no matches, suggest creating a new label
    if (contextualSuggestions.length === 0) {
      return [allSuggestions[0]] // Top suggestion for creation
    }

    return contextualSuggestions
  }

  /**
   * Suggest a NEW label to create (when no existing labels match)
   * @param noteContent - Content of the note
   * @returns Single label name suggestion
   */
  async suggestNewLabel(noteContent: string): Promise<string[]> {
    const suggestions = await autoTagService.suggestTags(noteContent)
    return [suggestions[0]] // Return top suggestion for creation
  }

  /**
   * Automatically create a label if theme is recurring (IA4)
   * @param noteContent - Content of the note
   * @param threshold - Minimum occurrence count to auto-create (default: 3)
   * @returns Created label or null if threshold not met
   */
  async createLabelIfRecurring(
    noteContent: string,
    threshold = 3
  ): Promise<Label | null> {
    const suggestions = await autoTagService.suggestTags(noteContent)
    const topSuggestion = suggestions[0]

    if (!topSuggestion) return null

    // Check if label already exists
    const existing = await prisma.label.findFirst({
      where: {
        notebookId: this.notebookId,
        name: topSuggestion,
      },
    })

    if (existing) return existing

    // Check if this theme is recurring (appears in multiple notes)
    const notesWithSimilarContent = await prisma.note.findMany({
      where: {
        notebookId: this.notebookId,
        content: {
          contains: topSuggestion,
        },
      },
      take: threshold,
    })

    if (notesWithSimilarContent.length < threshold) {
      return null // Not recurring enough
    }

    // Create the label
    const newLabel = await prisma.label.create({
      data: {
        name: topSuggestion,
        notebookId: this.notebookId,
        color: this.getRandomColor(),
      },
    })

    return newLabel
  }

  /**
   * Get recommended labels based on recent notes in notebook
   * @param limit - Number of recent notes to analyze (default: 10)
   * @returns Array of most used label names
   */
  async getRecommendedLabels(limit = 10): Promise<string[]> {
    const recentNotes = await prisma.note.findMany({
      where: {
        notebookId: this.notebookId,
      },
      include: {
        labels: true,
      },
      orderBy: {
        updatedAt: 'desc',
      },
      take: limit,
    })

    // Count label usage
    const labelCounts = new Map<string, number>()

    for (const note of recentNotes) {
      for (const label of note.labels) {
        labelCounts.set(label.name, (labelCounts.get(label.name) || 0) + 1)
      }
    }

    // Sort by frequency
    return Array.from(labelCounts.entries())
      .sort((a, b) => b[1] - a[1])
      .slice(0, 5)
      .map(([name]) => name)
  }

  /**
   * Generate a random color for new labels
   * @returns Hex color string
   */
  private getRandomColor(): string {
    const colors = [
      '#ef4444', // red
      '#f97316', // orange
      '#f59e0b', // amber
      '#84cc16', // lime
      '#10b981', // emerald
      '#06b6d4', // cyan
      '#3b82f6', // blue
      '#8b5cf6', // violet
      '#d946ef', // fuchsia
      '#f43f5e', // rose
    ]
    return colors[Math.floor(Math.random() * colors.length)]
  }
}
```

### 3.2 Server Action Integration

```typescript
// app/actions/notebooks.ts

'use server'

import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { ContextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
import { revalidatePath } from 'next/cache'

/**
 * Suggest contextual labels for a note
 */
export async function suggestContextualLabels(noteId: string, notebookId: string) {
  const session = await auth()
  if (!session?.user?.id) throw new Error('Unauthorized')

  const note = await prisma.note.findUnique({
    where: { id: noteId, userId: session.user.id },
  })

  if (!note) throw new Error('Note not found')

  const service = new ContextualAutoTagService(notebookId)
  const suggestions = await service.suggestLabels(note.content)

  return suggestions
}

/**
 * Create a label if theme is recurring
 */
export async function autoCreateLabelIfRecurring(noteId: string) {
  const session = await auth()
  if (!session?.user?.id) throw new Error('Unauthorized')

  const note = await prisma.note.findUnique({
    where: { id: noteId, userId: session.user.id },
  })

  if (!note?.notebookId) {
    throw new Error('Note must be in a notebook')
  }

  const service = new ContextualAutoTagService(note.notebookId)
  const newLabel = await service.createLabelIfRecurring(note.content)

  if (newLabel) {
    revalidatePath('/')
  }

  return newLabel
}
```

### 3.3 Testing

```typescript
// __tests__/contextual-auto-tag.service.test.ts

import { ContextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
import { prisma } from '@/lib/prisma'

jest.mock('@/lib/prisma')
jest.mock('@/lib/ai/services/auto-tag.service')

describe('ContextualAutoTagService', () => {
  const mockNotebookId = 'notebook-123'
  let service: ContextualAutoTagService

  beforeEach(() => {
    service = new ContextualAutoTagService(mockNotebookId)
  })

  it('should filter suggestions to existing labels', async () => {
    // Mock existing labels
    (prisma.label.findMany as jest.Mock).mockResolvedValue([
      { name: 'Work' },
      { name: 'Personal' },
    ])

    // Mock AI suggestions
    const { autoTagService } = require('@/lib/ai/services/auto-tag.service')
    autoTagService.suggestTags.mockResolvedValue(['Work', 'Personal', 'Travel', 'Ideas'])

    const suggestions = await service.suggestLabels('Meeting at 3pm')

    expect(suggestions).toEqual(['Work', 'Personal']) // Only existing labels
    expect(suggestions).not.toContain('Travel') // Not in this notebook
  })

  it('should suggest new label when no matches', async () => {
    (prisma.label.findMany as jest.Mock).mockResolvedValue([])

    const { autoTagService } = require('@/lib/ai/services/auto-tag.service')
    autoTagService.suggestTags.mockResolvedValue(['Recipes', 'Cooking', 'Food'])

    const suggestions = await service.suggestLabels('Best pasta recipe')

    expect(suggestions).toEqual(['Recipes']) // Top suggestion for creation
  })

  it('should create label if recurring theme', async () => {
    const mockLabel = { id: 'label-1', name: 'Meeting' }
    ;(prisma.label.findFirst as jest.Mock).mockResolvedValue(null)
    ;(prisma.note.findMany as jest.Mock).mockResolvedValue([1, 2, 3]) // 3 occurrences
    ;(prisma.label.create as jest.Mock).mockResolvedValue(mockLabel)

    const { autoTagService } = require('@/lib/ai/services/auto-tag.service')
    autoTagService.suggestTags.mockResolvedValue(['Meeting'])

    const result = await service.createLabelIfRecurring('Team meeting notes', 3)

    expect(result).toEqual(mockLabel)
    expect(prisma.label.create).toHaveBeenCalledWith({
      data: {
        name: 'Meeting',
        notebookId: mockNotebookId,
        color: expect.any(String),
      },
    })
  })

  it('should not create label if not recurring', async () => {
    ;(prisma.label.findFirst as jest.Mock).mockResolvedValue(null)
    ;(prisma.note.findMany as jest.Mock).mockResolvedValue([1]) // Only 1 occurrence

    const { autoTagService } = require('@/lib/ai/services/auto-tag.service')
    autoTagService.suggestTags.mockResolvedValue(['Random'])

    const result = await service.createLabelIfRecurring('Random thought', 3)

    expect(result).toBeNull()
    expect(prisma.label.create).not.toHaveBeenCalled()
  })
})
```

### 3.4 Performance Considerations

- **Database queries** are bounded (findOne, findMany with take)
- **AI calls** happen only once per suggestion (cached in autoTagService)
- **Recurring check** uses simple string matching (could be improved with embeddings)
- **Threshold** for auto-creation prevents spam (default: 3 occurrences)

### 3.5 Edge Cases

| Edge Case | Handling |
|-----------|----------|
| Notebook has no labels | Suggest creating first label |
| AI service fails | Return empty array, log error |
| Empty note content | Return empty array |
| Note already has all labels | Return empty array |
| Very long note content | Truncate to 2000 chars before AI call |

---

## 4. Service: NotebookSuggestionService

**Purpose:** Suggest which notebook a note should belong to (IA1)
**Type:** AI Service (Server-Side)
**Location:** `keep-notes/lib/ai/services/notebook-suggestion.service.ts`

### 4.1 Interface

```typescript
// lib/ai/services/notebook-suggestion.service.ts

import { prisma } from '@/lib/prisma'
import { generateText } from 'ai'
import { getOpenAIModel } from '@/lib/ai/providers/openai'
import type { Notebook, Note } from '@/lib/types'

export class NotebookSuggestionService {
  /**
   * Suggest the most appropriate notebook for a note
   * @param noteContent - Content of the note
   * @param userId - User ID (for fetching user's notebooks)
   * @returns Suggested notebook or null (if no good match)
   */
  async suggestNotebook(noteContent: string, userId: string): Promise<Notebook | null> {
    // 1. Get all notebooks for this user
    const notebooks = await prisma.notebook.findMany({
      where: { userId },
      include: {
        labels: true,
        _count: {
          select: { notes: true },
        },
      },
      orderBy: { order: 'asc' },
    })

    if (notebooks.length === 0) {
      return null // No notebooks to suggest
    }

    // 2. Build prompt for AI
    const prompt = this.buildPrompt(noteContent, notebooks)

    // 3. Call AI
    try {
      const response = await generateText({
        model: getOpenAIModel(),
        prompt,
        temperature: 0.3, // Low temperature for consistent suggestions
        maxTokens: 50,
      })

      const suggestedName = response.text.trim().toUpperCase()

      // 4. Find matching notebook
      const suggestedNotebook = notebooks.find(nb =>
        nb.name.toUpperCase() === suggestedName
      )

      // If AI says "NONE" or no match, return null
      if (suggestedName === 'NONE' || !suggestedNotebook) {
        return null
      }

      return suggestedNotebook
    } catch (error) {
      console.error('Failed to suggest notebook:', error)
      return null
    }
  }

  /**
   * Build the AI prompt for notebook suggestion
   */
  private buildPrompt(noteContent: string, notebooks: Notebook[]): string {
    const notebookList = notebooks
      .map(nb => {
        const labels = nb.labels.map(l => l.name).join(', ')
        const count = nb._count?.notes || 0
        return `- ${nb.name} (${count} notes)${labels ? ` [labels: ${labels}]` : ''}`
      })
      .join('\n')

    return `
You are a helpful assistant that suggests which notebook a note should belong to.

NOTE CONTENT:
${noteContent.substring(0, 500)}

AVAILABLE NOTEBOOKS:
${notebookList}

TASK:
Analyze the note content and suggest the MOST appropriate notebook for this note.
Consider:
1. The topic/theme of the note
2. Existing labels in each notebook
3. The number of notes (prefer notebooks with related content)

RULES:
- Return ONLY the notebook name, EXACTLY as shown above (case-insensitive)
- If no good match exists, return "NONE"
- If the note is too generic/vague, return "NONE"
- Do NOT include explanations or extra text

Examples:
- If note is about "Meeting with John about project timeline" and there's a "Work" notebook → return "WORK"
- If note is about "Grocery list: milk, eggs, bread" and there's no "Personal" or "Shopping" notebook → return "NONE"
- If note is about "Python script for data analysis" and there's a "Coding" notebook → return "CODING"

Your suggestion:
`.trim()
  }

  /**
   * Batch suggest notebooks for multiple notes (IA3)
   * @param noteContents - Array of note contents
   * @param userId - User ID
   * @returns Map of note index -> suggested notebook
   */
  async suggestNotebooksBatch(
    noteContents: string[],
    userId: string
  ): Promise<Map<number, Notebook | null>> {
    const results = new Map<number, Notebook | null>()

    // For efficiency, we could batch this into a single AI call
    // For now, process sequentially (could be parallelized)
    for (let i = 0; i < noteContents.length; i++) {
      const suggestion = await this.suggestNotebook(noteContents[i], userId)
      results.set(i, suggestion)
    }

    return results
  }

  /**
   * Get notebook suggestion confidence score
   * (For future UI enhancement: show confidence level)
   */
  async suggestNotebookWithConfidence(
    noteContent: string,
    userId: string
  ): Promise<{ notebook: Notebook | null; confidence: number }> {
    // This could use logprobs from OpenAI API to calculate confidence
    // For now, return binary confidence
    const notebook = await this.suggestNotebook(noteContent, userId)
    return {
      notebook,
      confidence: notebook ? 0.8 : 0, // Placeholder
    }
  }
}
```

### 4.2 Server Action Integration

```typescript
// app/actions/notebooks.ts

/**
 * Suggest a notebook for a note (IA1)
 */
export async function suggestNotebookForNote(noteId: string) {
  const session = await auth()
  if (!session?.user?.id) throw new Error('Unauthorized')

  const note = await prisma.note.findUnique({
    where: { id: noteId, userId: session.user.id },
    include: {
      notebook: true,
    },
  })

  if (!note) throw new Error('Note not found')

  // Already in a notebook - no suggestion needed
  if (note.notebook) {
    return null
  }

  const service = new NotebookSuggestionService()
  const suggestion = await service.suggestNotebook(note.content, session.user.id)

  return suggestion
}

/**
 * Organize multiple notes from inbox to notebooks (IA3)
 */
export async function organizeInbox(noteIds: string[]) {
  const session = await auth()
  if (!session?.user?.id) throw new Error('Unauthorized')

  const notes = await prisma.note.findMany({
    where: {
      id: { in: noteIds },
      userId: session.user.id,
      notebookId: null, // Only general notes
    },
  })

  const service = new NotebookSuggestionService()
  const suggestions = await service.suggestNotebooksBatch(
    notes.map(n => n.content),
    session.user.id
  )

  // Apply suggestions
  const updates: Promise<any>[] = []
  const results = new Map<string, Notebook | null>()

  for (let [index, notebook] of suggestions.entries()) {
    const note = notes[index]
    if (notebook) {
      updates.push(
        prisma.note.update({
          where: { id: note.id },
          data: { notebookId: notebook.id },
        })
      )
      results.set(note.id, notebook)
    } else {
      results.set(note.id, null) // No suggestion
    }
  }

  await Promise.all(updates)
  revalidatePath('/')

  return results
}
```

### 4.3 Testing

```typescript
// __tests__/notebook-suggestion.service.test.ts

import { NotebookSuggestionService } from '@/lib/ai/services/notebook-suggestion.service'
import { prisma } from '@/lib/prisma'

jest.mock('@/lib/prisma')
jest.mock('ai')

describe('NotebookSuggestionService', () => {
  let service: NotebookSuggestionService
  const mockUserId = 'user-123'

  beforeEach(() => {
    service = new NotebookSuggestionService()
  })

  it('should suggest appropriate notebook', async () => {
    const mockNotebooks = [
      { id: '1', name: 'Work', labels: [], _count: { notes: 10 } },
      { id: '2', name: 'Personal', labels: [], _count: { notes: 5 } },
    ]

    ;(prisma.notebook.findMany as jest.Mock).mockResolvedValue(mockNotebooks)

    const { generateText } = require('ai')
    generateText.mockResolvedValue({
      text: 'WORK',
    })

    const result = await service.suggestNotebook('Meeting with team', mockUserId)

    expect(result).toEqual(mockNotebooks[0])
  })

  it('should return null when AI says NONE', async () => {
    ;(prisma.notebook.findMany as jest.Mock).mockResolvedValue([
      { id: '1', name: 'Work', labels: [], _count: { notes: 10 } },
    ])

    const { generateText } = require('ai')
    generateText.mockResolvedValue({
      text: 'NONE',
    })

    const result = await service.suggestNotebook('Random thought', mockUserId)

    expect(result).toBeNull()
  })

  it('should return null when no notebooks exist', async () => {
    ;(prisma.notebook.findMany as jest.Mock).mockResolvedValue([])

    const result = await service.suggestNotebook('Any content', mockUserId)

    expect(result).toBeNull()
    expect(generateText).not.toHaveBeenCalled()
  })

  it('should handle batch suggestions', async () => {
    const mockNotebooks = [
      { id: '1', name: 'Work', labels: [], _count: { notes: 10 } },
      { id: '2', name: 'Personal', labels: [], _count: { notes: 5 } },
    ]

    ;(prisma.notebook.findMany as jest.Mock).mockResolvedValue(mockNotebooks)

    const { generateText } = require('ai')
    generateText
      .mockResolvedValueOnce({ text: 'WORK' })
      .mockResolvedValueOnce({ text: 'PERSONAL' })

    const results = await service.suggestNotebooksBatch(
      ['Meeting notes', 'Grocery list'],
      mockUserId
    )

    expect(results.get(0)).toEqual(mockNotebooks[0])
    expect(results.get(1)).toEqual(mockNotebooks[1])
  })
})
```

### 4.4 Performance Considerations

- **Sequential AI calls** in batch could be slow (optimize with single batch call)
- **Caching** could be added for repeated suggestions
- **Max tokens** limited to 50 for fast responses
- **Low temperature** (0.3) for consistent, deterministic suggestions

### 4.5 Edge Cases

| Edge Case | Handling |
|-----------|----------|
| No notebooks | Return null immediately (no AI call) |
| Note already in notebook | Return null (no suggestion needed) |
| AI returns invalid notebook name | Return null (no match found) |
| Empty note content | Return null (too vague) |
| Note content too long | Truncate to 500 chars for prompt |

---

## 5. Migration: Database Migration Script

**Purpose:** Migrate from flat tags system to notebooks without breaking changes
**Type:** Prisma Migration Script
**Location:** `keep-notes/prisma/migrations/[timestamp]/migration.sql`

### 5.1 Prisma Schema Changes

```prisma
// prisma/schema.prisma

// NEW: Notebook model
model Notebook {
  id          String   @id @default(cuid())
  name        String
  icon        String?
  color       String?
  order       Int
  userId      String
  user        User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  notes       Note[]
  labels      Label[]
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  @@index([userId, order])
  @@index([userId])
}

// MODIFIED: Label model (now belongs to a notebook)
model Label {
  id          String   @id @default(cuid())
  name        String
  color       String?
  notebookId  String   // NEW: Required
  notebook    Notebook @relation(fields: [notebookId], references: [id], onDelete: Cascade)
  notes       Note[]

  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  @@unique([notebookId, name]) // Unique within notebook
  @@index([notebookId])
}

// MODIFIED: Note model (now optionally belongs to a notebook)
model Note {
  id          String   @id @default(cuid())
  title       String?
  content     String
  isPinned    Boolean  @default(false)
  size        String   @default("small")
  order       Int
  userId      String
  user        User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  // NEW: Optional relation to Notebook
  notebookId  String?  // NULL = "Notes générales"
  notebook    Notebook? @relation(fields: [notebookId], references: [id], onDelete: SetNull)

  // NEW: Many-to-many relation with Label
  labels      Label[]

  // ... existing fields (embeddings, etc.)

  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  @@index([userId, order])
  @@index([userId, notebookId]) // NEW: For filtering by notebook
}
```

### 5.2 Migration Script

```sql
-- prisma/migrations/[timestamp]/migration.sql

-- PHASE 1: Create new tables
-- ===========================

-- Create Notebook table
CREATE TABLE "Notebook" (
  id TEXT NOT NULL PRIMARY KEY,
  name TEXT NOT NULL,
  icon TEXT,
  color TEXT,
  "order" INTEGER NOT NULL,
  "userId" TEXT NOT NULL,
  "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY ("userId") REFERENCES "User"(id) ON DELETE CASCADE
);

-- Create indexes for Notebook
CREATE INDEX "Notebook_userId_order_idx" ON "Notebook"("userId", "order");
CREATE INDEX "Notebook_userId_idx" ON "Notebook"("userId");

-- PHASE 2: Add new columns to existing tables
-- =============================================

-- Add notebookId column to Note (optional, defaults to NULL)
ALTER TABLE "Note" ADD COLUMN "notebookId" TEXT;
ALTER TABLE "Note" ADD FOREIGN KEY ("notebookId") REFERENCES "Notebook"(id) ON DELETE SETNULL;
CREATE INDEX "Note_notebookId_idx" ON "Note"("userId", "notebookId");

-- Add notebookId column to Label (required - will fill in next step)
ALTER TABLE "Label" ADD COLUMN "notebookId" TEXT NOT NULL DEFAULT '';
ALTER TABLE "Label" ADD FOREIGN KEY ("notebookId") REFERENCES "Notebook"(id) ON DELETE CASCADE;
CREATE UNIQUE INDEX "Label_notebookId_name_key" ON "Label"("notebookId", "name");
CREATE INDEX "Label_notebookId_idx" ON "Label"("notebookId");

-- PHASE 3: Create junction table for Note <-> Label many-to-many
-- ==============================================================

-- Drop existing foreign key if it exists
-- (Adjust based on your current schema)

-- Create junction table
CREATE TABLE "_NoteToLabel" (
  "A" TEXT NOT NULL, -- Note ID
  "B" TEXT NOT NULL, -- Label ID
  FOREIGN KEY ("A") REFERENCES "Note"(id) ON DELETE CASCADE,
  FOREIGN KEY ("B") REFERENCES "Label"(id) ON DELETE CASCADE
);

CREATE UNIQUE INDEX "_NoteToLabel_AB_unique" ON "_NoteToLabel"("A", "B");
CREATE INDEX "_NoteToLabel_B_index" ON "_NoteToLabel"("B");

-- PHASE 4: Migrate existing data
-- ===============================

-- Step 4.1: Create a migration notebook for each user
-- This will hold all existing labels
INSERT INTO "Notebook" (id, name, icon, "order", "userId", "createdAt", "updatedAt")
SELECT
  'migrate-' || id, -- Unique ID
  'Labels Migrés',   -- Name
  '📦',             -- Icon
  999,              -- Order (at the bottom)
  id,               -- User ID
  CURRENT_TIMESTAMP,
  CURRENT_TIMESTAMP
FROM "User";

-- Step 4.2: Update all labels to belong to the migration notebook
UPDATE "Label"
SET "notebookId" = (
  SELECT 'migrate-' || "userId"
  FROM "Note"
  WHERE "Note".id = "Label"."noteId"  -- Assuming there was a relation before
  LIMIT 1
);

-- If labels had a direct userId field, use this instead:
-- UPDATE "Label" SET "notebookId" = 'migrate-' || "userId";

-- Step 4.3: Migrate existing Note <-> Label relationships to junction table
-- (Adjust based on your current schema)
INSERT INTO "_NoteToLabel" ("A", "B")
SELECT "noteId", "labelId"
FROM "Note_Labels" -- Replace with your existing junction table name
ON CONFLICT DO NOTHING;

-- PHASE 5: Cleanup (optional - can be done later)
-- ==============================================

-- Drop old columns/tables if they are no longer needed
-- Only do this after confirming the migration worked!

-- Example:
-- DROP TABLE "Note_Labels";
-- ALTER TABLE "Label" DROP COLUMN "userId";
```

### 5.3 TypeScript Migration Script

```typescript
// scripts/migrate-to-notebooks.ts

import { prisma } from '../lib/prisma'

/**
 * Phase 2: Data Migration Script
 * Run this after Prisma migrate to migrate existing data
 */
async function migrateToNotebooks() {
  console.log('🚀 Starting migration to notebooks...')

  try {
    // 1. Create migration notebook for each user
    console.log('📁 Creating migration notebooks...')
    const users = await prisma.user.findMany()

    for (const user of users) {
      const existingNotebook = await prisma.notebook.findFirst({
        where: {
          name: 'Labels Migrés',
          userId: user.id,
        },
      })

      if (!existingNotebook) {
        await prisma.notebook.create({
          data: {
            id: `migrate-${user.id}`,
            name: 'Labels Migrés',
            icon: '📦',
            order: 999,
            userId: user.id,
          },
        })
        console.log(`  ✅ Created migration notebook for user ${user.id}`)
      }
    }

    // 2. Assign all existing labels to migration notebook
    console.log('🏷️  Migrating labels...')
    const labelsWithoutNotebook = await prisma.label.findMany({
      where: {
        notebookId: '', // or null depending on your default
      },
    })

    for (const label of labelsWithoutNotebook) {
      // Find user for this label
      // (Adjust based on your current schema)
      const userId = await getUserIdForLabel(label.id)

      await prisma.label.update({
        where: { id: label.id },
        data: {
          notebookId: `migrate-${userId}`,
        },
      })
    }

    console.log(`  ✅ Migrated ${labelsWithoutNotebook.length} labels`)

    // 3. Leave all notes without notebook (they're in "Notes générales")
    console.log('📝 Notes remain in "Notes générales" (Inbox)')

    console.log('✅ Migration complete!')
    console.log('\n📊 Summary:')
    console.log(`  - Users: ${users.length}`)
    console.log(`  - Migration notebooks: ${users.length}`)
    console.log(`  - Labels migrated: ${labelsWithoutNotebook.length}`)
    console.log('\n⚠️  IMPORTANT:')
    console.log('  - All existing labels are now in "Labels Migrés" notebook')
    console.log('  - Users can reorganize them later')
    console.log('  - No notes were moved (they remain in Inbox)')

  } catch (error) {
    console.error('❌ Migration failed:', error)
    throw error
  } finally {
    await prisma.$disconnect()
  }
}

/**
 * Helper: Get user ID for a label
 * Adjust based on your current schema
 */
async function getUserIdForLabel(labelId: string): Promise<string> {
  // If label has a direct userId field:
  const label = await prisma.label.findUnique({
    where: { id: labelId },
    select: { userId: true },
  })

  if (label?.userId) return label.userId

  // If label is related to notes, get userId from a note:
  const noteWithLabel = await prisma.note.findFirst({
    where: {
      labels: { some: { id: labelId } },
    },
    select: { userId: true },
  })

  if (noteWithLabel) return noteWithLabel.userId

  throw new Error(`Could not determine userId for label ${labelId}`)
}

// Run migration
migrateToNotebooks()
  .then(() => {
    console.log('✨ All done!')
    process.exit(0)
  })
  .catch((error) => {
    console.error('💥 Fatal error:', error)
    process.exit(1)
  })
```

### 5.4 Rollback Script

```typescript
// scripts/rollback-notebooks.ts

/**
 * Rollback notebook migration
 * WARNING: This will revert all notebooks changes
 */
async function rollbackNotebooks() {
  console.log('⚠️  ROLLING BACK notebook migration...')

  try {
    // 1. Delete all notebooks
    await prisma.notebook.deleteMany({})

    // 2. Remove notebookId from labels
    await prisma.label.updateMany({
      data: { notebookId: null },
    })

    // 3. Remove notebookId from notes
    await prisma.note.updateMany({
      data: { notebookId: null },
    })

    console.log('✅ Rollback complete')

  } catch (error) {
    console.error('❌ Rollback failed:', error)
    throw error
  } finally {
    await prisma.$disconnect()
  }
}

rollbackNotebooks()
```

### 5.5 Testing the Migration

```typescript
// __tests__/migration.test.ts

import { migrateToNotebooks } from '../scripts/migrate-to-notebooks'
import { prisma } from '../lib/prisma'

describe('Notebooks Migration', () => {
  beforeAll(async () => {
    // Setup test database with old schema
    await setupOldSchema()
  })

  it('should create migration notebook for each user', async () => {
    await migrateToNotebooks()

    const users = await prisma.user.findMany()
    for (const user of users) {
      const notebook = await prisma.notebook.findFirst({
        where: {
          name: 'Labels Migrés',
          userId: user.id,
        },
      })

      expect(notebook).toBeTruthy()
      expect(notebook?.icon).toBe('📦')
    }
  })

  it('should migrate all labels to migration notebook', async () => {
    await migrateToNotebooks()

    const labelsWithoutNotebook = await prisma.label.findMany({
      where: { notebookId: null },
    })

    expect(labelsWithoutNotebook).toHaveLength(0)
  })

  it('should not move any notes to notebooks', async () => {
    const notesBefore = await prisma.note.findMany()

    await migrateToNotebooks()

    const notesAfter = await prisma.note.findMany()
    const notesInNotebooks = notesAfter.filter(n => n.notebookId !== null)

    expect(notesInNotebooks).toHaveLength(0)
    expect(notesAfter).toHaveLength(notesBefore.length)
  })
})
```

### 5.6 Migration Checklist

**Before Migration:**
- [ ] Backup database (`cp prisma/dev.db prisma/dev.db.backup`)
- [ ] Run migration in staging environment first
- [ ] Test rollback script
- [ ] Prepare user communication

**During Migration:**
- [ ] Run Prisma migrate (`npx prisma migrate dev`)
- [ ] Run data migration script (`npx tsx scripts/migrate-to-notebooks.ts`)
- [ ] Verify data integrity
- [ ] Test application with migrated data

**After Migration:**
- [ ] Monitor for errors
- [ ] Gather user feedback
- [ ] Create support documentation
- [ ] Plan Phase 4 (cleanup of old schema)

### 5.7 Edge Cases

| Edge Case | Handling |
|-----------|----------|
| User has no labels | Migration notebook created but empty |
| Label names conflict | `@@unique([notebookId, name])` prevents conflicts in same notebook |
| Very large database | Use batch processing (process 1000 records at a time) |
| Migration fails mid-way | Transaction rollback + restore from backup |
| User deletes migration notebook | Labels become orphaned (prevent deletion via UI) |

---

## 6. Server Actions: Notebooks CRUD

**Purpose:** Server Actions for managing notebooks, labels, and notes
**Type:** Next.js Server Actions
**Location:** `keep-notes/app/actions/notebooks.ts`

### 6.1 Complete Implementation

```typescript
// app/actions/notebooks.ts

'use server'

import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { undoHistory } from '@/lib/undo-history'

// ===== TYPES =====

export interface CreateNotebookInput {
  name: string
  icon?: string
  color?: string
}

export interface UpdateNotebookInput {
  name?: string
  icon?: string
  color?: string
}

export interface CreateLabelInput {
  name: string
  color?: string
  notebookId: string
}

export interface UpdateLabelInput {
  name?: string
  color?: string
}

// ===== NOTEBOOK ACTIONS =====

/**
 * Create a new notebook
 */
export async function createNotebook(data: CreateNotebookInput) {
  const session = await auth()
  if (!session?.user?.id) throw new Error('Unauthorized')

  // Get the next order value
  const lastNotebook = await prisma.notebook.findFirst({
    where: { userId: session.user.id },
    orderBy: { order: 'desc' },
  })

  const notebook = await prisma.notebook.create({
    data: {
      name: data.name,
      icon: data.icon,
      color: data.color,
      order: (lastNotebook?.order ?? -1) + 1,
      userId: session.user.id,
    },
  })

  revalidatePath('/')
  return notebook
}

/**
 * Update a notebook
 */
export async function updateNotebook(notebookId: string, data: UpdateNotebookInput) {
  const session = await auth()
  if (!session?.user?.id) throw new Error('Unauthorized')

  // Verify ownership
  const notebook = await prisma.notebook.findFirst({
    where: {
      id: notebookId,
      userId: session.user.id,
    },
  })

  if (!notebook) throw new Error('Notebook not found')

  // Update
  const updated = await prisma.notebook.update({
    where: { id: notebookId },
    data,
  })

  revalidatePath('/')
  return updated
}

/**
 * Delete a notebook (notes become general)
 */
export async function deleteNotebook(notebookId: string) {
  const session = await auth()
  if (!session?.user?.id) throw new Error('Unauthorized')

  // Verify ownership
  const notebook = await prisma.notebook.findFirst({
    where: {
      id: notebookId,
      userId: session.user.id,
    },
    include: {
      notes: true,
      labels: true,
    },
  })

  if (!notebook) throw new Error('Notebook not found')

  // Save for undo
  const notebookData = JSON.stringify(notebook)

  // Delete (CASCADE will delete labels, notes will become general)
  await prisma.notebook.delete({
    where: { id: notebookId },
  })

  // Register undo action
  undoHistory.add({
    type: 'DELETE_NOTEBOOK',
    timestamp: new Date(),
    description: `Supprimer le notebook "${notebook.name}"`,
    undo: async () => {
      const data = JSON.parse(notebookData)

      // Recreate notebook
      await prisma.notebook.create({
        data: {
          id: data.id,
          name: data.name,
          icon: data.icon,
          color: data.color,
          order: data.order,
          userId: data.userId,
        },
      })

      // Recreate labels
      for (const label of data.labels) {
        await prisma.label.create({
          data: {
            id: label.id,
            name: label.name,
            color: label.color,
            notebookId: data.id,
          },
        })
      }

      revalidatePath('/')
    },
  })

  revalidatePath('/')
}

/**
 * Update notebook order (drag & drop)
 */
export async function updateNotebookOrder(notebookIds: string[]) {
  const session = await auth()
  if (!session?.user?.id) throw new Error('Unauthorized')

  // Update each notebook's order
  const updates = notebookIds.map((id, index) =>
    prisma.notebook.updateMany({
      where: {
        id,
        userId: session.user.id,
      },
      data: { order: index },
    })
  )

  await prisma.$transaction(updates)
  revalidatePath('/')
}

// ===== LABEL ACTIONS =====

/**
 * Create a label
 */
export async function createLabel(data: CreateLabelInput) {
  const session = await auth()
  if (!session?.user?.id) throw new Error('Unauthorized')

  // Verify notebook ownership
  const notebook = await prisma.notebook.findFirst({
    where: {
      id: data.notebookId,
      userId: session.user.id,
    },
  })

  if (!notebook) throw new Error('Notebook not found')

  const label = await prisma.label.create({
    data: {
      name: data.name,
      color: data.color,
      notebookId: data.notebookId,
    },
  })

  revalidatePath('/')
  return label
}

/**
 * Update a label
 */
export async function updateLabel(labelId: string, data: UpdateLabelInput) {
  const session = await auth()
  if (!session?.user?.id) throw new Error('Unauthorized')

  // Verify ownership (via notebook)
  const label = await prisma.label.findFirst({
    where: {
      id: labelId,
      notebook: { userId: session.user.id },
    },
  })

  if (!label) throw new Error('Label not found')

  const updated = await prisma.label.update({
    where: { id: labelId },
    data,
  })

  revalidatePath('/')
  return updated
}

/**
 * Delete a label
 */
export async function deleteLabel(labelId: string) {
  const session = await auth()
  if (!session?.user?.id) throw new Error('Unauthorized')

  // Verify ownership
  const label = await prisma.label.findFirst({
    where: {
      id: labelId,
      notebook: { userId: session.user.id },
    },
  })

  if (!label) throw new Error('Label not found')

  await prisma.label.delete({
    where: { id: labelId },
  })

  revalidatePath('/')
}

// ===== NOTE ACTIONS =====

/**
 * Move a note to a notebook (or to general notes)
 */
export async function moveNoteToNotebook(noteId: string, notebookId: string | null) {
  const session = await auth()
  if (!session?.user?.id) throw new Error('Unauthorized')

  const note = await prisma.note.findUnique({
    where: { id: noteId, userId: session.user.id },
  })

  if (!note) throw new Error('Note not found')

  const previousNotebookId = note.notebookId

  // Update note
  const updated = await prisma.note.update({
    where: { id: noteId },
    data: { notebookId },
  })

  // Register undo
  undoHistory.add({
    type: 'MOVE_NOTE',
    timestamp: new Date(),
    description: `Déplacer "${note.title || 'Sans titre'}"`,
    undo: async () => {
      await prisma.note.update({
        where: { id: noteId },
        data: { notebookId: previousNotebookId },
      })
      revalidatePath('/')
    },
  })

  revalidatePath('/')
  return updated
}

/**
 * Attach a label to a note
 */
export async function attachLabel(noteId: string, labelId: string) {
  const session = await auth()
  if (!session?.user?.id) throw new Error('Unauthorized')

  const note = await prisma.note.findUnique({
    where: { id: noteId, userId: session.user.id },
  })

  if (!note) throw new Error('Note not found')

  await prisma.note.update({
    where: { id: noteId },
    data: {
      labels: { connect: { id: labelId } },
    },
  })

  revalidatePath('/')
}

/**
 * Detach a label from a note
 */
export async function detachLabel(noteId: string, labelId: string) {
  const session = await auth()
  if (!session?.user?.id) throw new Error('Unauthorized')

  const note = await prisma.note.findUnique({
    where: { id: noteId, userId: session.user.id },
  })

  if (!note) throw new Error('Note not found')

  await prisma.note.update({
    where: { id: noteId },
    data: {
      labels: { disconnect: { id: labelId } },
    },
  })

  revalidatePath('/')
}

// ===== AI ACTIONS =====

/**
 * Suggest a notebook for a note (IA1)
 */
export async function suggestNotebook(noteContent: string) {
  const session = await auth()
  if (!session?.user?.id) throw new Error('Unauthorized')

  const { NotebookSuggestionService } = await import('@/lib/ai/services/notebook-suggestion.service')
  const service = new NotebookSuggestionService()

  return await service.suggestNotebook(noteContent, session.user.id)
}

/**
 * Suggest labels for a note (IA2)
 */
export async function suggestLabels(noteContent: string, notebookId: string) {
  const session = await auth()
  if (!session?.user?.id) throw new Error('Unauthorized')

  const { ContextualAutoTagService } = await import('@/lib/ai/services/contextual-auto-tag.service')
  const service = new ContextualAutoTagService(notebookId)

  return await service.suggestLabels(noteContent)
}

/**
 * Organize inbox notes into notebooks (IA3)
 */
export async function organizeInbox(noteIds: string[]) {
  const session = await auth()
  if (!session?.user?.id) throw new Error('Unauthorized')

  const { NotebookSuggestionService } = await import('@/lib/ai/services/notebook-suggestion.service')
  const service = new NotebookSuggestionService()

  // Get notes
  const notes = await prisma.note.findMany({
    where: {
      id: { in: noteIds },
      userId: session.user.id,
      notebookId: null,
    },
  })

  // Get suggestions
  const suggestions = await service.suggestNotebooksBatch(
    notes.map(n => n.content),
    session.user.id
  )

  // Apply suggestions
  const results = new Map<string, any>()

  for (const [index, notebook] of suggestions.entries()) {
    const note = notes[index]

    if (notebook) {
      await prisma.note.update({
        where: { id: note.id },
        data: { notebookId: notebook.id },
      })
      results.set(note.id, notebook)
    } else {
      results.set(note.id, null)
    }
  }

  revalidatePath('/')
  return results
}
```

### 6.2 Error Handling

```typescript
// app/actions/notebooks.ts (add error handling wrapper)

import { z } from 'zod'

// Validation schemas
const CreateNotebookSchema = z.object({
  name: z.string().min(1).max(50),
  icon: z.string().optional(),
  color: z.string().regex(/^#[0-9A-F]{6}$/i).optional(),
})

export async function createNotebook(data: CreateNotebookInput) {
  const session = await auth()
  if (!session?.user?.id) {
    return { success: false, error: 'Unauthorized' }
  }

  // Validate input
  const validation = CreateNotebookSchema.safeParse(data)
  if (!validation.success) {
    return { success: false, error: 'Invalid input', details: validation.error.errors }
  }

  try {
    const notebook = await prisma.notebook.create({
      data: {
        ...validation.data,
        userId: session.user.id,
        order: await getNextOrder(session.user.id),
      },
    })

    revalidatePath('/')
    return { success: true, data: notebook }
  } catch (error) {
    console.error('Failed to create notebook:', error)
    return { success: false, error: 'Failed to create notebook' }
  }
}
```

### 6.3 Testing

```typescript
// __tests__/notebooks-actions.test.ts

import { createNotebook, deleteNotebook, moveNoteToNotebook } from '@/app/actions/notebooks'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'

jest.mock('@/lib/auth')
jest.mock('@/lib/prisma')

describe('Notebooks Actions', () => {
  beforeEach(() => {
    ;(auth as jest.Mock).mockResolvedValue({
      user: { id: 'user-123' },
    })
  })

  it('should create notebook', async () => {
    const mockNotebook = { id: 'nb-1', name: 'Work' }
    ;(prisma.notebook.create as jest.Mock).mockResolvedValue(mockNotebook)

    const result = await createNotebook({ name: 'Work' })

    expect(result.success).toBe(true)
    expect(result.data).toEqual(mockNotebook)
  })

  it('should fail with invalid notebook name', async () => {
    const result = await createNotebook({ name: '' })

    expect(result.success).toBe(false)
    expect(result.error).toBe('Invalid input')
  })

  it('should move note to notebook', async () => {
    const mockNote = { id: 'note-1', title: 'Test' }
    ;(prisma.note.findUnique as jest.Mock).mockResolvedValue(mockNote)
    ;(prisma.note.update as jest.Mock).mockResolvedValue({ ...mockNote, notebookId: 'nb-1' })

    const result = await moveNoteToNotebook('note-1', 'nb-1')

    expect(prisma.note.update).toHaveBeenCalledWith({
      where: { id: 'note-1' },
      data: { notebookId: 'nb-1' },
    })
  })
})
```

---

## Summary

This technical specification document provides detailed implementation guidance for:

1. ✅ **NotebooksContext** - Global state management with React Context + useOptimistic
2. ✅ **NotebooksSidebar** - Sidebar UI with Muuri drag & drop
3. ✅ **ContextualAutoTagService** - AI service for contextual label suggestions
4. ✅ **NotebookSuggestionService** - AI service for notebook suggestions
5. ✅ **Migration Script** - Database migration without breaking changes
6. ✅ **Server Actions** - Complete CRUD operations with error handling

### Implementation Priority

**Phase 1 (Foundation):**
- Migration Script (5.2, 5.3)
- NotebooksContext (1.2)
- Server Actions (6.1)

**Phase 2 (Core UI):**
- NotebooksSidebar (2.2)
- NotebookItem component
- CreateNotebookModal

**Phase 3 (Advanced):**
- Drag & drop cross-container
- Label management UI

**Phase 4 (AI):**
- ContextualAutoTagService (3.1)
- NotebookSuggestionService (4.1)
- AI integration in UI

**Phase 5 (Polish):**
- Undo/Redo
- Testing
- Performance optimization

---

**Document Status:** ✅ COMPLETE
**Ready for Implementation:** YES
**Estimated Total Time:** 5-6 weeks

---

*This technical specification was created based on the validated Architecture Decision Document.*
*Date: 2026-01-11*
*Author: Winston (Architect AI Agent)*
