Keep/_bmad-output/planning-artifacts/notebooks-tech-specs.md
sepehr 7fb486c9a4 feat: Complete internationalization and code cleanup
## Translation Files
- Add 11 new language files (es, de, pt, ru, zh, ja, ko, ar, hi, nl, pl)
- Add 100+ missing translation keys across all 15 languages
- New sections: notebook, pagination, ai.batchOrganization, ai.autoLabels
- Update nav section with workspace, quickAccess, myLibrary keys

## Component Updates
- Update 15+ components to use translation keys instead of hardcoded text
- Components: notebook dialogs, sidebar, header, note-input, ghost-tags, etc.
- Replace 80+ hardcoded English/French strings with t() calls
- Ensure consistent UI across all supported languages

## Code Quality
- Remove 77+ console.log statements from codebase
- Clean up API routes, components, hooks, and services
- Keep only essential error handling (no debugging logs)

## UI/UX Improvements
- Update Keep logo to yellow post-it style (from-yellow-400 to-amber-500)
- Change selection colors to #FEF3C6 (notebooks) and #EFB162 (nav items)
- Make "+" button permanently visible in notebooks section
- Fix grammar and syntax errors in multiple components

## Bug Fixes
- Fix JSON syntax errors in it.json, nl.json, pl.json, zh.json
- Fix syntax errors in notebook-suggestion-toast.tsx
- Fix syntax errors in use-auto-tagging.ts
- Fix syntax errors in paragraph-refactor.service.ts
- Fix duplicate "fusion" section in nl.json

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

Ou une version plus courte si vous préférez :

feat(i18n): Add 15 languages, remove logs, update UI components

- Create 11 new translation files (es, de, pt, ru, zh, ja, ko, ar, hi, nl, pl)
- Add 100+ translation keys: notebook, pagination, AI features
- Update 15+ components to use translations (80+ strings)
- Remove 77+ console.log statements from codebase
- Fix JSON syntax errors in 4 translation files
- Fix component syntax errors (toast, hooks, services)
- Update logo to yellow post-it style
- Change selection colors (#FEF3C6, #EFB162)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-11 22:26:13 +01:00

72 KiB
Raw Blame History

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
  2. Component: NotebooksSidebar
  3. Service: ContextualAutoTagService
  4. Service: NotebookSuggestionService
  5. Migration: Database Migration Script
  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

// 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

// 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

// 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

// 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

// __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

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

// 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

/* 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

// __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

// 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

// 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

// __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

// 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

// 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

// __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/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

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

// 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

// 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

// __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

// 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

// 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

// __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)