feat: smart note history with manual/auto modes, delete entries, i18n fixes
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m16s

- Add noteHistoryMode setting (manual default / auto) with DB migration
- Manual mode: commit button in editor toolbar creates snapshots on demand
- Auto mode: smart snapshots with 20-char diff threshold + 5min cooldown,
  structural changes (color, pin, archive, labels) bypass cooldown
- Add delete individual history entries from history modal
- Fix sidebar: Notes nav no longer active on notebook pages
- Fix sidebar icon: replace filled Lightbulb with outlined FileText
- Fix title suggestions: change from amber to sky blue color scheme
- Fix hydration mismatch: add suppressHydrationWarning on locale dates
- Complete i18n: add history, sort, and AI chat translations for all 16 languages
- Translate French AI assistant section (40+ keys) from English to French
- Update README with new features and stack info

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 21:05:55 +02:00
parent ed807d3b2a
commit 69ea064ca8
40 changed files with 2110 additions and 250 deletions

View File

@@ -21,6 +21,8 @@ export default async function HomePage() {
initialSettings={{
showRecentNotes: settings?.showRecentNotes !== false,
notesViewMode,
noteHistory: settings?.noteHistory === true,
noteHistoryMode: (settings?.noteHistoryMode ?? 'manual') as 'manual' | 'auto',
}}
/>
)

View File

@@ -21,6 +21,8 @@ export type UserAISettingsData = {
fontSize?: 'small' | 'medium' | 'large'
languageDetection?: boolean
autoLabeling?: boolean
noteHistory?: boolean
noteHistoryMode?: 'manual' | 'auto'
}
/** Only fields that exist on `UserAISettings` in Prisma (excludes e.g. `theme`, which lives on `User`). */
@@ -41,6 +43,8 @@ const USER_AI_SETTINGS_PRISMA_KEYS = [
'anonymousAnalytics',
'languageDetection',
'autoLabeling',
'noteHistory',
'noteHistoryMode',
] as const
type UserAISettingsPrismaKey = (typeof USER_AI_SETTINGS_PRISMA_KEYS)[number]
@@ -151,6 +155,8 @@ const getCachedAISettings = unstable_cache(
fontSize: 'medium' as const,
languageDetection: true,
autoLabeling: true,
noteHistory: false,
noteHistoryMode: 'manual' as const,
}
}
@@ -180,6 +186,8 @@ const getCachedAISettings = unstable_cache(
fontSize: (settings.fontSize || 'medium') as 'small' | 'medium' | 'large',
languageDetection: settings.languageDetection ?? true,
autoLabeling: settings.autoLabeling ?? true,
noteHistory: settings.noteHistory ?? false,
noteHistoryMode: (settings.noteHistoryMode ?? 'manual') as 'manual' | 'auto',
}
} catch (error) {
console.error('Error getting AI settings:', error)
@@ -202,6 +210,8 @@ const getCachedAISettings = unstable_cache(
fontSize: 'medium' as const,
languageDetection: true,
autoLabeling: true,
noteHistory: false,
noteHistoryMode: 'manual' as const,
}
}
},
@@ -237,6 +247,8 @@ export async function getAISettings(userId?: string) {
fontSize: 'medium' as const,
languageDetection: true,
autoLabeling: true,
noteHistory: false,
noteHistoryMode: 'manual' as const,
}
}
@@ -266,6 +278,8 @@ export async function isAIFeatureEnabled(feature: keyof UserAISettingsData): Pro
return settings.paragraphRefactor
case 'memoryEcho':
return settings.memoryEcho
case 'noteHistory':
return settings.noteHistory
default:
return true
}

View File

