## 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>
72 KiB
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
- Component: NotebooksContext
- Component: NotebooksSidebar
- Service: ContextualAutoTagService
- Service: NotebookSuggestionService
- Migration: Database Migration Script
- 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:
- ✅ NotebooksContext - Global state management with React Context + useOptimistic
- ✅ NotebooksSidebar - Sidebar UI with Muuri drag & drop
- ✅ ContextualAutoTagService - AI service for contextual label suggestions
- ✅ NotebookSuggestionService - AI service for notebook suggestions
- ✅ Migration Script - Database migration without breaking changes
- ✅ 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)