## 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>
2757 lines
72 KiB
Markdown
2757 lines
72 KiB
Markdown
# Technical Specifications - Notebooks & Labels Contextuels
|
||
|
||
**Project:** Keep - Notebooks & Labels Contextuels
|
||
**Date:** 2026-01-11
|
||
**Status:** DRAFT
|
||
**Author:** Winston (Architect)
|
||
**Based on:** Architecture Decision Document (validated 2026-01-11)
|
||
|
||
---
|
||
|
||
## Table of Contents
|
||
|
||
1. [Component: NotebooksContext](#1-component-notebooks-context)
|
||
2. [Component: NotebooksSidebar](#2-component-notebookssidebar)
|
||
3. [Service: ContextualAutoTagService](#3-service-contextualautotagservice)
|
||
4. [Service: NotebookSuggestionService](#4-service-notebooksuggestionservice)
|
||
5. [Migration: Database Migration Script](#5-migration-database-migration-script)
|
||
6. [Server Actions: Notebooks CRUD](#6-server-actions-notebooks-crud)
|
||
|
||
---
|
||
|
||
## 1. Component: NotebooksContext
|
||
|
||
**Purpose:** Global state management for notebooks, labels, and current selection
|
||
**Type:** React Context Provider
|
||
**Location:** `keep-notes/app/context/notebooks-context.tsx`
|
||
|
||
### 1.1 TypeScript Interface
|
||
|
||
```typescript
|
||
// app/context/notebooks-context.tsx
|
||
|
||
import type { Notebook, Label, Note } from '@/lib/types'
|
||
|
||
/**
|
||
* Public interface for notebooks context
|
||
*/
|
||
export interface NotebooksContextValue {
|
||
// ===== STATE =====
|
||
/** All notebooks for the current user */
|
||
notebooks: Notebook[]
|
||
|
||
/** Currently selected notebook (null = Notes générales / Inbox) */
|
||
currentNotebook: Notebook | null
|
||
|
||
/** Labels for the currently selected notebook */
|
||
currentLabels: Label[]
|
||
|
||
/** Loading state for initial data fetch */
|
||
isLoading: boolean
|
||
|
||
/** Error state */
|
||
error: string | null
|
||
|
||
// ===== ACTIONS: NOTEBOOKS =====
|
||
|
||
/**
|
||
* Create a new notebook with optimistic UI
|
||
* @param data - Notebook creation data
|
||
* @returns Promise resolving to created notebook
|
||
*/
|
||
createNotebookOptimistic: (data: CreateNotebookInput) => Promise<Notebook>
|
||
|
||
/**
|
||
* Update an existing notebook
|
||
* @param notebookId - ID of notebook to update
|
||
* @param data - Partial update data
|
||
*/
|
||
updateNotebook: (notebookId: string, data: UpdateNotebookInput) => Promise<void>
|
||
|
||
/**
|
||
* Delete a notebook (notes become general)
|
||
* @param notebookId - ID of notebook to delete
|
||
*/
|
||
deleteNotebook: (notebookId: string) => Promise<void>
|
||
|
||
/**
|
||
* Update the order of notebooks (drag & drop result)
|
||
* @param notebookIds - Ordered array of notebook IDs
|
||
*/
|
||
updateNotebookOrderOptimistic: (notebookIds: string[]) => Promise<void>
|
||
|
||
/**
|
||
* Set the current notebook (navigation)
|
||
* @param notebook - Notebook to select (null = general notes)
|
||
*/
|
||
setCurrentNotebook: (notebook: Notebook | null) => void
|
||
|
||
// ===== ACTIONS: LABELS =====
|
||
|
||
/**
|
||
* Create a label in the current notebook
|
||
* @param data - Label creation data
|
||
*/
|
||
createLabel: (data: CreateLabelInput) => Promise<Label>
|
||
|
||
/**
|
||
* Update a label
|
||
* @param labelId - ID of label to update
|
||
* @param data - Partial update data
|
||
*/
|
||
updateLabel: (labelId: string, data: UpdateLabelInput) => Promise<void>
|
||
|
||
/**
|
||
* Delete a label
|
||
* @param labelId - ID of label to delete
|
||
*/
|
||
deleteLabel: (labelId: string) => Promise<void>
|
||
|
||
// ===== ACTIONS: NOTES =====
|
||
|
||
/**
|
||
* Move a note to a notebook (or to general notes)
|
||
* @param noteId - ID of note to move
|
||
* @param notebookId - Target notebook ID (null = general notes)
|
||
*/
|
||
moveNoteToNotebookOptimistic: (noteId: string, notebookId: string | null) => Promise<void>
|
||
|
||
/**
|
||
* Attach a label to a note
|
||
* @param noteId - ID of note
|
||
* @param labelId - ID of label to attach
|
||
*/
|
||
attachLabelToNote: (noteId: string, labelId: string) => Promise<void>
|
||
|
||
/**
|
||
* Detach a label from a note
|
||
* @param noteId - ID of note
|
||
* @param labelId - ID of label to detach
|
||
*/
|
||
detachLabelFromNote: (noteId: string, labelId: string) => Promise<void>
|
||
|
||
// ===== ACTIONS: AI =====
|
||
|
||
/**
|
||
* Suggest a notebook for a note (IA1)
|
||
* @param noteContent - Content of the note
|
||
* @returns Suggested notebook or null
|
||
*/
|
||
suggestNotebookForNote: (noteContent: string) => Promise<Notebook | null>
|
||
|
||
/**
|
||
* Suggest labels for a note in current notebook (IA2)
|
||
* @param noteContent - Content of the note
|
||
* @returns Array of suggested label names
|
||
*/
|
||
suggestLabelsForNote: (noteContent: string) => Promise<string[]>
|
||
|
||
/**
|
||
* Organize multiple notes from inbox to notebooks (IA3)
|
||
* @param noteIds - Array of note IDs to organize
|
||
* @returns Map of noteId -> suggested notebook
|
||
*/
|
||
organizeInboxBatch: (noteIds: string[]) => Promise<Map<string, Notebook | null>>
|
||
}
|
||
|
||
// ===== INPUT TYPES =====
|
||
|
||
export interface CreateNotebookInput {
|
||
name: string
|
||
icon?: string // emoji or icon name
|
||
color?: string // hex color
|
||
}
|
||
|
||
export interface UpdateNotebookInput {
|
||
name?: string
|
||
icon?: string
|
||
color?: string
|
||
}
|
||
|
||
export interface CreateLabelInput {
|
||
name: string
|
||
color?: string
|
||
}
|
||
|
||
export interface UpdateLabelInput {
|
||
name?: string
|
||
color?: string
|
||
}
|
||
```
|
||
|
||
### 1.2 Implementation
|
||
|
||
```typescript
|
||
// app/context/notebooks-context.tsx (continued)
|
||
|
||
'use client'
|
||
|
||
import { createContext, useContext, useState, useEffect, useMemo, useCallback, useOptimistic } from 'react'
|
||
import { useActionState } from 'react'
|
||
import {
|
||
createNotebook,
|
||
updateNotebook,
|
||
deleteNotebook,
|
||
updateNotebookOrder,
|
||
createLabel,
|
||
updateLabel,
|
||
deleteLabel,
|
||
moveNoteToNotebook,
|
||
attachLabel,
|
||
detachLabel,
|
||
suggestNotebook,
|
||
suggestLabels,
|
||
organizeInbox,
|
||
} from '@/app/actions/notebooks'
|
||
import type { Notebook, Label } from '@/lib/types'
|
||
|
||
export const NotebooksContext = createContext<NotebooksContextValue | null>(null)
|
||
|
||
export function useNotebooks() {
|
||
const context = useContext(NotebooksContext)
|
||
if (!context) {
|
||
throw new Error('useNotebooks must be used within NotebooksProvider')
|
||
}
|
||
return context
|
||
}
|
||
|
||
interface NotebooksProviderProps {
|
||
children: React.ReactNode
|
||
initialNotebooks?: Notebook[]
|
||
}
|
||
|
||
export function NotebooksProvider({ children, initialNotebooks = [] }: NotebooksProviderProps) {
|
||
// ===== BASE STATE =====
|
||
const [notebooks, setNotebooks] = useState<Notebook[]>(initialNotebooks)
|
||
const [currentNotebook, setCurrentNotebook] = useState<Notebook | null>(null)
|
||
const [isLoading, setIsLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
// ===== OPTIMISTIC STATE =====
|
||
const [optimisticNotebooks, addOptimisticNotebook] = useOptimistic(
|
||
notebooks,
|
||
(state, action: OptimisticAction) => {
|
||
switch (action.type) {
|
||
case 'CREATE':
|
||
return [...state, action.notebook]
|
||
case 'UPDATE_ORDER':
|
||
const orderMap = new Map(action.notebookIds.map((id, index) => [id, index]))
|
||
return [...state].sort((a, b) => (orderMap.get(a.id) ?? 999) - (orderMap.get(b.id) ?? 999))
|
||
case 'DELETE':
|
||
return state.filter(nb => nb.id !== action.notebookId)
|
||
default:
|
||
return state
|
||
}
|
||
}
|
||
)
|
||
|
||
type OptimisticAction =
|
||
| { type: 'CREATE'; notebook: Notebook }
|
||
| { type: 'UPDATE_ORDER'; notebookIds: string[] }
|
||
| { type: 'DELETE'; notebookId: string }
|
||
|
||
// ===== DERIVED STATE =====
|
||
const currentLabels = useMemo(() => {
|
||
if (!currentNotebook) return []
|
||
return notebooks
|
||
.find(nb => nb.id === currentNotebook.id)
|
||
?.labels ?? []
|
||
}, [currentNotebook, notebooks])
|
||
|
||
// ===== DATA LOADING =====
|
||
useEffect(() => {
|
||
async function loadNotebooks() {
|
||
setIsLoading(true)
|
||
setError(null)
|
||
try {
|
||
const response = await fetch('/api/notebooks')
|
||
if (!response.ok) throw new Error('Failed to load notebooks')
|
||
const data = await response.json()
|
||
setNotebooks(data.notebooks)
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||
} finally {
|
||
setIsLoading(false)
|
||
}
|
||
}
|
||
|
||
loadNotebooks()
|
||
}, [])
|
||
|
||
// ===== ACTIONS: NOTEBOOKS =====
|
||
|
||
const createNotebookOptimistic = useCallback(async (data: CreateNotebookInput) => {
|
||
const tempId = `temp-${Date.now()}`
|
||
const tempNotebook: Notebook = {
|
||
id: tempId,
|
||
name: data.name,
|
||
icon: data.icon,
|
||
color: data.color,
|
||
order: notebooks.length,
|
||
userId: '', // Will be filled by server
|
||
notes: [],
|
||
labels: [],
|
||
createdAt: new Date(),
|
||
updatedAt: new Date(),
|
||
}
|
||
|
||
// Optimistic update
|
||
addOptimisticNotebook({ type: 'CREATE', notebook: tempNotebook })
|
||
|
||
try {
|
||
const result = await createNotebook(data)
|
||
// Server will revalidate, causing a reload with real data
|
||
return result
|
||
} catch (err) {
|
||
// Error: optimistic update will be reverted on next reload
|
||
throw err
|
||
}
|
||
}, [notebooks.length, addOptimisticNotebook])
|
||
|
||
const updateNotebookOrderOptimistic = useCallback(async (notebookIds: string[]) => {
|
||
// Optimistic update
|
||
addOptimisticNotebook({ type: 'UPDATE_ORDER', notebookIds })
|
||
|
||
try {
|
||
await updateNotebookOrder(notebookIds)
|
||
} catch (err) {
|
||
throw err
|
||
}
|
||
}, [addOptimisticNotebook])
|
||
|
||
const deleteNotebook = useCallback(async (notebookId: string) => {
|
||
try {
|
||
await deleteNotebook(notebookId)
|
||
// Server revalidation will reload data
|
||
} catch (err) {
|
||
throw err
|
||
}
|
||
}, [])
|
||
|
||
const updateNotebook = useCallback(async (notebookId: string, data: UpdateNotebookInput) => {
|
||
try {
|
||
await updateNotebook(notebookId, data)
|
||
} catch (err) {
|
||
throw err
|
||
}
|
||
}, [])
|
||
|
||
// ===== ACTIONS: LABELS =====
|
||
|
||
const createLabel = useCallback(async (data: CreateLabelInput) => {
|
||
if (!currentNotebook) {
|
||
throw new Error('No notebook selected')
|
||
}
|
||
|
||
try {
|
||
const result = await createLabel({ ...data, notebookId: currentNotebook.id })
|
||
return result
|
||
} catch (err) {
|
||
throw err
|
||
}
|
||
}, [currentNotebook])
|
||
|
||
const updateLabel = useCallback(async (labelId: string, data: UpdateLabelInput) => {
|
||
try {
|
||
await updateLabel(labelId, data)
|
||
} catch (err) {
|
||
throw err
|
||
}
|
||
}, [])
|
||
|
||
const deleteLabel = useCallback(async (labelId: string) => {
|
||
try {
|
||
await deleteLabel(labelId)
|
||
} catch (err) {
|
||
throw err
|
||
}
|
||
}, [])
|
||
|
||
// ===== ACTIONS: NOTES =====
|
||
|
||
const moveNoteToNotebookOptimistic = useCallback(async (noteId: string, notebookId: string | null) => {
|
||
try {
|
||
await moveNoteToNotebook(noteId, notebookId)
|
||
// Server revalidation
|
||
} catch (err) {
|
||
throw err
|
||
}
|
||
}, [])
|
||
|
||
const attachLabelToNote = useCallback(async (noteId: string, labelId: string) => {
|
||
try {
|
||
await attachLabel(noteId, labelId)
|
||
} catch (err) {
|
||
throw err
|
||
}
|
||
}, [])
|
||
|
||
const detachLabelFromNote = useCallback(async (noteId: string, labelId: string) => {
|
||
try {
|
||
await detachLabel(noteId, labelId)
|
||
} catch (err) {
|
||
throw err
|
||
}
|
||
}, [])
|
||
|
||
// ===== ACTIONS: AI =====
|
||
|
||
const suggestNotebookForNote = useCallback(async (noteContent: string) => {
|
||
try {
|
||
const result = await suggestNotebook(noteContent)
|
||
return result
|
||
} catch (err) {
|
||
console.error('Failed to suggest notebook:', err)
|
||
return null
|
||
}
|
||
}, [])
|
||
|
||
const suggestLabelsForNote = useCallback(async (noteContent: string) => {
|
||
if (!currentNotebook) return []
|
||
|
||
try {
|
||
const result = await suggestLabels(noteContent, currentNotebook.id)
|
||
return result
|
||
} catch (err) {
|
||
console.error('Failed to suggest labels:', err)
|
||
return []
|
||
}
|
||
}, [currentNotebook])
|
||
|
||
const organizeInboxBatch = useCallback(async (noteIds: string[]) => {
|
||
try {
|
||
const result = await organizeInbox(noteIds)
|
||
return result
|
||
} catch (err) {
|
||
console.error('Failed to organize inbox:', err)
|
||
return new Map()
|
||
}
|
||
}, [])
|
||
|
||
// ===== CONTEXT VALUE =====
|
||
const value: NotebooksContextValue = useMemo(() => ({
|
||
notebooks: optimisticNotebooks,
|
||
currentNotebook,
|
||
currentLabels,
|
||
isLoading,
|
||
error,
|
||
createNotebookOptimistic,
|
||
updateNotebook,
|
||
deleteNotebook,
|
||
updateNotebookOrderOptimistic,
|
||
setCurrentNotebook,
|
||
createLabel,
|
||
updateLabel,
|
||
deleteLabel,
|
||
moveNoteToNotebookOptimistic,
|
||
attachLabelToNote,
|
||
detachLabelFromNote,
|
||
suggestNotebookForNote,
|
||
suggestLabelsForNote,
|
||
organizeInboxBatch,
|
||
}), [
|
||
optimisticNotebooks,
|
||
currentNotebook,
|
||
currentLabels,
|
||
isLoading,
|
||
error,
|
||
createNotebookOptimistic,
|
||
updateNotebook,
|
||
deleteNotebook,
|
||
updateNotebookOrderOptimistic,
|
||
createLabel,
|
||
updateLabel,
|
||
deleteLabel,
|
||
moveNoteToNotebookOptimistic,
|
||
attachLabelToNote,
|
||
detachLabelFromNote,
|
||
suggestNotebookForNote,
|
||
suggestLabelsForNote,
|
||
organizeInboxBatch,
|
||
])
|
||
|
||
return (
|
||
<NotebooksContext.Provider value={value}>
|
||
{children}
|
||
</NotebooksContext.Provider>
|
||
)
|
||
}
|
||
```
|
||
|
||
### 1.3 Integration
|
||
|
||
```typescript
|
||
// app/providers.tsx
|
||
|
||
import { NotebooksProvider } from '@/app/context/notebooks-context'
|
||
|
||
export function Providers({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<NotebooksProvider>
|
||
{children}
|
||
</NotebooksProvider>
|
||
)
|
||
}
|
||
```
|
||
|
||
### 1.4 Usage Example
|
||
|
||
```typescript
|
||
// components/notebooks-sidebar.tsx
|
||
|
||
'use client'
|
||
|
||
import { useNotebooks } from '@/app/context/notebooks-context'
|
||
|
||
export function NotebooksSidebar() {
|
||
const {
|
||
notebooks,
|
||
currentNotebook,
|
||
currentLabels,
|
||
setCurrentNotebook,
|
||
createNotebookOptimistic,
|
||
isLoading,
|
||
} = useNotebooks()
|
||
|
||
if (isLoading) return <div>Loading...</div>
|
||
|
||
return (
|
||
<div className="sidebar">
|
||
{/* Notebook list */}
|
||
{notebooks.map(notebook => (
|
||
<div
|
||
key={notebook.id}
|
||
onClick={() => setCurrentNotebook(notebook)}
|
||
className={currentNotebook?.id === notebook.id ? 'active' : ''}
|
||
>
|
||
{notebook.icon} {notebook.name}
|
||
</div>
|
||
))}
|
||
|
||
{/* Current labels */}
|
||
{currentLabels.map(label => (
|
||
<div key={label.id}>{label.name}</div>
|
||
))}
|
||
|
||
<button onClick={() => createNotebookOptimistic({ name: 'New Notebook' })}>
|
||
+ New Notebook
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
### 1.5 Testing
|
||
|
||
```typescript
|
||
// __tests__/notebooks-context.test.tsx
|
||
|
||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||
import { NotebooksProvider, useNotebooks } from '@/app/context/notebooks-context'
|
||
|
||
describe('NotebooksContext', () => {
|
||
it('should load notebooks on mount', async () => {
|
||
const wrapper = ({ children }) => <NotebooksProvider>{children}</NotebooksProvider>
|
||
|
||
const { result } = renderHook(() => useNotebooks(), { wrapper })
|
||
|
||
expect(result.current.isLoading).toBe(true)
|
||
|
||
await waitFor(() => {
|
||
expect(result.current.isLoading).toBe(false)
|
||
expect(result.current.notebooks).toBeDefined()
|
||
})
|
||
})
|
||
|
||
it('should create notebook optimistically', async () => {
|
||
const wrapper = ({ children }) => <NotebooksProvider>{children}</NotebooksProvider>
|
||
|
||
const { result } = renderHook(() => useNotebooks(), { wrapper })
|
||
|
||
await act(async () => {
|
||
await result.current.createNotebookOptimistic({ name: 'Test Notebook' })
|
||
})
|
||
|
||
// Optimistic update should be visible immediately
|
||
expect(result.current.notebooks.some(nb => nb.name === 'Test Notebook')).toBe(true)
|
||
})
|
||
|
||
it('should derive current labels from current notebook', async () => {
|
||
const wrapper = ({ children }) => (
|
||
<NotebooksProvider initialNotebooks={[mockNotebookWithLabels]}>
|
||
{children}
|
||
</NotebooksProvider>
|
||
)
|
||
|
||
const { result } = renderHook(() => useNotebooks(), { wrapper })
|
||
|
||
act(() => {
|
||
result.current.setCurrentNotebook(mockNotebookWithLabels)
|
||
})
|
||
|
||
expect(result.current.currentLabels).toEqual(mockNotebookWithLabels.labels)
|
||
})
|
||
})
|
||
```
|
||
|
||
### 1.6 Performance Considerations
|
||
|
||
- **useMemo** for derived state (currentLabels) to prevent unnecessary recalculations
|
||
- **useCallback** for all action handlers to maintain referential stability
|
||
- **useOptimistic** for instant UI feedback during server mutations
|
||
- **Debouncing** could be added to search/filter operations if needed
|
||
- **Pagination** for notebooks if user has > 100 notebooks
|
||
|
||
### 1.7 Edge Cases
|
||
|
||
| Edge Case | Handling |
|
||
|-----------|----------|
|
||
| No notebooks | Show empty state, prompt to create first notebook |
|
||
| Notebook deleted while selected | Automatically deselect (currentNotebook = null) |
|
||
| Label creation without notebook | Throw error ("No notebook selected") |
|
||
| Concurrent updates | Server wins, last write wins (optimistic) |
|
||
| Network error | Show error message, retry button |
|
||
|
||
---
|
||
|
||
## 2. Component: NotebooksSidebar
|
||
|
||
**Purpose:** Sidebar UI for displaying and managing notebooks with drag & drop
|
||
**Type:** React Client Component
|
||
**Location:** `keep-notes/app/components/notebooks-sidebar.tsx`
|
||
|
||
### 2.1 Props Interface
|
||
|
||
```typescript
|
||
interface NotebooksSidebarProps {
|
||
/** Optional className for styling */
|
||
className?: string
|
||
|
||
/** Width of the sidebar (default: 256px) */
|
||
width?: string
|
||
|
||
/** Whether to show notebook counts */
|
||
showCounts?: boolean
|
||
|
||
/** Whether to show labels in sidebar */
|
||
showLabels?: boolean
|
||
}
|
||
```
|
||
|
||
### 2.2 Implementation
|
||
|
||
```typescript
|
||
// app/components/notebooks-sidebar.tsx
|
||
|
||
'use client'
|
||
|
||
import { useEffect, useRef, useCallback, useState } from 'react'
|
||
import { useNotebooks } from '@/app/context/notebooks-context'
|
||
import { NoteItem } from './notebook-item'
|
||
import { LabelChip } from './label-chip'
|
||
import { CreateNotebookModal } from './create-notebook-modal'
|
||
|
||
export function NotebooksSidebar({
|
||
className = '',
|
||
width = 'w-64',
|
||
showCounts = true,
|
||
showLabels = true,
|
||
}: NotebooksSidebarProps) {
|
||
const {
|
||
notebooks,
|
||
currentNotebook,
|
||
currentLabels,
|
||
setCurrentNotebook,
|
||
createNotebookOptimistic,
|
||
updateNotebookOrderOptimistic,
|
||
deleteNotebook,
|
||
} = useNotebooks()
|
||
|
||
const notebooksGridRef = useRef<HTMLDivElement>(null)
|
||
const notebooksMuuri = useRef<any>(null)
|
||
const [isDragging, setIsDragging] = useState(false)
|
||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||
const [hoveredNotebook, setHoveredNotebook] = useState<string | null>(null)
|
||
|
||
// ===== MUURI DRAG & DROP =====
|
||
const handleDragEnd = useCallback(async (grid: any) => {
|
||
if (!grid) return
|
||
|
||
const items = grid.getItems()
|
||
const notebookIds = items
|
||
.map((item: any) => item.getElement()?.getAttribute('data-id'))
|
||
.filter((id: any): id is string => !!id)
|
||
|
||
try {
|
||
await updateNotebookOrderOptimistic(notebookIds)
|
||
} catch (error) {
|
||
console.error('Failed to update notebook order:', error)
|
||
} finally {
|
||
setIsDragging(false)
|
||
}
|
||
}, [updateNotebookOrderOptimistic])
|
||
|
||
useEffect(() => {
|
||
let isMounted = true
|
||
|
||
const initNotebooksMuuri = async () => {
|
||
// Dynamic import of Muuri
|
||
const MuuriClass = (await import('muuri')).default
|
||
|
||
if (!isMounted || !notebooksGridRef.current) return
|
||
|
||
// Configuration synced with masonry-grid for consistency
|
||
const layoutOptions = {
|
||
dragEnabled: true,
|
||
dragContainer: document.body,
|
||
dragStartPredicate: {
|
||
distance: 5, // More sensitive than masonry (sidebar more compact)
|
||
delay: 0,
|
||
},
|
||
dragPlaceholder: {
|
||
enabled: true,
|
||
createElement: (item: any) => {
|
||
const el = item.getElement().cloneNode(true)
|
||
el.style.opacity = '0.5'
|
||
el.style.position = 'fixed'
|
||
return el
|
||
},
|
||
},
|
||
dragAutoScroll: {
|
||
targets: [window],
|
||
speed: (item: any, target: any, intersection: any) => {
|
||
return intersection * 20
|
||
},
|
||
},
|
||
// Vertical layout for sidebar
|
||
layout: {
|
||
fillGaps: false,
|
||
horizontal: false,
|
||
alignRight: false,
|
||
alignBottom: false,
|
||
rounding: false,
|
||
},
|
||
}
|
||
|
||
notebooksMuuri.current = new MuuriClass(notebooksGridRef.current, layoutOptions)
|
||
.on('dragStart', () => setIsDragging(true))
|
||
.on('dragEnd', () => handleDragEnd(notebooksMuuri.current))
|
||
}
|
||
|
||
initNotebooksMuuri()
|
||
|
||
return () => {
|
||
isMounted = false
|
||
notebooksMuuri.current?.destroy()
|
||
notebooksMuuri.current = null
|
||
}
|
||
}, [handleDragEnd])
|
||
|
||
// Sync Muuri when notebooks change
|
||
useEffect(() => {
|
||
if (notebooksMuuri.current && !isDragging) {
|
||
requestAnimationFrame(() => {
|
||
notebooksMuuri.current?.refreshItems().layout()
|
||
})
|
||
}
|
||
}, [notebooks, isDragging])
|
||
|
||
// ===== EVENT HANDLERS =====
|
||
const handleNotebookClick = useCallback((notebook: Notebook | null) => {
|
||
setCurrentNotebook(notebook)
|
||
}, [setCurrentNotebook])
|
||
|
||
const handleNotebookHover = useCallback((notebookId: string | null) => {
|
||
setHoveredNotebook(notebookId)
|
||
}, [])
|
||
|
||
const handleDeleteNotebook = useCallback(async (notebookId: string) => {
|
||
if (confirm('Are you sure? Notes will become general.')) {
|
||
await deleteNotebook(notebookId)
|
||
}
|
||
}, [deleteNotebook])
|
||
|
||
const handleCreateNotebook = useCallback(async (data: CreateNotebookInput) => {
|
||
await createNotebookOptimistic(data)
|
||
setShowCreateModal(false)
|
||
}, [createNotebookOptimistic])
|
||
|
||
// ===== DROP TARGET FOR NOTES =====
|
||
const handleNoteDrop = useCallback(async (noteId: string) => {
|
||
if (!hoveredNotebook) return
|
||
|
||
// Note dropped on a notebook - move it
|
||
// This will be called from masonry-grid drag end
|
||
await moveNoteToNotebook(noteId, hoveredNotebook)
|
||
}, [hoveredNotebook])
|
||
|
||
// Expose drop handler to parent
|
||
useEffect(() => {
|
||
// Store handler in window for cross-component communication
|
||
// (alternative: use event bus or context)
|
||
;(window as any).notebooksSidebarDropHandler = handleNoteDrop
|
||
}, [handleNoteDrop])
|
||
|
||
// ===== RENDER =====
|
||
return (
|
||
<aside className={`notebooks-sidebar ${width} ${className}`}>
|
||
{/* Header */}
|
||
<div className="sidebar-header p-4 border-b">
|
||
<h2 className="text-sm font-semibold">Notebooks</h2>
|
||
<button
|
||
onClick={() => setShowCreateModal(true)}
|
||
className="text-xs text-blue-600 hover:underline"
|
||
>
|
||
+ New
|
||
</button>
|
||
</div>
|
||
|
||
{/* Notebook List */}
|
||
<div
|
||
ref={notebooksGridRef}
|
||
className="notebooks-list p-2"
|
||
>
|
||
{/* "Notes générales" (Inbox) - always first */}
|
||
<NoteItem
|
||
notebook={null}
|
||
isActive={!currentNotebook}
|
||
onClick={() => handleNotebookClick(null)}
|
||
onHover={() => handleNotebookHover(null)}
|
||
count={notebooks.reduce((sum, nb) => sum + (nb.notes?.length || 0), 0)}
|
||
showCount={showCounts}
|
||
/>
|
||
|
||
{/* Notebooks (Muuri items) */}
|
||
{notebooks.map(notebook => (
|
||
<div
|
||
key={notebook.id}
|
||
data-id={notebook.id}
|
||
onMouseEnter={() => handleNotebookHover(notebook.id)}
|
||
onMouseLeave={() => handleNotebookHover(null)}
|
||
>
|
||
<NoteItem
|
||
notebook={notebook}
|
||
isActive={currentNotebook?.id === notebook.id}
|
||
onClick={() => handleNotebookClick(notebook)}
|
||
onDelete={() => handleDeleteNotebook(notebook.id)}
|
||
count={notebook.notes?.length || 0}
|
||
showCount={showCounts}
|
||
isHovered={hoveredNotebook === notebook.id}
|
||
/>
|
||
|
||
{/* Labels (shown when notebook is active or hovered) */}
|
||
{showLabels && (currentNotebook?.id === notebook.id || hoveredNotebook === notebook.id) && (
|
||
<div className="labels-list ml-4 mt-1">
|
||
{notebook.labels?.map(label => (
|
||
<LabelChip
|
||
key={label.id}
|
||
label={label}
|
||
notebookId={notebook.id}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Create Modal */}
|
||
{showCreateModal && (
|
||
<CreateNotebookModal
|
||
onClose={() => setShowCreateModal(false)}
|
||
onSubmit={handleCreateNotebook}
|
||
/>
|
||
)}
|
||
</aside>
|
||
)
|
||
}
|
||
|
||
// ===== SUB-COMPONENT: NoteItem =====
|
||
interface NoteItemProps {
|
||
notebook: Notebook | null
|
||
isActive: boolean
|
||
onClick: () => void
|
||
onHover?: () => void
|
||
onDelete?: () => void
|
||
count?: number
|
||
showCount?: boolean
|
||
isHovered?: boolean
|
||
}
|
||
|
||
function NoteItem({
|
||
notebook,
|
||
isActive,
|
||
onClick,
|
||
onHover,
|
||
onDelete,
|
||
count = 0,
|
||
showCount = true,
|
||
isHovered = false,
|
||
}: NoteItemProps) {
|
||
return (
|
||
<div
|
||
className={`
|
||
notebook-item p-3 rounded cursor-pointer
|
||
transition-colors duration-150
|
||
${isActive ? 'bg-blue-100 text-blue-900' : 'hover:bg-gray-100'}
|
||
${isHovered ? 'bg-gray-50' : ''}
|
||
`}
|
||
onClick={onClick}
|
||
onMouseEnter={onHover}
|
||
>
|
||
<div className="flex items-center">
|
||
<span className="notebook-icon mr-2">
|
||
{notebook?.icon || '📝'}
|
||
</span>
|
||
<span className="notebook-name flex-1 text-sm">
|
||
{notebook?.name || 'Notes générales'}
|
||
</span>
|
||
|
||
{/* Count */}
|
||
{showCount && (
|
||
<span className="notebook-count text-xs text-gray-500 ml-2">
|
||
{count}
|
||
</span>
|
||
)}
|
||
|
||
{/* Delete button (only for non-general notebooks) */}
|
||
{notebook && isHovered && onDelete && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
onDelete()
|
||
}}
|
||
className="delete-btn text-gray-400 hover:text-red-500 ml-2"
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
### 2.3 Styles
|
||
|
||
```css
|
||
/* app/components/notebooks-sidebar.module.css */
|
||
|
||
.notebooks-sidebar {
|
||
background: #f9fafb;
|
||
border-right: 1px solid #e5e7eb;
|
||
height: 100vh;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.sidebar-header {
|
||
position: sticky;
|
||
top: 0;
|
||
background: #f9fafb;
|
||
z-index: 10;
|
||
}
|
||
|
||
.notebooks-list {
|
||
min-height: 100px;
|
||
}
|
||
|
||
/* Muuri item styles */
|
||
.notebooks-list > div {
|
||
position: absolute;
|
||
width: 100%;
|
||
}
|
||
|
||
.notebook-item.muuri-item-dragging {
|
||
z-index: 3;
|
||
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
|
||
}
|
||
|
||
.notebook-item.muuri-item-releasing {
|
||
z-index: 2;
|
||
}
|
||
|
||
/* Drop zone indicator */
|
||
.notebook-item.drop-target {
|
||
background: #dbeafe !important;
|
||
border: 2px dashed #3b82f6;
|
||
}
|
||
|
||
/* Labels list */
|
||
.labels-list {
|
||
animation: slideDown 0.2s ease-out;
|
||
}
|
||
|
||
@keyframes slideDown {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(-10px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 2.4 Testing
|
||
|
||
```typescript
|
||
// __tests__/notebooks-sidebar.test.tsx
|
||
|
||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||
import { NotebooksSidebar } from '@/app/components/notebooks-sidebar'
|
||
import { NotebooksProvider } from '@/app/context/notebooks-context'
|
||
|
||
describe('NotebooksSidebar', () => {
|
||
const mockNotebooks = [
|
||
{ id: '1', name: 'Work', icon: '💼', notes: [], labels: [] },
|
||
{ id: '2', name: 'Personal', icon: '🏠', notes: [], labels: [] },
|
||
]
|
||
|
||
beforeEach(() => {
|
||
// Mock Muuri
|
||
global.muuri = jest.fn(() => ({
|
||
on: jest.fn().mockReturnThis(),
|
||
destroy: jest.fn(),
|
||
refreshItems: jest.fn().mockReturnThis(),
|
||
layout: jest.fn().mockReturnThis(),
|
||
getItems: jest.fn(() => []),
|
||
}))
|
||
})
|
||
|
||
it('should render notebooks list', () => {
|
||
render(
|
||
<NotebooksProvider initialNotebooks={mockNotebooks}>
|
||
<NotebooksSidebar />
|
||
</NotebooksProvider>
|
||
)
|
||
|
||
expect(screen.getByText('Notes générales')).toBeInTheDocument()
|
||
expect(screen.getByText('Work')).toBeInTheDocument()
|
||
expect(screen.getByText('Personal')).toBeInTheDocument()
|
||
})
|
||
|
||
it('should call setCurrentNotebook when notebook clicked', async () => {
|
||
render(
|
||
<NotebooksProvider initialNotebooks={mockNotebooks}>
|
||
<NotebooksSidebar />
|
||
</NotebooksProvider>
|
||
)
|
||
|
||
fireEvent.click(screen.getByText('Work'))
|
||
|
||
await waitFor(() => {
|
||
// Verify current notebook changed
|
||
// (would need to check context value)
|
||
})
|
||
})
|
||
|
||
it('should show delete button on hover', async () => {
|
||
render(
|
||
<NotebooksProvider initialNotebooks={mockNotebooks}>
|
||
<NotebooksSidebar />
|
||
</NotebooksProvider>
|
||
)
|
||
|
||
const workItem = screen.getByText('Work')
|
||
|
||
fireEvent.mouseEnter(workItem)
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument()
|
||
})
|
||
})
|
||
})
|
||
```
|
||
|
||
### 2.5 Performance Considerations
|
||
|
||
- **requestAnimationFrame** for Muuri layout updates (smooth 60fps)
|
||
- **Conditional rendering** of labels (only when active/hovered)
|
||
- **Debouncing** of hover events if needed
|
||
- **Virtual scrolling** if user has > 50 notebooks (future optimization)
|
||
|
||
### 2.6 Edge Cases
|
||
|
||
| Edge Case | Handling |
|
||
|-----------|----------|
|
||
| No notebooks | Show empty state with CTA to create first notebook |
|
||
| Drag to invalid position | Muuri auto-corrects to nearest valid position |
|
||
| Drop during network error | Show error, keep optimistic state until resolved |
|
||
| Very long notebook names | CSS truncate with ellipsis, tooltip on hover |
|
||
| Very many notebooks (>50) | Add scroll + search/filter |
|
||
|
||
---
|
||
|
||
## 3. Service: ContextualAutoTagService
|
||
|
||
**Purpose:** Suggest contextual labels for notes within a specific notebook
|
||
**Type:** AI Service (Server-Side)
|
||
**Location:** `keep-notes/lib/ai/services/contextual-auto-tag.service.ts`
|
||
|
||
### 3.1 Interface
|
||
|
||
```typescript
|
||
// lib/ai/services/contextual-auto-tag.service.ts
|
||
|
||
import { prisma } from '@/lib/prisma'
|
||
import { autoTagService } from './auto-tag.service'
|
||
import type { Label, Note } from '@/lib/types'
|
||
|
||
export class ContextualAutoTagService {
|
||
constructor(private notebookId: string) {}
|
||
|
||
/**
|
||
* Suggest labels for a note, filtered by current notebook
|
||
* @param noteContent - Content of the note
|
||
* @param maxSuggestions - Maximum number of suggestions (default: 5)
|
||
* @returns Array of label names
|
||
*/
|
||
async suggestLabels(noteContent: string, maxSuggestions = 5): Promise<string[]> {
|
||
// 1. Get all labels in the current notebook
|
||
const existingLabels = await prisma.label.findMany({
|
||
where: { notebookId: this.notebookId },
|
||
select: { name: true },
|
||
})
|
||
|
||
if (existingLabels.length === 0) {
|
||
// No labels in notebook - suggest creating first label
|
||
return await this.suggestNewLabel(noteContent)
|
||
}
|
||
|
||
// 2. Get AI suggestions (from existing service)
|
||
const allSuggestions = await autoTagService.suggestTags(noteContent)
|
||
|
||
// 3. Filter to only include labels that exist in this notebook
|
||
const contextualSuggestions = allSuggestions
|
||
.filter(suggestion =>
|
||
existingLabels.some(label => label.name === suggestion)
|
||
)
|
||
.slice(0, maxSuggestions)
|
||
|
||
// 4. If no matches, suggest creating a new label
|
||
if (contextualSuggestions.length === 0) {
|
||
return [allSuggestions[0]] // Top suggestion for creation
|
||
}
|
||
|
||
return contextualSuggestions
|
||
}
|
||
|
||
/**
|
||
* Suggest a NEW label to create (when no existing labels match)
|
||
* @param noteContent - Content of the note
|
||
* @returns Single label name suggestion
|
||
*/
|
||
async suggestNewLabel(noteContent: string): Promise<string[]> {
|
||
const suggestions = await autoTagService.suggestTags(noteContent)
|
||
return [suggestions[0]] // Return top suggestion for creation
|
||
}
|
||
|
||
/**
|
||
* Automatically create a label if theme is recurring (IA4)
|
||
* @param noteContent - Content of the note
|
||
* @param threshold - Minimum occurrence count to auto-create (default: 3)
|
||
* @returns Created label or null if threshold not met
|
||
*/
|
||
async createLabelIfRecurring(
|
||
noteContent: string,
|
||
threshold = 3
|
||
): Promise<Label | null> {
|
||
const suggestions = await autoTagService.suggestTags(noteContent)
|
||
const topSuggestion = suggestions[0]
|
||
|
||
if (!topSuggestion) return null
|
||
|
||
// Check if label already exists
|
||
const existing = await prisma.label.findFirst({
|
||
where: {
|
||
notebookId: this.notebookId,
|
||
name: topSuggestion,
|
||
},
|
||
})
|
||
|
||
if (existing) return existing
|
||
|
||
// Check if this theme is recurring (appears in multiple notes)
|
||
const notesWithSimilarContent = await prisma.note.findMany({
|
||
where: {
|
||
notebookId: this.notebookId,
|
||
content: {
|
||
contains: topSuggestion,
|
||
},
|
||
},
|
||
take: threshold,
|
||
})
|
||
|
||
if (notesWithSimilarContent.length < threshold) {
|
||
return null // Not recurring enough
|
||
}
|
||
|
||
// Create the label
|
||
const newLabel = await prisma.label.create({
|
||
data: {
|
||
name: topSuggestion,
|
||
notebookId: this.notebookId,
|
||
color: this.getRandomColor(),
|
||
},
|
||
})
|
||
|
||
return newLabel
|
||
}
|
||
|
||
/**
|
||
* Get recommended labels based on recent notes in notebook
|
||
* @param limit - Number of recent notes to analyze (default: 10)
|
||
* @returns Array of most used label names
|
||
*/
|
||
async getRecommendedLabels(limit = 10): Promise<string[]> {
|
||
const recentNotes = await prisma.note.findMany({
|
||
where: {
|
||
notebookId: this.notebookId,
|
||
},
|
||
include: {
|
||
labels: true,
|
||
},
|
||
orderBy: {
|
||
updatedAt: 'desc',
|
||
},
|
||
take: limit,
|
||
})
|
||
|
||
// Count label usage
|
||
const labelCounts = new Map<string, number>()
|
||
|
||
for (const note of recentNotes) {
|
||
for (const label of note.labels) {
|
||
labelCounts.set(label.name, (labelCounts.get(label.name) || 0) + 1)
|
||
}
|
||
}
|
||
|
||
// Sort by frequency
|
||
return Array.from(labelCounts.entries())
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 5)
|
||
.map(([name]) => name)
|
||
}
|
||
|
||
/**
|
||
* Generate a random color for new labels
|
||
* @returns Hex color string
|
||
*/
|
||
private getRandomColor(): string {
|
||
const colors = [
|
||
'#ef4444', // red
|
||
'#f97316', // orange
|
||
'#f59e0b', // amber
|
||
'#84cc16', // lime
|
||
'#10b981', // emerald
|
||
'#06b6d4', // cyan
|
||
'#3b82f6', // blue
|
||
'#8b5cf6', // violet
|
||
'#d946ef', // fuchsia
|
||
'#f43f5e', // rose
|
||
]
|
||
return colors[Math.floor(Math.random() * colors.length)]
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.2 Server Action Integration
|
||
|
||
```typescript
|
||
// app/actions/notebooks.ts
|
||
|
||
'use server'
|
||
|
||
import { auth } from '@/lib/auth'
|
||
import { prisma } from '@/lib/prisma'
|
||
import { ContextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
|
||
import { revalidatePath } from 'next/cache'
|
||
|
||
/**
|
||
* Suggest contextual labels for a note
|
||
*/
|
||
export async function suggestContextualLabels(noteId: string, notebookId: string) {
|
||
const session = await auth()
|
||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||
|
||
const note = await prisma.note.findUnique({
|
||
where: { id: noteId, userId: session.user.id },
|
||
})
|
||
|
||
if (!note) throw new Error('Note not found')
|
||
|
||
const service = new ContextualAutoTagService(notebookId)
|
||
const suggestions = await service.suggestLabels(note.content)
|
||
|
||
return suggestions
|
||
}
|
||
|
||
/**
|
||
* Create a label if theme is recurring
|
||
*/
|
||
export async function autoCreateLabelIfRecurring(noteId: string) {
|
||
const session = await auth()
|
||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||
|
||
const note = await prisma.note.findUnique({
|
||
where: { id: noteId, userId: session.user.id },
|
||
})
|
||
|
||
if (!note?.notebookId) {
|
||
throw new Error('Note must be in a notebook')
|
||
}
|
||
|
||
const service = new ContextualAutoTagService(note.notebookId)
|
||
const newLabel = await service.createLabelIfRecurring(note.content)
|
||
|
||
if (newLabel) {
|
||
revalidatePath('/')
|
||
}
|
||
|
||
return newLabel
|
||
}
|
||
```
|
||
|
||
### 3.3 Testing
|
||
|
||
```typescript
|
||
// __tests__/contextual-auto-tag.service.test.ts
|
||
|
||
import { ContextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
|
||
import { prisma } from '@/lib/prisma'
|
||
|
||
jest.mock('@/lib/prisma')
|
||
jest.mock('@/lib/ai/services/auto-tag.service')
|
||
|
||
describe('ContextualAutoTagService', () => {
|
||
const mockNotebookId = 'notebook-123'
|
||
let service: ContextualAutoTagService
|
||
|
||
beforeEach(() => {
|
||
service = new ContextualAutoTagService(mockNotebookId)
|
||
})
|
||
|
||
it('should filter suggestions to existing labels', async () => {
|
||
// Mock existing labels
|
||
(prisma.label.findMany as jest.Mock).mockResolvedValue([
|
||
{ name: 'Work' },
|
||
{ name: 'Personal' },
|
||
])
|
||
|
||
// Mock AI suggestions
|
||
const { autoTagService } = require('@/lib/ai/services/auto-tag.service')
|
||
autoTagService.suggestTags.mockResolvedValue(['Work', 'Personal', 'Travel', 'Ideas'])
|
||
|
||
const suggestions = await service.suggestLabels('Meeting at 3pm')
|
||
|
||
expect(suggestions).toEqual(['Work', 'Personal']) // Only existing labels
|
||
expect(suggestions).not.toContain('Travel') // Not in this notebook
|
||
})
|
||
|
||
it('should suggest new label when no matches', async () => {
|
||
(prisma.label.findMany as jest.Mock).mockResolvedValue([])
|
||
|
||
const { autoTagService } = require('@/lib/ai/services/auto-tag.service')
|
||
autoTagService.suggestTags.mockResolvedValue(['Recipes', 'Cooking', 'Food'])
|
||
|
||
const suggestions = await service.suggestLabels('Best pasta recipe')
|
||
|
||
expect(suggestions).toEqual(['Recipes']) // Top suggestion for creation
|
||
})
|
||
|
||
it('should create label if recurring theme', async () => {
|
||
const mockLabel = { id: 'label-1', name: 'Meeting' }
|
||
;(prisma.label.findFirst as jest.Mock).mockResolvedValue(null)
|
||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([1, 2, 3]) // 3 occurrences
|
||
;(prisma.label.create as jest.Mock).mockResolvedValue(mockLabel)
|
||
|
||
const { autoTagService } = require('@/lib/ai/services/auto-tag.service')
|
||
autoTagService.suggestTags.mockResolvedValue(['Meeting'])
|
||
|
||
const result = await service.createLabelIfRecurring('Team meeting notes', 3)
|
||
|
||
expect(result).toEqual(mockLabel)
|
||
expect(prisma.label.create).toHaveBeenCalledWith({
|
||
data: {
|
||
name: 'Meeting',
|
||
notebookId: mockNotebookId,
|
||
color: expect.any(String),
|
||
},
|
||
})
|
||
})
|
||
|
||
it('should not create label if not recurring', async () => {
|
||
;(prisma.label.findFirst as jest.Mock).mockResolvedValue(null)
|
||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([1]) // Only 1 occurrence
|
||
|
||
const { autoTagService } = require('@/lib/ai/services/auto-tag.service')
|
||
autoTagService.suggestTags.mockResolvedValue(['Random'])
|
||
|
||
const result = await service.createLabelIfRecurring('Random thought', 3)
|
||
|
||
expect(result).toBeNull()
|
||
expect(prisma.label.create).not.toHaveBeenCalled()
|
||
})
|
||
})
|
||
```
|
||
|
||
### 3.4 Performance Considerations
|
||
|
||
- **Database queries** are bounded (findOne, findMany with take)
|
||
- **AI calls** happen only once per suggestion (cached in autoTagService)
|
||
- **Recurring check** uses simple string matching (could be improved with embeddings)
|
||
- **Threshold** for auto-creation prevents spam (default: 3 occurrences)
|
||
|
||
### 3.5 Edge Cases
|
||
|
||
| Edge Case | Handling |
|
||
|-----------|----------|
|
||
| Notebook has no labels | Suggest creating first label |
|
||
| AI service fails | Return empty array, log error |
|
||
| Empty note content | Return empty array |
|
||
| Note already has all labels | Return empty array |
|
||
| Very long note content | Truncate to 2000 chars before AI call |
|
||
|
||
---
|
||
|
||
## 4. Service: NotebookSuggestionService
|
||
|
||
**Purpose:** Suggest which notebook a note should belong to (IA1)
|
||
**Type:** AI Service (Server-Side)
|
||
**Location:** `keep-notes/lib/ai/services/notebook-suggestion.service.ts`
|
||
|
||
### 4.1 Interface
|
||
|
||
```typescript
|
||
// lib/ai/services/notebook-suggestion.service.ts
|
||
|
||
import { prisma } from '@/lib/prisma'
|
||
import { generateText } from 'ai'
|
||
import { getOpenAIModel } from '@/lib/ai/providers/openai'
|
||
import type { Notebook, Note } from '@/lib/types'
|
||
|
||
export class NotebookSuggestionService {
|
||
/**
|
||
* Suggest the most appropriate notebook for a note
|
||
* @param noteContent - Content of the note
|
||
* @param userId - User ID (for fetching user's notebooks)
|
||
* @returns Suggested notebook or null (if no good match)
|
||
*/
|
||
async suggestNotebook(noteContent: string, userId: string): Promise<Notebook | null> {
|
||
// 1. Get all notebooks for this user
|
||
const notebooks = await prisma.notebook.findMany({
|
||
where: { userId },
|
||
include: {
|
||
labels: true,
|
||
_count: {
|
||
select: { notes: true },
|
||
},
|
||
},
|
||
orderBy: { order: 'asc' },
|
||
})
|
||
|
||
if (notebooks.length === 0) {
|
||
return null // No notebooks to suggest
|
||
}
|
||
|
||
// 2. Build prompt for AI
|
||
const prompt = this.buildPrompt(noteContent, notebooks)
|
||
|
||
// 3. Call AI
|
||
try {
|
||
const response = await generateText({
|
||
model: getOpenAIModel(),
|
||
prompt,
|
||
temperature: 0.3, // Low temperature for consistent suggestions
|
||
maxTokens: 50,
|
||
})
|
||
|
||
const suggestedName = response.text.trim().toUpperCase()
|
||
|
||
// 4. Find matching notebook
|
||
const suggestedNotebook = notebooks.find(nb =>
|
||
nb.name.toUpperCase() === suggestedName
|
||
)
|
||
|
||
// If AI says "NONE" or no match, return null
|
||
if (suggestedName === 'NONE' || !suggestedNotebook) {
|
||
return null
|
||
}
|
||
|
||
return suggestedNotebook
|
||
} catch (error) {
|
||
console.error('Failed to suggest notebook:', error)
|
||
return null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Build the AI prompt for notebook suggestion
|
||
*/
|
||
private buildPrompt(noteContent: string, notebooks: Notebook[]): string {
|
||
const notebookList = notebooks
|
||
.map(nb => {
|
||
const labels = nb.labels.map(l => l.name).join(', ')
|
||
const count = nb._count?.notes || 0
|
||
return `- ${nb.name} (${count} notes)${labels ? ` [labels: ${labels}]` : ''}`
|
||
})
|
||
.join('\n')
|
||
|
||
return `
|
||
You are a helpful assistant that suggests which notebook a note should belong to.
|
||
|
||
NOTE CONTENT:
|
||
${noteContent.substring(0, 500)}
|
||
|
||
AVAILABLE NOTEBOOKS:
|
||
${notebookList}
|
||
|
||
TASK:
|
||
Analyze the note content and suggest the MOST appropriate notebook for this note.
|
||
Consider:
|
||
1. The topic/theme of the note
|
||
2. Existing labels in each notebook
|
||
3. The number of notes (prefer notebooks with related content)
|
||
|
||
RULES:
|
||
- Return ONLY the notebook name, EXACTLY as shown above (case-insensitive)
|
||
- If no good match exists, return "NONE"
|
||
- If the note is too generic/vague, return "NONE"
|
||
- Do NOT include explanations or extra text
|
||
|
||
Examples:
|
||
- If note is about "Meeting with John about project timeline" and there's a "Work" notebook → return "WORK"
|
||
- If note is about "Grocery list: milk, eggs, bread" and there's no "Personal" or "Shopping" notebook → return "NONE"
|
||
- If note is about "Python script for data analysis" and there's a "Coding" notebook → return "CODING"
|
||
|
||
Your suggestion:
|
||
`.trim()
|
||
}
|
||
|
||
/**
|
||
* Batch suggest notebooks for multiple notes (IA3)
|
||
* @param noteContents - Array of note contents
|
||
* @param userId - User ID
|
||
* @returns Map of note index -> suggested notebook
|
||
*/
|
||
async suggestNotebooksBatch(
|
||
noteContents: string[],
|
||
userId: string
|
||
): Promise<Map<number, Notebook | null>> {
|
||
const results = new Map<number, Notebook | null>()
|
||
|
||
// For efficiency, we could batch this into a single AI call
|
||
// For now, process sequentially (could be parallelized)
|
||
for (let i = 0; i < noteContents.length; i++) {
|
||
const suggestion = await this.suggestNotebook(noteContents[i], userId)
|
||
results.set(i, suggestion)
|
||
}
|
||
|
||
return results
|
||
}
|
||
|
||
/**
|
||
* Get notebook suggestion confidence score
|
||
* (For future UI enhancement: show confidence level)
|
||
*/
|
||
async suggestNotebookWithConfidence(
|
||
noteContent: string,
|
||
userId: string
|
||
): Promise<{ notebook: Notebook | null; confidence: number }> {
|
||
// This could use logprobs from OpenAI API to calculate confidence
|
||
// For now, return binary confidence
|
||
const notebook = await this.suggestNotebook(noteContent, userId)
|
||
return {
|
||
notebook,
|
||
confidence: notebook ? 0.8 : 0, // Placeholder
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 4.2 Server Action Integration
|
||
|
||
```typescript
|
||
// app/actions/notebooks.ts
|
||
|
||
/**
|
||
* Suggest a notebook for a note (IA1)
|
||
*/
|
||
export async function suggestNotebookForNote(noteId: string) {
|
||
const session = await auth()
|
||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||
|
||
const note = await prisma.note.findUnique({
|
||
where: { id: noteId, userId: session.user.id },
|
||
include: {
|
||
notebook: true,
|
||
},
|
||
})
|
||
|
||
if (!note) throw new Error('Note not found')
|
||
|
||
// Already in a notebook - no suggestion needed
|
||
if (note.notebook) {
|
||
return null
|
||
}
|
||
|
||
const service = new NotebookSuggestionService()
|
||
const suggestion = await service.suggestNotebook(note.content, session.user.id)
|
||
|
||
return suggestion
|
||
}
|
||
|
||
/**
|
||
* Organize multiple notes from inbox to notebooks (IA3)
|
||
*/
|
||
export async function organizeInbox(noteIds: string[]) {
|
||
const session = await auth()
|
||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||
|
||
const notes = await prisma.note.findMany({
|
||
where: {
|
||
id: { in: noteIds },
|
||
userId: session.user.id,
|
||
notebookId: null, // Only general notes
|
||
},
|
||
})
|
||
|
||
const service = new NotebookSuggestionService()
|
||
const suggestions = await service.suggestNotebooksBatch(
|
||
notes.map(n => n.content),
|
||
session.user.id
|
||
)
|
||
|
||
// Apply suggestions
|
||
const updates: Promise<any>[] = []
|
||
const results = new Map<string, Notebook | null>()
|
||
|
||
for (let [index, notebook] of suggestions.entries()) {
|
||
const note = notes[index]
|
||
if (notebook) {
|
||
updates.push(
|
||
prisma.note.update({
|
||
where: { id: note.id },
|
||
data: { notebookId: notebook.id },
|
||
})
|
||
)
|
||
results.set(note.id, notebook)
|
||
} else {
|
||
results.set(note.id, null) // No suggestion
|
||
}
|
||
}
|
||
|
||
await Promise.all(updates)
|
||
revalidatePath('/')
|
||
|
||
return results
|
||
}
|
||
```
|
||
|
||
### 4.3 Testing
|
||
|
||
```typescript
|
||
// __tests__/notebook-suggestion.service.test.ts
|
||
|
||
import { NotebookSuggestionService } from '@/lib/ai/services/notebook-suggestion.service'
|
||
import { prisma } from '@/lib/prisma'
|
||
|
||
jest.mock('@/lib/prisma')
|
||
jest.mock('ai')
|
||
|
||
describe('NotebookSuggestionService', () => {
|
||
let service: NotebookSuggestionService
|
||
const mockUserId = 'user-123'
|
||
|
||
beforeEach(() => {
|
||
service = new NotebookSuggestionService()
|
||
})
|
||
|
||
it('should suggest appropriate notebook', async () => {
|
||
const mockNotebooks = [
|
||
{ id: '1', name: 'Work', labels: [], _count: { notes: 10 } },
|
||
{ id: '2', name: 'Personal', labels: [], _count: { notes: 5 } },
|
||
]
|
||
|
||
;(prisma.notebook.findMany as jest.Mock).mockResolvedValue(mockNotebooks)
|
||
|
||
const { generateText } = require('ai')
|
||
generateText.mockResolvedValue({
|
||
text: 'WORK',
|
||
})
|
||
|
||
const result = await service.suggestNotebook('Meeting with team', mockUserId)
|
||
|
||
expect(result).toEqual(mockNotebooks[0])
|
||
})
|
||
|
||
it('should return null when AI says NONE', async () => {
|
||
;(prisma.notebook.findMany as jest.Mock).mockResolvedValue([
|
||
{ id: '1', name: 'Work', labels: [], _count: { notes: 10 } },
|
||
])
|
||
|
||
const { generateText } = require('ai')
|
||
generateText.mockResolvedValue({
|
||
text: 'NONE',
|
||
})
|
||
|
||
const result = await service.suggestNotebook('Random thought', mockUserId)
|
||
|
||
expect(result).toBeNull()
|
||
})
|
||
|
||
it('should return null when no notebooks exist', async () => {
|
||
;(prisma.notebook.findMany as jest.Mock).mockResolvedValue([])
|
||
|
||
const result = await service.suggestNotebook('Any content', mockUserId)
|
||
|
||
expect(result).toBeNull()
|
||
expect(generateText).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('should handle batch suggestions', async () => {
|
||
const mockNotebooks = [
|
||
{ id: '1', name: 'Work', labels: [], _count: { notes: 10 } },
|
||
{ id: '2', name: 'Personal', labels: [], _count: { notes: 5 } },
|
||
]
|
||
|
||
;(prisma.notebook.findMany as jest.Mock).mockResolvedValue(mockNotebooks)
|
||
|
||
const { generateText } = require('ai')
|
||
generateText
|
||
.mockResolvedValueOnce({ text: 'WORK' })
|
||
.mockResolvedValueOnce({ text: 'PERSONAL' })
|
||
|
||
const results = await service.suggestNotebooksBatch(
|
||
['Meeting notes', 'Grocery list'],
|
||
mockUserId
|
||
)
|
||
|
||
expect(results.get(0)).toEqual(mockNotebooks[0])
|
||
expect(results.get(1)).toEqual(mockNotebooks[1])
|
||
})
|
||
})
|
||
```
|
||
|
||
### 4.4 Performance Considerations
|
||
|
||
- **Sequential AI calls** in batch could be slow (optimize with single batch call)
|
||
- **Caching** could be added for repeated suggestions
|
||
- **Max tokens** limited to 50 for fast responses
|
||
- **Low temperature** (0.3) for consistent, deterministic suggestions
|
||
|
||
### 4.5 Edge Cases
|
||
|
||
| Edge Case | Handling |
|
||
|-----------|----------|
|
||
| No notebooks | Return null immediately (no AI call) |
|
||
| Note already in notebook | Return null (no suggestion needed) |
|
||
| AI returns invalid notebook name | Return null (no match found) |
|
||
| Empty note content | Return null (too vague) |
|
||
| Note content too long | Truncate to 500 chars for prompt |
|
||
|
||
---
|
||
|
||
## 5. Migration: Database Migration Script
|
||
|
||
**Purpose:** Migrate from flat tags system to notebooks without breaking changes
|
||
**Type:** Prisma Migration Script
|
||
**Location:** `keep-notes/prisma/migrations/[timestamp]/migration.sql`
|
||
|
||
### 5.1 Prisma Schema Changes
|
||
|
||
```prisma
|
||
// prisma/schema.prisma
|
||
|
||
// NEW: Notebook model
|
||
model Notebook {
|
||
id String @id @default(cuid())
|
||
name String
|
||
icon String?
|
||
color String?
|
||
order Int
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
notes Note[]
|
||
labels Label[]
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@index([userId, order])
|
||
@@index([userId])
|
||
}
|
||
|
||
// MODIFIED: Label model (now belongs to a notebook)
|
||
model Label {
|
||
id String @id @default(cuid())
|
||
name String
|
||
color String?
|
||
notebookId String // NEW: Required
|
||
notebook Notebook @relation(fields: [notebookId], references: [id], onDelete: Cascade)
|
||
notes Note[]
|
||
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@unique([notebookId, name]) // Unique within notebook
|
||
@@index([notebookId])
|
||
}
|
||
|
||
// MODIFIED: Note model (now optionally belongs to a notebook)
|
||
model Note {
|
||
id String @id @default(cuid())
|
||
title String?
|
||
content String
|
||
isPinned Boolean @default(false)
|
||
size String @default("small")
|
||
order Int
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
// NEW: Optional relation to Notebook
|
||
notebookId String? // NULL = "Notes générales"
|
||
notebook Notebook? @relation(fields: [notebookId], references: [id], onDelete: SetNull)
|
||
|
||
// NEW: Many-to-many relation with Label
|
||
labels Label[]
|
||
|
||
// ... existing fields (embeddings, etc.)
|
||
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@index([userId, order])
|
||
@@index([userId, notebookId]) // NEW: For filtering by notebook
|
||
}
|
||
```
|
||
|
||
### 5.2 Migration Script
|
||
|
||
```sql
|
||
-- prisma/migrations/[timestamp]/migration.sql
|
||
|
||
-- PHASE 1: Create new tables
|
||
-- ===========================
|
||
|
||
-- Create Notebook table
|
||
CREATE TABLE "Notebook" (
|
||
id TEXT NOT NULL PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
icon TEXT,
|
||
color TEXT,
|
||
"order" INTEGER NOT NULL,
|
||
"userId" TEXT NOT NULL,
|
||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY ("userId") REFERENCES "User"(id) ON DELETE CASCADE
|
||
);
|
||
|
||
-- Create indexes for Notebook
|
||
CREATE INDEX "Notebook_userId_order_idx" ON "Notebook"("userId", "order");
|
||
CREATE INDEX "Notebook_userId_idx" ON "Notebook"("userId");
|
||
|
||
-- PHASE 2: Add new columns to existing tables
|
||
-- =============================================
|
||
|
||
-- Add notebookId column to Note (optional, defaults to NULL)
|
||
ALTER TABLE "Note" ADD COLUMN "notebookId" TEXT;
|
||
ALTER TABLE "Note" ADD FOREIGN KEY ("notebookId") REFERENCES "Notebook"(id) ON DELETE SETNULL;
|
||
CREATE INDEX "Note_notebookId_idx" ON "Note"("userId", "notebookId");
|
||
|
||
-- Add notebookId column to Label (required - will fill in next step)
|
||
ALTER TABLE "Label" ADD COLUMN "notebookId" TEXT NOT NULL DEFAULT '';
|
||
ALTER TABLE "Label" ADD FOREIGN KEY ("notebookId") REFERENCES "Notebook"(id) ON DELETE CASCADE;
|
||
CREATE UNIQUE INDEX "Label_notebookId_name_key" ON "Label"("notebookId", "name");
|
||
CREATE INDEX "Label_notebookId_idx" ON "Label"("notebookId");
|
||
|
||
-- PHASE 3: Create junction table for Note <-> Label many-to-many
|
||
-- ==============================================================
|
||
|
||
-- Drop existing foreign key if it exists
|
||
-- (Adjust based on your current schema)
|
||
|
||
-- Create junction table
|
||
CREATE TABLE "_NoteToLabel" (
|
||
"A" TEXT NOT NULL, -- Note ID
|
||
"B" TEXT NOT NULL, -- Label ID
|
||
FOREIGN KEY ("A") REFERENCES "Note"(id) ON DELETE CASCADE,
|
||
FOREIGN KEY ("B") REFERENCES "Label"(id) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE UNIQUE INDEX "_NoteToLabel_AB_unique" ON "_NoteToLabel"("A", "B");
|
||
CREATE INDEX "_NoteToLabel_B_index" ON "_NoteToLabel"("B");
|
||
|
||
-- PHASE 4: Migrate existing data
|
||
-- ===============================
|
||
|
||
-- Step 4.1: Create a migration notebook for each user
|
||
-- This will hold all existing labels
|
||
INSERT INTO "Notebook" (id, name, icon, "order", "userId", "createdAt", "updatedAt")
|
||
SELECT
|
||
'migrate-' || id, -- Unique ID
|
||
'Labels Migrés', -- Name
|
||
'📦', -- Icon
|
||
999, -- Order (at the bottom)
|
||
id, -- User ID
|
||
CURRENT_TIMESTAMP,
|
||
CURRENT_TIMESTAMP
|
||
FROM "User";
|
||
|
||
-- Step 4.2: Update all labels to belong to the migration notebook
|
||
UPDATE "Label"
|
||
SET "notebookId" = (
|
||
SELECT 'migrate-' || "userId"
|
||
FROM "Note"
|
||
WHERE "Note".id = "Label"."noteId" -- Assuming there was a relation before
|
||
LIMIT 1
|
||
);
|
||
|
||
-- If labels had a direct userId field, use this instead:
|
||
-- UPDATE "Label" SET "notebookId" = 'migrate-' || "userId";
|
||
|
||
-- Step 4.3: Migrate existing Note <-> Label relationships to junction table
|
||
-- (Adjust based on your current schema)
|
||
INSERT INTO "_NoteToLabel" ("A", "B")
|
||
SELECT "noteId", "labelId"
|
||
FROM "Note_Labels" -- Replace with your existing junction table name
|
||
ON CONFLICT DO NOTHING;
|
||
|
||
-- PHASE 5: Cleanup (optional - can be done later)
|
||
-- ==============================================
|
||
|
||
-- Drop old columns/tables if they are no longer needed
|
||
-- Only do this after confirming the migration worked!
|
||
|
||
-- Example:
|
||
-- DROP TABLE "Note_Labels";
|
||
-- ALTER TABLE "Label" DROP COLUMN "userId";
|
||
```
|
||
|
||
### 5.3 TypeScript Migration Script
|
||
|
||
```typescript
|
||
// scripts/migrate-to-notebooks.ts
|
||
|
||
import { prisma } from '../lib/prisma'
|
||
|
||
/**
|
||
* Phase 2: Data Migration Script
|
||
* Run this after Prisma migrate to migrate existing data
|
||
*/
|
||
async function migrateToNotebooks() {
|
||
console.log('🚀 Starting migration to notebooks...')
|
||
|
||
try {
|
||
// 1. Create migration notebook for each user
|
||
console.log('📁 Creating migration notebooks...')
|
||
const users = await prisma.user.findMany()
|
||
|
||
for (const user of users) {
|
||
const existingNotebook = await prisma.notebook.findFirst({
|
||
where: {
|
||
name: 'Labels Migrés',
|
||
userId: user.id,
|
||
},
|
||
})
|
||
|
||
if (!existingNotebook) {
|
||
await prisma.notebook.create({
|
||
data: {
|
||
id: `migrate-${user.id}`,
|
||
name: 'Labels Migrés',
|
||
icon: '📦',
|
||
order: 999,
|
||
userId: user.id,
|
||
},
|
||
})
|
||
console.log(` ✅ Created migration notebook for user ${user.id}`)
|
||
}
|
||
}
|
||
|
||
// 2. Assign all existing labels to migration notebook
|
||
console.log('🏷️ Migrating labels...')
|
||
const labelsWithoutNotebook = await prisma.label.findMany({
|
||
where: {
|
||
notebookId: '', // or null depending on your default
|
||
},
|
||
})
|
||
|
||
for (const label of labelsWithoutNotebook) {
|
||
// Find user for this label
|
||
// (Adjust based on your current schema)
|
||
const userId = await getUserIdForLabel(label.id)
|
||
|
||
await prisma.label.update({
|
||
where: { id: label.id },
|
||
data: {
|
||
notebookId: `migrate-${userId}`,
|
||
},
|
||
})
|
||
}
|
||
|
||
console.log(` ✅ Migrated ${labelsWithoutNotebook.length} labels`)
|
||
|
||
// 3. Leave all notes without notebook (they're in "Notes générales")
|
||
console.log('📝 Notes remain in "Notes générales" (Inbox)')
|
||
|
||
console.log('✅ Migration complete!')
|
||
console.log('\n📊 Summary:')
|
||
console.log(` - Users: ${users.length}`)
|
||
console.log(` - Migration notebooks: ${users.length}`)
|
||
console.log(` - Labels migrated: ${labelsWithoutNotebook.length}`)
|
||
console.log('\n⚠️ IMPORTANT:')
|
||
console.log(' - All existing labels are now in "Labels Migrés" notebook')
|
||
console.log(' - Users can reorganize them later')
|
||
console.log(' - No notes were moved (they remain in Inbox)')
|
||
|
||
} catch (error) {
|
||
console.error('❌ Migration failed:', error)
|
||
throw error
|
||
} finally {
|
||
await prisma.$disconnect()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Helper: Get user ID for a label
|
||
* Adjust based on your current schema
|
||
*/
|
||
async function getUserIdForLabel(labelId: string): Promise<string> {
|
||
// If label has a direct userId field:
|
||
const label = await prisma.label.findUnique({
|
||
where: { id: labelId },
|
||
select: { userId: true },
|
||
})
|
||
|
||
if (label?.userId) return label.userId
|
||
|
||
// If label is related to notes, get userId from a note:
|
||
const noteWithLabel = await prisma.note.findFirst({
|
||
where: {
|
||
labels: { some: { id: labelId } },
|
||
},
|
||
select: { userId: true },
|
||
})
|
||
|
||
if (noteWithLabel) return noteWithLabel.userId
|
||
|
||
throw new Error(`Could not determine userId for label ${labelId}`)
|
||
}
|
||
|
||
// Run migration
|
||
migrateToNotebooks()
|
||
.then(() => {
|
||
console.log('✨ All done!')
|
||
process.exit(0)
|
||
})
|
||
.catch((error) => {
|
||
console.error('💥 Fatal error:', error)
|
||
process.exit(1)
|
||
})
|
||
```
|
||
|
||
### 5.4 Rollback Script
|
||
|
||
```typescript
|
||
// scripts/rollback-notebooks.ts
|
||
|
||
/**
|
||
* Rollback notebook migration
|
||
* WARNING: This will revert all notebooks changes
|
||
*/
|
||
async function rollbackNotebooks() {
|
||
console.log('⚠️ ROLLING BACK notebook migration...')
|
||
|
||
try {
|
||
// 1. Delete all notebooks
|
||
await prisma.notebook.deleteMany({})
|
||
|
||
// 2. Remove notebookId from labels
|
||
await prisma.label.updateMany({
|
||
data: { notebookId: null },
|
||
})
|
||
|
||
// 3. Remove notebookId from notes
|
||
await prisma.note.updateMany({
|
||
data: { notebookId: null },
|
||
})
|
||
|
||
console.log('✅ Rollback complete')
|
||
|
||
} catch (error) {
|
||
console.error('❌ Rollback failed:', error)
|
||
throw error
|
||
} finally {
|
||
await prisma.$disconnect()
|
||
}
|
||
}
|
||
|
||
rollbackNotebooks()
|
||
```
|
||
|
||
### 5.5 Testing the Migration
|
||
|
||
```typescript
|
||
// __tests__/migration.test.ts
|
||
|
||
import { migrateToNotebooks } from '../scripts/migrate-to-notebooks'
|
||
import { prisma } from '../lib/prisma'
|
||
|
||
describe('Notebooks Migration', () => {
|
||
beforeAll(async () => {
|
||
// Setup test database with old schema
|
||
await setupOldSchema()
|
||
})
|
||
|
||
it('should create migration notebook for each user', async () => {
|
||
await migrateToNotebooks()
|
||
|
||
const users = await prisma.user.findMany()
|
||
for (const user of users) {
|
||
const notebook = await prisma.notebook.findFirst({
|
||
where: {
|
||
name: 'Labels Migrés',
|
||
userId: user.id,
|
||
},
|
||
})
|
||
|
||
expect(notebook).toBeTruthy()
|
||
expect(notebook?.icon).toBe('📦')
|
||
}
|
||
})
|
||
|
||
it('should migrate all labels to migration notebook', async () => {
|
||
await migrateToNotebooks()
|
||
|
||
const labelsWithoutNotebook = await prisma.label.findMany({
|
||
where: { notebookId: null },
|
||
})
|
||
|
||
expect(labelsWithoutNotebook).toHaveLength(0)
|
||
})
|
||
|
||
it('should not move any notes to notebooks', async () => {
|
||
const notesBefore = await prisma.note.findMany()
|
||
|
||
await migrateToNotebooks()
|
||
|
||
const notesAfter = await prisma.note.findMany()
|
||
const notesInNotebooks = notesAfter.filter(n => n.notebookId !== null)
|
||
|
||
expect(notesInNotebooks).toHaveLength(0)
|
||
expect(notesAfter).toHaveLength(notesBefore.length)
|
||
})
|
||
})
|
||
```
|
||
|
||
### 5.6 Migration Checklist
|
||
|
||
**Before Migration:**
|
||
- [ ] Backup database (`cp prisma/dev.db prisma/dev.db.backup`)
|
||
- [ ] Run migration in staging environment first
|
||
- [ ] Test rollback script
|
||
- [ ] Prepare user communication
|
||
|
||
**During Migration:**
|
||
- [ ] Run Prisma migrate (`npx prisma migrate dev`)
|
||
- [ ] Run data migration script (`npx tsx scripts/migrate-to-notebooks.ts`)
|
||
- [ ] Verify data integrity
|
||
- [ ] Test application with migrated data
|
||
|
||
**After Migration:**
|
||
- [ ] Monitor for errors
|
||
- [ ] Gather user feedback
|
||
- [ ] Create support documentation
|
||
- [ ] Plan Phase 4 (cleanup of old schema)
|
||
|
||
### 5.7 Edge Cases
|
||
|
||
| Edge Case | Handling |
|
||
|-----------|----------|
|
||
| User has no labels | Migration notebook created but empty |
|
||
| Label names conflict | `@@unique([notebookId, name])` prevents conflicts in same notebook |
|
||
| Very large database | Use batch processing (process 1000 records at a time) |
|
||
| Migration fails mid-way | Transaction rollback + restore from backup |
|
||
| User deletes migration notebook | Labels become orphaned (prevent deletion via UI) |
|
||
|
||
---
|
||
|
||
## 6. Server Actions: Notebooks CRUD
|
||
|
||
**Purpose:** Server Actions for managing notebooks, labels, and notes
|
||
**Type:** Next.js Server Actions
|
||
**Location:** `keep-notes/app/actions/notebooks.ts`
|
||
|
||
### 6.1 Complete Implementation
|
||
|
||
```typescript
|
||
// app/actions/notebooks.ts
|
||
|
||
'use server'
|
||
|
||
import { auth } from '@/lib/auth'
|
||
import { prisma } from '@/lib/prisma'
|
||
import { revalidatePath } from 'next/cache'
|
||
import { undoHistory } from '@/lib/undo-history'
|
||
|
||
// ===== TYPES =====
|
||
|
||
export interface CreateNotebookInput {
|
||
name: string
|
||
icon?: string
|
||
color?: string
|
||
}
|
||
|
||
export interface UpdateNotebookInput {
|
||
name?: string
|
||
icon?: string
|
||
color?: string
|
||
}
|
||
|
||
export interface CreateLabelInput {
|
||
name: string
|
||
color?: string
|
||
notebookId: string
|
||
}
|
||
|
||
export interface UpdateLabelInput {
|
||
name?: string
|
||
color?: string
|
||
}
|
||
|
||
// ===== NOTEBOOK ACTIONS =====
|
||
|
||
/**
|
||
* Create a new notebook
|
||
*/
|
||
export async function createNotebook(data: CreateNotebookInput) {
|
||
const session = await auth()
|
||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||
|
||
// Get the next order value
|
||
const lastNotebook = await prisma.notebook.findFirst({
|
||
where: { userId: session.user.id },
|
||
orderBy: { order: 'desc' },
|
||
})
|
||
|
||
const notebook = await prisma.notebook.create({
|
||
data: {
|
||
name: data.name,
|
||
icon: data.icon,
|
||
color: data.color,
|
||
order: (lastNotebook?.order ?? -1) + 1,
|
||
userId: session.user.id,
|
||
},
|
||
})
|
||
|
||
revalidatePath('/')
|
||
return notebook
|
||
}
|
||
|
||
/**
|
||
* Update a notebook
|
||
*/
|
||
export async function updateNotebook(notebookId: string, data: UpdateNotebookInput) {
|
||
const session = await auth()
|
||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||
|
||
// Verify ownership
|
||
const notebook = await prisma.notebook.findFirst({
|
||
where: {
|
||
id: notebookId,
|
||
userId: session.user.id,
|
||
},
|
||
})
|
||
|
||
if (!notebook) throw new Error('Notebook not found')
|
||
|
||
// Update
|
||
const updated = await prisma.notebook.update({
|
||
where: { id: notebookId },
|
||
data,
|
||
})
|
||
|
||
revalidatePath('/')
|
||
return updated
|
||
}
|
||
|
||
/**
|
||
* Delete a notebook (notes become general)
|
||
*/
|
||
export async function deleteNotebook(notebookId: string) {
|
||
const session = await auth()
|
||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||
|
||
// Verify ownership
|
||
const notebook = await prisma.notebook.findFirst({
|
||
where: {
|
||
id: notebookId,
|
||
userId: session.user.id,
|
||
},
|
||
include: {
|
||
notes: true,
|
||
labels: true,
|
||
},
|
||
})
|
||
|
||
if (!notebook) throw new Error('Notebook not found')
|
||
|
||
// Save for undo
|
||
const notebookData = JSON.stringify(notebook)
|
||
|
||
// Delete (CASCADE will delete labels, notes will become general)
|
||
await prisma.notebook.delete({
|
||
where: { id: notebookId },
|
||
})
|
||
|
||
// Register undo action
|
||
undoHistory.add({
|
||
type: 'DELETE_NOTEBOOK',
|
||
timestamp: new Date(),
|
||
description: `Supprimer le notebook "${notebook.name}"`,
|
||
undo: async () => {
|
||
const data = JSON.parse(notebookData)
|
||
|
||
// Recreate notebook
|
||
await prisma.notebook.create({
|
||
data: {
|
||
id: data.id,
|
||
name: data.name,
|
||
icon: data.icon,
|
||
color: data.color,
|
||
order: data.order,
|
||
userId: data.userId,
|
||
},
|
||
})
|
||
|
||
// Recreate labels
|
||
for (const label of data.labels) {
|
||
await prisma.label.create({
|
||
data: {
|
||
id: label.id,
|
||
name: label.name,
|
||
color: label.color,
|
||
notebookId: data.id,
|
||
},
|
||
})
|
||
}
|
||
|
||
revalidatePath('/')
|
||
},
|
||
})
|
||
|
||
revalidatePath('/')
|
||
}
|
||
|
||
/**
|
||
* Update notebook order (drag & drop)
|
||
*/
|
||
export async function updateNotebookOrder(notebookIds: string[]) {
|
||
const session = await auth()
|
||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||
|
||
// Update each notebook's order
|
||
const updates = notebookIds.map((id, index) =>
|
||
prisma.notebook.updateMany({
|
||
where: {
|
||
id,
|
||
userId: session.user.id,
|
||
},
|
||
data: { order: index },
|
||
})
|
||
)
|
||
|
||
await prisma.$transaction(updates)
|
||
revalidatePath('/')
|
||
}
|
||
|
||
// ===== LABEL ACTIONS =====
|
||
|
||
/**
|
||
* Create a label
|
||
*/
|
||
export async function createLabel(data: CreateLabelInput) {
|
||
const session = await auth()
|
||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||
|
||
// Verify notebook ownership
|
||
const notebook = await prisma.notebook.findFirst({
|
||
where: {
|
||
id: data.notebookId,
|
||
userId: session.user.id,
|
||
},
|
||
})
|
||
|
||
if (!notebook) throw new Error('Notebook not found')
|
||
|
||
const label = await prisma.label.create({
|
||
data: {
|
||
name: data.name,
|
||
color: data.color,
|
||
notebookId: data.notebookId,
|
||
},
|
||
})
|
||
|
||
revalidatePath('/')
|
||
return label
|
||
}
|
||
|
||
/**
|
||
* Update a label
|
||
*/
|
||
export async function updateLabel(labelId: string, data: UpdateLabelInput) {
|
||
const session = await auth()
|
||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||
|
||
// Verify ownership (via notebook)
|
||
const label = await prisma.label.findFirst({
|
||
where: {
|
||
id: labelId,
|
||
notebook: { userId: session.user.id },
|
||
},
|
||
})
|
||
|
||
if (!label) throw new Error('Label not found')
|
||
|
||
const updated = await prisma.label.update({
|
||
where: { id: labelId },
|
||
data,
|
||
})
|
||
|
||
revalidatePath('/')
|
||
return updated
|
||
}
|
||
|
||
/**
|
||
* Delete a label
|
||
*/
|
||
export async function deleteLabel(labelId: string) {
|
||
const session = await auth()
|
||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||
|
||
// Verify ownership
|
||
const label = await prisma.label.findFirst({
|
||
where: {
|
||
id: labelId,
|
||
notebook: { userId: session.user.id },
|
||
},
|
||
})
|
||
|
||
if (!label) throw new Error('Label not found')
|
||
|
||
await prisma.label.delete({
|
||
where: { id: labelId },
|
||
})
|
||
|
||
revalidatePath('/')
|
||
}
|
||
|
||
// ===== NOTE ACTIONS =====
|
||
|
||
/**
|
||
* Move a note to a notebook (or to general notes)
|
||
*/
|
||
export async function moveNoteToNotebook(noteId: string, notebookId: string | null) {
|
||
const session = await auth()
|
||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||
|
||
const note = await prisma.note.findUnique({
|
||
where: { id: noteId, userId: session.user.id },
|
||
})
|
||
|
||
if (!note) throw new Error('Note not found')
|
||
|
||
const previousNotebookId = note.notebookId
|
||
|
||
// Update note
|
||
const updated = await prisma.note.update({
|
||
where: { id: noteId },
|
||
data: { notebookId },
|
||
})
|
||
|
||
// Register undo
|
||
undoHistory.add({
|
||
type: 'MOVE_NOTE',
|
||
timestamp: new Date(),
|
||
description: `Déplacer "${note.title || 'Sans titre'}"`,
|
||
undo: async () => {
|
||
await prisma.note.update({
|
||
where: { id: noteId },
|
||
data: { notebookId: previousNotebookId },
|
||
})
|
||
revalidatePath('/')
|
||
},
|
||
})
|
||
|
||
revalidatePath('/')
|
||
return updated
|
||
}
|
||
|
||
/**
|
||
* Attach a label to a note
|
||
*/
|
||
export async function attachLabel(noteId: string, labelId: string) {
|
||
const session = await auth()
|
||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||
|
||
const note = await prisma.note.findUnique({
|
||
where: { id: noteId, userId: session.user.id },
|
||
})
|
||
|
||
if (!note) throw new Error('Note not found')
|
||
|
||
await prisma.note.update({
|
||
where: { id: noteId },
|
||
data: {
|
||
labels: { connect: { id: labelId } },
|
||
},
|
||
})
|
||
|
||
revalidatePath('/')
|
||
}
|
||
|
||
/**
|
||
* Detach a label from a note
|
||
*/
|
||
export async function detachLabel(noteId: string, labelId: string) {
|
||
const session = await auth()
|
||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||
|
||
const note = await prisma.note.findUnique({
|
||
where: { id: noteId, userId: session.user.id },
|
||
})
|
||
|
||
if (!note) throw new Error('Note not found')
|
||
|
||
await prisma.note.update({
|
||
where: { id: noteId },
|
||
data: {
|
||
labels: { disconnect: { id: labelId } },
|
||
},
|
||
})
|
||
|
||
revalidatePath('/')
|
||
}
|
||
|
||
// ===== AI ACTIONS =====
|
||
|
||
/**
|
||
* Suggest a notebook for a note (IA1)
|
||
*/
|
||
export async function suggestNotebook(noteContent: string) {
|
||
const session = await auth()
|
||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||
|
||
const { NotebookSuggestionService } = await import('@/lib/ai/services/notebook-suggestion.service')
|
||
const service = new NotebookSuggestionService()
|
||
|
||
return await service.suggestNotebook(noteContent, session.user.id)
|
||
}
|
||
|
||
/**
|
||
* Suggest labels for a note (IA2)
|
||
*/
|
||
export async function suggestLabels(noteContent: string, notebookId: string) {
|
||
const session = await auth()
|
||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||
|
||
const { ContextualAutoTagService } = await import('@/lib/ai/services/contextual-auto-tag.service')
|
||
const service = new ContextualAutoTagService(notebookId)
|
||
|
||
return await service.suggestLabels(noteContent)
|
||
}
|
||
|
||
/**
|
||
* Organize inbox notes into notebooks (IA3)
|
||
*/
|
||
export async function organizeInbox(noteIds: string[]) {
|
||
const session = await auth()
|
||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||
|
||
const { NotebookSuggestionService } = await import('@/lib/ai/services/notebook-suggestion.service')
|
||
const service = new NotebookSuggestionService()
|
||
|
||
// Get notes
|
||
const notes = await prisma.note.findMany({
|
||
where: {
|
||
id: { in: noteIds },
|
||
userId: session.user.id,
|
||
notebookId: null,
|
||
},
|
||
})
|
||
|
||
// Get suggestions
|
||
const suggestions = await service.suggestNotebooksBatch(
|
||
notes.map(n => n.content),
|
||
session.user.id
|
||
)
|
||
|
||
// Apply suggestions
|
||
const results = new Map<string, any>()
|
||
|
||
for (const [index, notebook] of suggestions.entries()) {
|
||
const note = notes[index]
|
||
|
||
if (notebook) {
|
||
await prisma.note.update({
|
||
where: { id: note.id },
|
||
data: { notebookId: notebook.id },
|
||
})
|
||
results.set(note.id, notebook)
|
||
} else {
|
||
results.set(note.id, null)
|
||
}
|
||
}
|
||
|
||
revalidatePath('/')
|
||
return results
|
||
}
|
||
```
|
||
|
||
### 6.2 Error Handling
|
||
|
||
```typescript
|
||
// app/actions/notebooks.ts (add error handling wrapper)
|
||
|
||
import { z } from 'zod'
|
||
|
||
// Validation schemas
|
||
const CreateNotebookSchema = z.object({
|
||
name: z.string().min(1).max(50),
|
||
icon: z.string().optional(),
|
||
color: z.string().regex(/^#[0-9A-F]{6}$/i).optional(),
|
||
})
|
||
|
||
export async function createNotebook(data: CreateNotebookInput) {
|
||
const session = await auth()
|
||
if (!session?.user?.id) {
|
||
return { success: false, error: 'Unauthorized' }
|
||
}
|
||
|
||
// Validate input
|
||
const validation = CreateNotebookSchema.safeParse(data)
|
||
if (!validation.success) {
|
||
return { success: false, error: 'Invalid input', details: validation.error.errors }
|
||
}
|
||
|
||
try {
|
||
const notebook = await prisma.notebook.create({
|
||
data: {
|
||
...validation.data,
|
||
userId: session.user.id,
|
||
order: await getNextOrder(session.user.id),
|
||
},
|
||
})
|
||
|
||
revalidatePath('/')
|
||
return { success: true, data: notebook }
|
||
} catch (error) {
|
||
console.error('Failed to create notebook:', error)
|
||
return { success: false, error: 'Failed to create notebook' }
|
||
}
|
||
}
|
||
```
|
||
|
||
### 6.3 Testing
|
||
|
||
```typescript
|
||
// __tests__/notebooks-actions.test.ts
|
||
|
||
import { createNotebook, deleteNotebook, moveNoteToNotebook } from '@/app/actions/notebooks'
|
||
import { auth } from '@/lib/auth'
|
||
import { prisma } from '@/lib/prisma'
|
||
|
||
jest.mock('@/lib/auth')
|
||
jest.mock('@/lib/prisma')
|
||
|
||
describe('Notebooks Actions', () => {
|
||
beforeEach(() => {
|
||
;(auth as jest.Mock).mockResolvedValue({
|
||
user: { id: 'user-123' },
|
||
})
|
||
})
|
||
|
||
it('should create notebook', async () => {
|
||
const mockNotebook = { id: 'nb-1', name: 'Work' }
|
||
;(prisma.notebook.create as jest.Mock).mockResolvedValue(mockNotebook)
|
||
|
||
const result = await createNotebook({ name: 'Work' })
|
||
|
||
expect(result.success).toBe(true)
|
||
expect(result.data).toEqual(mockNotebook)
|
||
})
|
||
|
||
it('should fail with invalid notebook name', async () => {
|
||
const result = await createNotebook({ name: '' })
|
||
|
||
expect(result.success).toBe(false)
|
||
expect(result.error).toBe('Invalid input')
|
||
})
|
||
|
||
it('should move note to notebook', async () => {
|
||
const mockNote = { id: 'note-1', title: 'Test' }
|
||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(mockNote)
|
||
;(prisma.note.update as jest.Mock).mockResolvedValue({ ...mockNote, notebookId: 'nb-1' })
|
||
|
||
const result = await moveNoteToNotebook('note-1', 'nb-1')
|
||
|
||
expect(prisma.note.update).toHaveBeenCalledWith({
|
||
where: { id: 'note-1' },
|
||
data: { notebookId: 'nb-1' },
|
||
})
|
||
})
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
## Summary
|
||
|
||
This technical specification document provides detailed implementation guidance for:
|
||
|
||
1. ✅ **NotebooksContext** - Global state management with React Context + useOptimistic
|
||
2. ✅ **NotebooksSidebar** - Sidebar UI with Muuri drag & drop
|
||
3. ✅ **ContextualAutoTagService** - AI service for contextual label suggestions
|
||
4. ✅ **NotebookSuggestionService** - AI service for notebook suggestions
|
||
5. ✅ **Migration Script** - Database migration without breaking changes
|
||
6. ✅ **Server Actions** - Complete CRUD operations with error handling
|
||
|
||
### Implementation Priority
|
||
|
||
**Phase 1 (Foundation):**
|
||
- Migration Script (5.2, 5.3)
|
||
- NotebooksContext (1.2)
|
||
- Server Actions (6.1)
|
||
|
||
**Phase 2 (Core UI):**
|
||
- NotebooksSidebar (2.2)
|
||
- NotebookItem component
|
||
- CreateNotebookModal
|
||
|
||
**Phase 3 (Advanced):**
|
||
- Drag & drop cross-container
|
||
- Label management UI
|
||
|
||
**Phase 4 (AI):**
|
||
- ContextualAutoTagService (3.1)
|
||
- NotebookSuggestionService (4.1)
|
||
- AI integration in UI
|
||
|
||
**Phase 5 (Polish):**
|
||
- Undo/Redo
|
||
- Testing
|
||
- Performance optimization
|
||
|
||
---
|
||
|
||
**Document Status:** ✅ COMPLETE
|
||
**Ready for Implementation:** YES
|
||
**Estimated Total Time:** 5-6 weeks
|
||
|
||
---
|
||
|
||
*This technical specification was created based on the validated Architecture Decision Document.*
|
||
*Date: 2026-01-11*
|
||
*Author: Winston (Architect AI Agent)*
|