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

@@ -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) {