@@ -10,6 +10,14 @@ import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } f
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
import { cleanupNoteImages, parseImageUrls, deleteImageFileSafely } from '@/lib/image-cleanup'
import { getAISettings } from '@/app/actions/ai-settings'
import {
createNoteHistorySnapshot,
getNoteHistoryMode,
isNoteHistoryEnabledForUser,
parseNoteHistoryEntry,
shouldCaptureHistorySnapshot,
shouldCreateAutoSnapshot,
} from '@/lib/note-history'
/**
@@ -57,6 +65,24 @@ function parseNote(dbNote: any): Note {
return parseNoteUtil(dbNote)
}
async function ensureSessionUserExists(sessionUser: { id: string; email?: string | null; name?: string | null }) {
const fallbackEmail = `user-${sessionUser.id}@local.momento`
const safeEmail = sessionUser.email || fallbackEmail
await prisma.user.upsert({
where: { id: sessionUser.id },
update: {
...(sessionUser.email ? { email: sessionUser.email } : {}),
...(sessionUser.name !== undefined ? { name: sessionUser.name } : {}),
},
create: {
id: sessionUser.id,
email: safeEmail,
name: sessionUser.name || null,
},
})
}
// Helper to get hash color for labels (copied from utils)
function getHashColor(name: string): string {
const colors = ['red', 'blue', 'green', 'yellow', 'purple', 'pink', 'orange', 'gray']
@@ -316,6 +342,130 @@ export async function getArchivedNotes() {
}
}
export async function getNoteHistory(noteId: string, limit = 30) {
const session = await auth()
if (!session?.user?.id) return []
const enabled = await isNoteHistoryEnabledForUser(session.user.id)
if (!enabled) return []
const clampedLimit = Math.min(Math.max(limit, 1), 100)
const note = await prisma.note.findFirst({
where: { id: noteId, userId: session.user.id },
select: { id: true },
})
if (!note) return []
const entries = await (prisma as any).noteHistory.findMany({
where: { noteId: note.id, userId: session.user.id },
orderBy: { createdAt: 'desc' },
take: clampedLimit,
})
return entries.map(parseNoteHistoryEntry)
}
export async function restoreNoteVersion(noteId: string, historyEntryId: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const enabled = await isNoteHistoryEnabledForUser(session.user.id)
if (!enabled) throw new Error('History is disabled')
const [note, historyEntry] = await Promise.all([
prisma.note.findFirst({
where: { id: noteId, userId: session.user.id },
select: { id: true, notebookId: true },
}),
(prisma as any).noteHistory.findFirst({
where: {
id: historyEntryId,
noteId,
userId: session.user.id,
},
}),
])
if (!note || !historyEntry) {
throw new Error('History entry not found')
}
const restored = await prisma.note.update({
where: { id: note.id, userId: session.user.id },
data: {
title: historyEntry.title,
content: historyEntry.content,
color: historyEntry.color,
isPinned: historyEntry.isPinned,
isArchived: historyEntry.isArchived,
type: historyEntry.type,
checkItems: historyEntry.checkItems,
labels: historyEntry.labels,
images: historyEntry.images,
links: historyEntry.links,
isMarkdown: historyEntry.isMarkdown,
size: historyEntry.size,
notebookId: historyEntry.notebookId,
contentUpdatedAt: new Date(),
},
})
try {
await createNoteHistorySnapshot({
noteId: note.id,
userId: session.user.id,
reason: `restore:v${historyEntry.version}`,
})
} catch (snapshotError) {
console.error('[HISTORY] Failed to create snapshot after restore:', snapshotError)
}
revalidatePath('/')
revalidatePath(`/note/${note.id}`)
revalidatePath('/archive')
if (note.notebookId) revalidatePath(`/notebook/${note.notebookId}`)
if (historyEntry.notebookId && historyEntry.notebookId !== note.notebookId) {
revalidatePath(`/notebook/${historyEntry.notebookId}`)
}
return parseNote(restored)
}
export async function commitNoteHistory(noteId: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const enabled = await isNoteHistoryEnabledForUser(session.user.id)
if (!enabled) throw new Error('History is disabled')
const note = await prisma.note.findFirst({
where: { id: noteId, userId: session.user.id },
select: { id: true },
})
if (!note) throw new Error('Note not found')
await createNoteHistorySnapshot({
noteId: note.id,
userId: session.user.id,
reason: 'manual-commit',
})
}
export async function deleteNoteHistoryEntry(noteId: string, historyEntryId: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const entry = await (prisma as any).noteHistory.findFirst({
where: { id: historyEntryId, noteId, userId: session.user.id },
})
if (!entry) throw new Error('History entry not found')
await (prisma as any).noteHistory.delete({
where: { id: historyEntryId },
})
}
// Search notes - DB-side filtering (fast) with optional semantic search
// Supports contextual search within notebook (IA5)
export async function searchNotes(query: string, useSemantic: boolean = false, notebookId?: string) {
@@ -455,6 +605,14 @@ export async function createNote(data: {
if (!session?.user?.id) throw new Error('Unauthorized');
try {
// Defensive guard: after DB reset/migration, auth session can exist while User row is missing.
// Recreate user row to avoid Note_userId_fkey failures.
await ensureSessionUserExists({
id: session.user.id,
email: session.user.email,
name: session.user.name,
})
// Save note to DB immediately (fast!) — AI operations run in background after
const note = await prisma.note.create({
data: {
@@ -482,6 +640,19 @@ export async function createNote(data: {
await syncNoteLabels(note.id, data.labels, data.notebookId ?? null, session.user.id)
}
try {
const historyEnabled = await isNoteHistoryEnabledForUser(session.user.id)
if (historyEnabled) {
await createNoteHistorySnapshot({
noteId: note.id,
userId: session.user.id,
reason: 'create',
})
}
} catch (snapshotError) {
console.error('[HISTORY] Failed to create initial snapshot:', snapshotError)
}
if (!data.skipRevalidation) {
// Revalidate main page (handles both inbox and notebook views via query params)
revalidatePath('/')
@@ -612,7 +783,7 @@ export async function updateNote(id: string, data: {
try {
const oldNote = await prisma.note.findUnique({
where: { id, userId: session.user.id },
select: { labels: true, notebookId: true, reminder: true }
select: { labels: true, notebookId: true, reminder: true, content: true, title: true }
})
const oldLabels: string[] = oldNote?.labels ? JSON.parse(oldNote.labels) : []
const oldNotebookId = oldNote?.notebookId
@@ -682,6 +853,33 @@ export async function updateNote(id: string, data: {
await syncNoteLabels(id, labelsToSync, effectiveNotebookId ?? null, session.user.id)
}
try {
const historyEnabled = await isNoteHistoryEnabledForUser(session.user.id)
if (historyEnabled && shouldCaptureHistorySnapshot(data as Record<string, unknown>)) {
const mode = await getNoteHistoryMode(session.user.id)
if (mode === 'manual') {
// No auto-snapshot in manual mode — user commits explicitly
} else {
const shouldAuto = await shouldCreateAutoSnapshot({
noteId: id,
userId: session.user.id,
updateData: data as Record<string, unknown>,
existingContent: oldNote?.content ?? '',
existingTitle: oldNote?.title ?? null,
})
if (shouldAuto) {
await createNoteHistorySnapshot({
noteId: id,
userId: session.user.id,
reason: 'update',
})
}
}
}
} catch (snapshotError) {
console.error('[HISTORY] Failed to create snapshot after update:', snapshotError)
}
// Only revalidate for STRUCTURAL changes that affect the page layout/lists
// Content edits (title, content, size, color) use optimistic UI — no refresh needed
const structuralFields = ['isPinned', 'isArchived', 'labels', 'notebookId']
@@ -690,6 +888,9 @@ export async function updateNote(id: string, data: {
if (isStructuralChange && !options?.skipRevalidation) {
revalidatePath('/')
revalidatePath(`/note/${id}`)
if (data.isArchived !== undefined) {
revalidatePath('/archive')
}
if (data.notebookId !== undefined && data.notebookId !== oldNotebookId) {
if (oldNotebookId) {

View File

@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getNoteHistory, restoreNoteVersion } from '@/app/actions/notes'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const { id } = await params
const limitRaw = request.nextUrl.searchParams.get('limit')
const limit = limitRaw ? Number.parseInt(limitRaw, 10) : 30
const entries = await getNoteHistory(id, Number.isNaN(limit) ? 30 : limit)
return NextResponse.json({ success: true, data: entries })
} catch (error) {
console.error('Error fetching note history:', error)
return NextResponse.json({ success: false, error: 'Failed to fetch history' }, { status: 500 })
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const { id } = await params
const body = await request.json()
const historyEntryId = body?.historyEntryId
if (!historyEntryId || typeof historyEntryId !== 'string') {
return NextResponse.json({ success: false, error: 'historyEntryId is required' }, { status: 400 })
}
const restoredNote = await restoreNoteVersion(id, historyEntryId)
return NextResponse.json({ success: true, data: restoredNote })
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to restore history version'
console.error('Error restoring note history:', error)
return NextResponse.json({ success: false, error: message }, { status: 500 })
}
}

View File

@@ -2,6 +2,13 @@ import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { parseNote } from '@/lib/utils'
import {
createNoteHistorySnapshot,
getNoteHistoryMode,
isNoteHistoryEnabledForUser,
shouldCaptureHistorySnapshot,
shouldCreateAutoSnapshot,
} from '@/lib/note-history'
// GET /api/notes/[id] - Get a single note
export async function GET(
@@ -134,6 +141,32 @@ export async function PUT(
data: updateData,
})
try {
const historyEnabled = await isNoteHistoryEnabledForUser(session.user.id)
if (historyEnabled && shouldCaptureHistorySnapshot(updateData)) {
const mode = await getNoteHistoryMode(session.user.id)
if (mode === 'auto') {
const shouldAuto = await shouldCreateAutoSnapshot({
noteId: id,
userId: session.user.id,
updateData,
existingContent: existingNote.content ?? '',
existingTitle: existingNote.title ?? null,
})
if (shouldAuto) {
await createNoteHistorySnapshot({
noteId: id,
userId: session.user.id,
reason: 'api:update',
})
}
}
// manual mode: no auto-snapshot
}
} catch (snapshotError) {
console.error('[HISTORY] Failed to create snapshot from /api/notes/[id] PUT:', snapshotError)
}
return NextResponse.json({
success: true,
data: parseNote(note)