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
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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user