- Add slides.tool.ts with support for title, bullets, chart, stats, table, cards, timeline, quote, comparison, equation, image, summary slide types - Chart types: bar, horizontal-bar, line, donut, radar - Integrate with agent executor and canvas system - Add multilingual support (en/fr) - Various UI improvements and bug fixes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1157 lines
40 KiB
TypeScript
1157 lines
40 KiB
TypeScript
'use server'
|
|
|
|
import { revalidatePath } from 'next/cache'
|
|
import prisma from '@/lib/prisma'
|
|
import { Note, CheckItem, NoteType } from '@/lib/types'
|
|
import { auth } from '@/auth'
|
|
import { getAIProvider } from '@/lib/ai/factory'
|
|
import { parseNote, getHashColor } from '@/lib/utils'
|
|
import { upsertNoteEmbedding } from '@/lib/embeddings'
|
|
import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config'
|
|
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
|
|
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
|
|
import { getAISettings } from '@/app/actions/ai-settings'
|
|
import {
|
|
createNoteHistorySnapshot,
|
|
getNoteHistoryMode,
|
|
shouldCaptureHistorySnapshot,
|
|
shouldCreateAutoSnapshot,
|
|
} from '@/lib/note-history'
|
|
import { NOTE_LIST_SELECT } from '@/lib/note-select'
|
|
|
|
|
|
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,
|
|
},
|
|
})
|
|
}
|
|
|
|
/** Clé stable (carnet + nom) : les étiquettes sont uniques par (notebookId, name) côté Prisma */
|
|
function labelScopeKey(notebookId: string | null | undefined, rawName: string): string {
|
|
const name = rawName.trim().toLowerCase()
|
|
if (!name) return ''
|
|
const nb = notebookId ?? ''
|
|
return `${nb}\u0000${name}`
|
|
}
|
|
|
|
function collectLabelNamesFromNote(note: {
|
|
labels: string | null
|
|
labelRelations?: { name: string }[]
|
|
}): string[] {
|
|
const names: string[] = []
|
|
if (note.labels) {
|
|
try {
|
|
const parsed: unknown = JSON.parse(note.labels)
|
|
if (Array.isArray(parsed)) {
|
|
for (const l of parsed) {
|
|
if (typeof l === 'string' && l.trim()) names.push(l.trim())
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('[SYNC] Failed to parse labels:', e)
|
|
}
|
|
}
|
|
for (const rel of note.labelRelations ?? []) {
|
|
if (rel.name?.trim()) names.push(rel.name.trim())
|
|
}
|
|
return names
|
|
}
|
|
|
|
/**
|
|
* Upsert Label rows and return their IDs.
|
|
* No orphan cleanup — labels are only deleted via the label management dialog.
|
|
*/
|
|
async function syncLabels(userId: string, noteLabels: string[] = [], notebookId?: string | null): Promise<{ id: string; name: string }[]> {
|
|
try {
|
|
const nbScope = notebookId ?? null
|
|
|
|
if (noteLabels.length > 0) {
|
|
// Deduplicate case-insensitively, keep original case
|
|
const seen = new Set<string>()
|
|
const trimmedNames = noteLabels
|
|
.map(name => name?.trim())
|
|
.filter((n): n is string => Boolean(n))
|
|
.filter(n => {
|
|
const key = n.toLowerCase()
|
|
if (seen.has(key)) return false
|
|
seen.add(key)
|
|
return true
|
|
})
|
|
|
|
// Batch lookup (1 query) then batch insert (1 query) — avoids N+1
|
|
const existingLabels = await prisma.label.findMany({
|
|
where: { userId, notebookId: nbScope, name: { in: trimmedNames, mode: 'insensitive' } },
|
|
select: { name: true },
|
|
})
|
|
const existingLower = new Set(existingLabels.map(l => l.name.toLowerCase()))
|
|
const toCreate = trimmedNames.filter(n => !existingLower.has(n.toLowerCase()))
|
|
if (toCreate.length > 0) {
|
|
await prisma.label.createMany({
|
|
data: toCreate.map(name => ({ userId, name, color: getHashColor(name), notebookId: nbScope })),
|
|
skipDuplicates: true,
|
|
})
|
|
}
|
|
|
|
if (trimmedNames.length === 0) return []
|
|
// Search with original case (case-insensitive on PostgreSQL)
|
|
return prisma.label.findMany({
|
|
where: { userId, notebookId: nbScope, name: { in: trimmedNames, mode: 'insensitive' } },
|
|
select: { id: true, name: true },
|
|
})
|
|
}
|
|
|
|
return []
|
|
} catch (error) {
|
|
console.error('Fatal error in syncLabels:', error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
/** Sync both Note.labels (JSON) AND labelRelations for a single note.
|
|
* Also cleans up orphan labels in the same notebook scope. */
|
|
async function syncNoteLabels(noteId: string, labelNames: string[], notebookId: string | null, userId: string) {
|
|
const uniqueNames = [...new Set(labelNames.map(n => n.trim()).filter(Boolean))]
|
|
const labelRows = await syncLabels(userId, uniqueNames, notebookId)
|
|
const labelIds = labelRows.map(l => l.id)
|
|
await prisma.note.update({
|
|
where: { id: noteId },
|
|
data: {
|
|
labels: uniqueNames.length > 0 ? JSON.stringify(uniqueNames) : null,
|
|
labelRelations: { set: labelIds.map(id => ({ id })) },
|
|
},
|
|
})
|
|
|
|
// Clean up orphan labels: labels in this notebook scope that are no longer
|
|
// referenced by any note (neither via JSON nor via labelRelations)
|
|
if (notebookId !== null) {
|
|
const allLabels = await prisma.label.findMany({
|
|
where: { notebookId, userId },
|
|
select: { id: true, name: true },
|
|
})
|
|
if (allLabels.length > 0) {
|
|
const labelIdsSet = new Set(allLabels.map(l => l.id))
|
|
// Find notes in this notebook that have any label relation
|
|
const notesWithLabels = await prisma.note.findMany({
|
|
where: {
|
|
notebookId,
|
|
userId,
|
|
labelRelations: { some: { id: { in: Array.from(labelIdsSet) } } },
|
|
},
|
|
select: { labels: true },
|
|
})
|
|
// Collect all label names still in use via JSON
|
|
const namesInUse = new Set<string>()
|
|
for (const n of notesWithLabels) {
|
|
if (n.labels) {
|
|
try {
|
|
const parsed = JSON.parse(n.labels as string)
|
|
if (Array.isArray(parsed)) {
|
|
parsed.filter((x: any) => typeof x === 'string').forEach((x: string) => namesInUse.add(x.toLowerCase()))
|
|
}
|
|
} catch { }
|
|
}
|
|
}
|
|
// Delete labels not in use
|
|
const orphans = allLabels.filter(l => !namesInUse.has(l.name.toLowerCase()))
|
|
if (orphans.length > 0) {
|
|
await prisma.label.deleteMany({
|
|
where: { id: { in: orphans.map(l => l.id) } },
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Après déplacement via API : rattacher les étiquettes de la note au bon carnet */
|
|
export async function reconcileLabelsAfterNoteMove(noteId: string, newNotebookId: string | null) {
|
|
const session = await auth()
|
|
if (!session?.user?.id) return
|
|
const note = await prisma.note.findFirst({
|
|
where: { id: noteId, userId: session.user.id },
|
|
select: { labels: true },
|
|
})
|
|
if (!note) return
|
|
let labels: string[] = []
|
|
if (note.labels) {
|
|
try {
|
|
const raw = JSON.parse(note.labels) as unknown
|
|
if (Array.isArray(raw)) {
|
|
labels = raw.filter((x): x is string => typeof x === 'string')
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
await syncNoteLabels(noteId, labels, newNotebookId, session.user.id)
|
|
}
|
|
|
|
// Get all notes (non-archived by default)
|
|
export async function getNotes(includeArchived = false) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) return [];
|
|
|
|
try {
|
|
const notes = await prisma.note.findMany({
|
|
where: {
|
|
userId: session.user.id,
|
|
trashedAt: null,
|
|
...(includeArchived ? {} : { isArchived: false }),
|
|
},
|
|
select: NOTE_LIST_SELECT,
|
|
orderBy: [
|
|
{ isPinned: 'desc' },
|
|
{ order: 'asc' },
|
|
{ updatedAt: 'desc' }
|
|
]
|
|
})
|
|
|
|
return notes.map(parseNote)
|
|
} catch (error) {
|
|
console.error('Error fetching notes:', error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
// Get notes with reminders (upcoming, overdue, done)
|
|
export async function getNotesWithReminders() {
|
|
const session = await auth();
|
|
if (!session?.user?.id) return [];
|
|
|
|
try {
|
|
const notes = await prisma.note.findMany({
|
|
where: {
|
|
userId: session.user.id,
|
|
trashedAt: null,
|
|
isArchived: false,
|
|
reminder: { not: null }
|
|
},
|
|
select: NOTE_LIST_SELECT,
|
|
orderBy: { reminder: 'asc' }
|
|
})
|
|
|
|
return notes.map(parseNote)
|
|
} catch (error) {
|
|
console.error('Error fetching notes with reminders:', error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
// Mark a reminder as done / undone
|
|
export async function toggleReminderDone(noteId: string, done: boolean) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) return { error: 'Unauthorized' }
|
|
|
|
try {
|
|
await prisma.note.update({
|
|
where: { id: noteId, userId: session.user.id },
|
|
data: { isReminderDone: done }
|
|
})
|
|
revalidatePath('/reminders')
|
|
return { success: true }
|
|
} catch (error) {
|
|
console.error('Error toggling reminder done:', error)
|
|
return { error: 'Failed to update reminder' }
|
|
}
|
|
}
|
|
|
|
// Clear completed reminders (set reminder to null for done reminders)
|
|
export async function clearCompletedReminders() {
|
|
const session = await auth();
|
|
if (!session?.user?.id) return { error: 'Unauthorized' }
|
|
|
|
try {
|
|
await prisma.note.updateMany({
|
|
where: {
|
|
userId: session.user.id,
|
|
isReminderDone: true,
|
|
reminder: { not: null },
|
|
},
|
|
data: {
|
|
reminder: null,
|
|
isReminderDone: false,
|
|
},
|
|
})
|
|
revalidatePath('/reminders')
|
|
return { success: true }
|
|
} catch (error) {
|
|
console.error('Error clearing completed reminders:', error)
|
|
return { error: 'Failed to clear reminders' }
|
|
}
|
|
}
|
|
|
|
// Get archived notes only
|
|
export async function getArchivedNotes() {
|
|
const session = await auth();
|
|
if (!session?.user?.id) return [];
|
|
|
|
try {
|
|
const notes = await prisma.note.findMany({
|
|
where: {
|
|
userId: session.user.id,
|
|
isArchived: true,
|
|
trashedAt: null
|
|
},
|
|
select: NOTE_LIST_SELECT,
|
|
orderBy: { updatedAt: 'desc' }
|
|
})
|
|
|
|
return notes.map(parseNote)
|
|
} catch (error) {
|
|
console.error('Error fetching archived notes:', error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
// Unified hybrid search — always uses FTS + pgvector with RRF fusion.
|
|
// Supports contextual search within notebook (IA5).
|
|
export async function searchNotes(query: string, _useSemantic: boolean = true, notebookId?: string) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) return [];
|
|
|
|
try {
|
|
if (!query || !query.trim()) {
|
|
return await getAllNotes();
|
|
}
|
|
|
|
const results = await semanticSearchService.searchAsUser(session.user.id, query, {
|
|
limit: 50,
|
|
threshold: 0.25,
|
|
notebookId
|
|
});
|
|
|
|
const noteIds = results.map(r => r.noteId);
|
|
const notes = await prisma.note.findMany({
|
|
where: {
|
|
id: { in: noteIds },
|
|
userId: session.user.id,
|
|
isArchived: false,
|
|
trashedAt: null,
|
|
},
|
|
select: NOTE_LIST_SELECT,
|
|
});
|
|
|
|
const orderMap = new Map(results.map((r, i) => [r.noteId, i]));
|
|
const parsed = notes.map(parseNote);
|
|
|
|
parsed.sort((a, b) => (orderMap.get(a.id) ?? 999) - (orderMap.get(b.id) ?? 999));
|
|
|
|
if (parsed.length > 0) {
|
|
const topResult = results[0];
|
|
if (topResult) {
|
|
parsed[0].matchType = topResult.matchType;
|
|
parsed[0].searchScore = topResult.score;
|
|
}
|
|
}
|
|
|
|
return parsed;
|
|
} catch (error) {
|
|
console.error('Search error:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Create a new note
|
|
export async function createNote(data: {
|
|
title?: string
|
|
content: string
|
|
color?: string
|
|
type?: NoteType
|
|
checkItems?: CheckItem[]
|
|
labels?: string[]
|
|
images?: string[]
|
|
links?: any[]
|
|
isArchived?: boolean
|
|
reminder?: Date | null
|
|
isMarkdown?: boolean
|
|
size?: 'small' | 'medium' | 'large'
|
|
autoGenerated?: boolean
|
|
aiProvider?: string
|
|
notebookId?: string | undefined
|
|
skipRevalidation?: boolean
|
|
}) {
|
|
const session = await auth();
|
|
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: {
|
|
userId: session.user.id,
|
|
title: data.title || null,
|
|
content: data.content,
|
|
color: data.color || 'default',
|
|
type: data.type || 'richtext',
|
|
checkItems: data.checkItems ? JSON.stringify(data.checkItems) : null,
|
|
labels: null, // set by syncNoteLabels below
|
|
images: data.images ? JSON.stringify(data.images) : null,
|
|
links: data.links ? JSON.stringify(data.links) : null,
|
|
isArchived: data.isArchived || false,
|
|
reminder: data.reminder || null,
|
|
isMarkdown: data.isMarkdown || false,
|
|
size: data.size || 'small',
|
|
autoGenerated: data.autoGenerated || null,
|
|
aiProvider: data.aiProvider || null,
|
|
notebookId: data.notebookId || null,
|
|
}
|
|
})
|
|
|
|
// Sync labels (JSON + labelRelations + Label rows) in one call
|
|
if (data.labels && data.labels.length > 0) {
|
|
await syncNoteLabels(note.id, data.labels, data.notebookId ?? null, session.user.id)
|
|
}
|
|
|
|
try {
|
|
// New notes start with historyEnabled=false (schema default),
|
|
// so no initial snapshot is needed here.
|
|
// History is enabled per-note via enableNoteHistory() action.
|
|
} 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('/home')
|
|
}
|
|
|
|
// Fire-and-forget: run AI operations in background without blocking the response
|
|
const userId = session.user.id
|
|
const noteId = note.id
|
|
const content = data.content
|
|
const notebookId = data.notebookId
|
|
const hasUserLabels = data.labels && data.labels.length > 0
|
|
|
|
// Use setImmediate-like pattern to not block the response
|
|
; (async () => {
|
|
try {
|
|
const bgConfig = await getSystemConfig()
|
|
const provider = getAIProvider(bgConfig)
|
|
const embedding = await provider.getEmbeddings(content)
|
|
if (embedding) {
|
|
await upsertNoteEmbedding(noteId, embedding)
|
|
}
|
|
} catch (e) {
|
|
console.error('[BG] Embedding generation failed:', e)
|
|
}
|
|
|
|
// Background task 2: Auto-labeling (only if no user labels and has notebook)
|
|
if (!hasUserLabels && notebookId) {
|
|
try {
|
|
const userAISettings = await getAISettings(userId)
|
|
const autoLabelingEnabled = userAISettings.autoLabeling !== false
|
|
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70)
|
|
|
|
console.log('[BG] Auto-labeling check: enabled=', autoLabelingEnabled, 'confidence=', autoLabelingConfidence, 'notebookId=', notebookId)
|
|
|
|
if (autoLabelingEnabled) {
|
|
// Detect user's language from their existing notes for localized prompts
|
|
let userLang = 'en'
|
|
try {
|
|
const langResult = await prisma.note.groupBy({
|
|
by: ['language'],
|
|
where: { userId, language: { not: null } },
|
|
_count: true,
|
|
orderBy: { _count: { language: 'desc' } },
|
|
take: 1,
|
|
})
|
|
if (langResult.length > 0 && langResult[0].language) {
|
|
userLang = langResult[0].language
|
|
}
|
|
} catch { }
|
|
|
|
const suggestions = await contextualAutoTagService.suggestLabels(
|
|
content,
|
|
notebookId,
|
|
userId,
|
|
userLang
|
|
)
|
|
|
|
console.log('[BG] Auto-labeling suggestions:', suggestions.length, suggestions.map(s => s.label))
|
|
|
|
const appliedLabels = suggestions
|
|
.filter(s => s.confidence >= autoLabelingConfidence)
|
|
.map(s => s.label)
|
|
|
|
if (appliedLabels.length > 0) {
|
|
// Merge with existing labels
|
|
const existing = await prisma.note.findUnique({
|
|
where: { id: noteId },
|
|
select: { labels: true },
|
|
})
|
|
let existingNames: string[] = []
|
|
if (existing?.labels) {
|
|
try {
|
|
const parsed = existing.labels as unknown
|
|
existingNames = Array.isArray(parsed)
|
|
? parsed.filter((n): n is string => typeof n === 'string' && n.trim().length > 0)
|
|
: []
|
|
} catch { existingNames = [] }
|
|
}
|
|
const merged = [...new Set([...existingNames, ...appliedLabels])]
|
|
await syncNoteLabels(noteId, merged, notebookId ?? null, userId)
|
|
if (!data.skipRevalidation) {
|
|
revalidatePath('/home')
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('[BG] Auto-labeling failed:', error)
|
|
}
|
|
} else {
|
|
console.log('[BG] Auto-labeling skipped: hasUserLabels=', hasUserLabels, 'notebookId=', notebookId)
|
|
}
|
|
})().catch(e => console.error('[BG] Uncaught background error:', e))
|
|
|
|
return parseNote(note)
|
|
} catch (error) {
|
|
console.error('Error creating note:', error)
|
|
throw new Error('Failed to create note')
|
|
}
|
|
}
|
|
|
|
// Update a note
|
|
export async function updateNote(id: string, data: {
|
|
title?: string | null
|
|
content?: string
|
|
color?: string
|
|
isPinned?: boolean
|
|
isArchived?: boolean
|
|
type?: NoteType
|
|
checkItems?: CheckItem[] | null
|
|
labels?: string[] | null
|
|
images?: string[] | null
|
|
illustrationSvg?: string | null
|
|
links?: any[] | null
|
|
reminder?: Date | null
|
|
isMarkdown?: boolean
|
|
size?: 'small' | 'medium' | 'large'
|
|
autoGenerated?: boolean | null
|
|
aiProvider?: string | null
|
|
notebookId?: string | null
|
|
}, options?: { skipContentTimestamp?: boolean; skipRevalidation?: boolean }) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) throw new Error('Unauthorized');
|
|
|
|
try {
|
|
const oldNote = await prisma.note.findUnique({
|
|
where: { id, userId: session.user.id },
|
|
select: { labels: true, notebookId: true, reminder: true, content: true, title: true, historyEnabled: true }
|
|
})
|
|
const oldLabels: string[] = oldNote?.labels ? JSON.parse(oldNote.labels) : []
|
|
const oldNotebookId = oldNote?.notebookId
|
|
|
|
const updateData: any = { ...data }
|
|
|
|
// Reset isReminderDone only when reminder date actually changes (not on every save)
|
|
if ('reminder' in data && data.reminder !== null) {
|
|
const newTime = new Date(data.reminder as Date).getTime()
|
|
const oldTime = oldNote?.reminder ? new Date(oldNote.reminder).getTime() : null
|
|
if (newTime !== oldTime) {
|
|
updateData.isReminderDone = false
|
|
}
|
|
}
|
|
|
|
if (data.content !== undefined) {
|
|
const noteId = id
|
|
const content = data.content
|
|
; (async () => {
|
|
try {
|
|
const provider = getAIProvider(await getSystemConfig());
|
|
const embedding = await provider.getEmbeddings(content);
|
|
if (embedding) {
|
|
await upsertNoteEmbedding(noteId, embedding)
|
|
}
|
|
} catch (e) {
|
|
console.error('[BG] Embedding regeneration failed:', e);
|
|
}
|
|
}).catch(e => console.error('[BG] Uncaught background error:', e))
|
|
}
|
|
|
|
if ('checkItems' in data) updateData.checkItems = data.checkItems ? JSON.stringify(data.checkItems) : null
|
|
// labels handled by syncNoteLabels below
|
|
delete updateData.labels
|
|
if ('images' in data) updateData.images = data.images ? JSON.stringify(data.images) : null
|
|
if ('illustrationSvg' in data) updateData.illustrationSvg = data.illustrationSvg
|
|
if ('links' in data) updateData.links = data.links ? JSON.stringify(data.links) : null
|
|
if ('notebookId' in data) updateData.notebookId = data.notebookId
|
|
// Explicitly handle size to ensure it propagates
|
|
if ('size' in data && data.size) updateData.size = data.size
|
|
|
|
// Only update contentUpdatedAt for actual content changes, NOT for property changes
|
|
// (size, color, isPinned, isArchived are properties, not content)
|
|
// skipContentTimestamp=true is used by the inline editor to avoid bumping "Récent" on every auto-save
|
|
const contentFields = ['title', 'content', 'checkItems', 'images', 'links', 'illustrationSvg']
|
|
const isContentChange = contentFields.some(field => field in data)
|
|
if (isContentChange && !options?.skipContentTimestamp) {
|
|
updateData.contentUpdatedAt = new Date()
|
|
}
|
|
|
|
console.log('[updateNote] Attempting update, id:', id, 'userId:', session.user.id)
|
|
let note
|
|
try {
|
|
note = await prisma.note.update({
|
|
where: { id, userId: session.user.id },
|
|
data: updateData
|
|
})
|
|
console.log('[updateNote] Succeeded, note id:', note?.id)
|
|
} catch (dbError: any) {
|
|
console.error('[updateNote] FAILED:', dbError.code, dbError.message)
|
|
throw dbError
|
|
}
|
|
|
|
// Sync labels (JSON + labelRelations + Label rows)
|
|
const notebookMoved =
|
|
data.notebookId !== undefined && data.notebookId !== oldNotebookId
|
|
if (data.labels !== undefined || notebookMoved) {
|
|
const labelsToSync = data.labels !== undefined ? (data.labels || []) : oldLabels
|
|
const effectiveNotebookId =
|
|
data.notebookId !== undefined ? data.notebookId : oldNotebookId
|
|
await syncNoteLabels(id, labelsToSync, effectiveNotebookId ?? null, session.user.id)
|
|
}
|
|
|
|
try {
|
|
const historyEnabled = oldNote?.historyEnabled === true
|
|
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']
|
|
const isStructuralChange = structuralFields.some(field => field in data)
|
|
|
|
console.log('[updateNote] Structural check — data fields:', Object.keys(data), '| isStructural:', isStructuralChange)
|
|
|
|
if (!options?.skipRevalidation) {
|
|
try { revalidatePath(`/note/${id}`) } catch {}
|
|
try { revalidatePath('/home') } catch {}
|
|
}
|
|
|
|
if (isStructuralChange) {
|
|
if (data.isArchived !== undefined) {
|
|
revalidatePath('/archive')
|
|
}
|
|
|
|
if (data.notebookId !== undefined && data.notebookId !== oldNotebookId) {
|
|
if (oldNotebookId) {
|
|
revalidatePath(`/notebook/${oldNotebookId}`)
|
|
}
|
|
if (data.notebookId) {
|
|
revalidatePath(`/notebook/${data.notebookId}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (data.labels !== undefined) {
|
|
const refreshed = await prisma.note.findUnique({ where: { id } })
|
|
if (refreshed) return parseNote(refreshed)
|
|
}
|
|
|
|
return parseNote(note)
|
|
} catch (error) {
|
|
console.error('Error updating note:', error)
|
|
throw error // Re-throw the REAL error, not a generic one
|
|
}
|
|
}
|
|
|
|
// Toggle functions
|
|
export async function togglePin(id: string, isPinned: boolean) { return updateNote(id, { isPinned }) }
|
|
export async function toggleArchive(id: string, isArchived: boolean) { return updateNote(id, { isArchived }) }
|
|
export async function updateColor(id: string, color: string) { return updateNote(id, { color }) }
|
|
export async function updateLabels(id: string, labels: string[]) { return updateNote(id, { labels }) }
|
|
export async function removeFusedBadge(id: string) { return updateNote(id, { autoGenerated: null, aiProvider: null }) }
|
|
|
|
// Update note size WITHOUT revalidation - client uses optimistic updates
|
|
export async function updateSize(id: string, size: 'small' | 'medium' | 'large') {
|
|
|
|
const result = await updateNote(id, { size })
|
|
|
|
return result
|
|
}
|
|
|
|
// Get all unique labels
|
|
export async function getAllLabels() {
|
|
const session = await auth();
|
|
if (!session?.user?.id) return [];
|
|
try {
|
|
const notes = await prisma.note.findMany({
|
|
where: { userId: session.user.id },
|
|
select: { labels: true }
|
|
})
|
|
const labelsSet = new Set<string>()
|
|
notes.forEach((note: any) => {
|
|
const labels = note.labels ? JSON.parse(note.labels) : null
|
|
if (labels) labels.forEach((label: string) => labelsSet.add(label))
|
|
})
|
|
return Array.from(labelsSet).sort()
|
|
} catch (error) {
|
|
console.error('Error fetching labels:', error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
// Reorder
|
|
export async function reorderNotes(draggedId: string, targetId: string) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) throw new Error('Unauthorized');
|
|
try {
|
|
const draggedNote = await prisma.note.findUnique({ where: { id: draggedId, userId: session.user.id } })
|
|
const targetNote = await prisma.note.findUnique({ where: { id: targetId, userId: session.user.id } })
|
|
if (!draggedNote || !targetNote) throw new Error('Notes not found')
|
|
const allNotes = await prisma.note.findMany({
|
|
where: { userId: session.user.id, isPinned: draggedNote.isPinned, isArchived: false, trashedAt: null },
|
|
orderBy: { order: 'asc' }
|
|
})
|
|
const reorderedNotes = allNotes.filter((n: any) => n.id !== draggedId)
|
|
const targetIndex = reorderedNotes.findIndex((n: any) => n.id === targetId)
|
|
reorderedNotes.splice(targetIndex, 0, draggedNote)
|
|
const updates = reorderedNotes.map((note: any, index: number) =>
|
|
prisma.note.update({ where: { id: note.id }, data: { order: index } })
|
|
)
|
|
await prisma.$transaction(updates)
|
|
revalidatePath('/home')
|
|
return { success: true }
|
|
} catch (error) {
|
|
throw new Error('Failed to reorder notes')
|
|
}
|
|
}
|
|
|
|
export async function updateFullOrder(ids: string[]) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) throw new Error('Unauthorized');
|
|
const userId = session.user.id;
|
|
try {
|
|
const updates = ids.map((id: string, index: number) =>
|
|
prisma.note.update({ where: { id, userId }, data: { order: index } })
|
|
)
|
|
await prisma.$transaction(updates)
|
|
revalidatePath('/home')
|
|
return { success: true }
|
|
} catch (error) {
|
|
throw new Error('Failed to update order')
|
|
}
|
|
}
|
|
|
|
// Optimized version for drag & drop - no revalidation to prevent double refresh
|
|
export async function updateFullOrderWithoutRevalidation(ids: string[]) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) throw new Error('Unauthorized');
|
|
const userId = session.user.id;
|
|
try {
|
|
// Verify all notes belong to the user before updating
|
|
const notes = await prisma.note.findMany({
|
|
where: { id: { in: ids }, userId },
|
|
select: { id: true },
|
|
})
|
|
const ownedIds = new Set(notes.map(n => n.id))
|
|
const validIds = ids.filter(id => ownedIds.has(id))
|
|
|
|
const updates = validIds.map((id: string, index: number) =>
|
|
prisma.note.update({ where: { id }, data: { order: index } })
|
|
)
|
|
await prisma.$transaction(updates)
|
|
return { success: true }
|
|
} catch (error) {
|
|
throw new Error('Failed to update order')
|
|
}
|
|
}
|
|
|
|
// Maintenance - Sync all labels and clean up orphans (par carnet, aligné sur syncLabels)
|
|
export async function cleanupAllOrphans() {
|
|
const session = await auth();
|
|
if (!session?.user?.id) throw new Error('Unauthorized');
|
|
const userId = session.user.id;
|
|
let createdCount = 0;
|
|
let deletedCount = 0;
|
|
const errors: any[] = [];
|
|
try {
|
|
const allNotes = await prisma.note.findMany({
|
|
where: { userId },
|
|
select: {
|
|
notebookId: true,
|
|
labels: true,
|
|
labelRelations: { select: { name: true } },
|
|
},
|
|
})
|
|
|
|
const usedSet = new Set<string>()
|
|
for (const note of allNotes) {
|
|
for (const name of collectLabelNamesFromNote(note)) {
|
|
const key = labelScopeKey(note.notebookId, name)
|
|
if (key) usedSet.add(key)
|
|
}
|
|
}
|
|
|
|
let allScoped = await prisma.label.findMany({
|
|
where: { userId },
|
|
select: { id: true, name: true, notebookId: true },
|
|
})
|
|
|
|
const ensuredPairs = new Set<string>()
|
|
for (const note of allNotes) {
|
|
for (const name of collectLabelNamesFromNote(note)) {
|
|
const key = labelScopeKey(note.notebookId, name)
|
|
if (!key || ensuredPairs.has(key)) continue
|
|
ensuredPairs.add(key)
|
|
const trimmed = name.trim()
|
|
const nb = note.notebookId ?? null
|
|
const exists = allScoped.some(
|
|
l => (l.notebookId ?? null) === nb && l.name.toLowerCase() === trimmed.toLowerCase()
|
|
)
|
|
if (exists) continue
|
|
try {
|
|
const created = await prisma.label.create({
|
|
data: {
|
|
userId,
|
|
name: trimmed,
|
|
color: getHashColor(trimmed),
|
|
notebookId: nb,
|
|
},
|
|
})
|
|
allScoped.push(created)
|
|
createdCount++
|
|
} catch (e: any) {
|
|
console.error(`Failed to create label:`, e)
|
|
errors.push({ label: trimmed, notebookId: nb, error: e.message, code: e.code })
|
|
allScoped = await prisma.label.findMany({
|
|
where: { userId },
|
|
select: { id: true, name: true, notebookId: true },
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
allScoped = await prisma.label.findMany({
|
|
where: { userId },
|
|
select: { id: true, name: true, notebookId: true },
|
|
})
|
|
for (const label of allScoped) {
|
|
const key = labelScopeKey(label.notebookId, label.name)
|
|
if (!key || usedSet.has(key)) continue
|
|
try {
|
|
await prisma.label.update({
|
|
where: { id: label.id },
|
|
data: { notes: { set: [] } },
|
|
})
|
|
await prisma.label.delete({ where: { id: label.id } })
|
|
deletedCount++
|
|
} catch (e: any) {
|
|
console.error(`Failed to delete orphan ${label.id}:`, e)
|
|
errors.push({ labelId: label.id, name: label.name, error: e?.message, code: e?.code })
|
|
}
|
|
}
|
|
|
|
revalidatePath('/home')
|
|
revalidatePath('/settings')
|
|
return {
|
|
success: true,
|
|
created: createdCount,
|
|
deleted: deletedCount,
|
|
errors,
|
|
message: `Created ${createdCount} missing label records, deleted ${deletedCount} orphan labels${errors.length > 0 ? ` (${errors.length} errors)` : ''}`
|
|
}
|
|
} catch (error) {
|
|
console.error('[CLEANUP] Fatal error:', error);
|
|
throw new Error('Failed to cleanup database')
|
|
}
|
|
}
|
|
|
|
export async function syncAllEmbeddings() {
|
|
const session = await auth();
|
|
if (!session?.user?.id) throw new Error('Unauthorized');
|
|
const userId = session.user.id;
|
|
let updatedCount = 0;
|
|
try {
|
|
const notesToSync = await prisma.note.findMany({
|
|
where: {
|
|
userId,
|
|
trashedAt: null,
|
|
noteEmbedding: { is: null }
|
|
}
|
|
})
|
|
const provider = getAIProvider(await getSystemConfig());
|
|
for (const note of notesToSync) {
|
|
if (!note.content) continue;
|
|
try {
|
|
const embedding = await provider.getEmbeddings(note.content);
|
|
if (embedding) {
|
|
await upsertNoteEmbedding(note.id, embedding)
|
|
updatedCount++;
|
|
}
|
|
} catch (e) { }
|
|
}
|
|
return { success: true, count: updatedCount }
|
|
} catch (error: any) {
|
|
throw new Error(`Failed to sync embeddings: ${error.message}`)
|
|
}
|
|
}
|
|
|
|
// Get all notes including those shared with the user
|
|
export async function getAllNotes(includeArchived = false, notebookId?: string) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) return [];
|
|
|
|
const userId = session.user.id;
|
|
|
|
try {
|
|
const whereClause: any = {
|
|
userId,
|
|
trashedAt: null,
|
|
...(includeArchived ? {} : { isArchived: false }),
|
|
...(notebookId !== undefined ? { notebookId: notebookId || null } : {}),
|
|
}
|
|
|
|
const ownNotes = await prisma.note.findMany({
|
|
where: whereClause,
|
|
select: NOTE_LIST_SELECT,
|
|
orderBy: [
|
|
{ isPinned: 'desc' },
|
|
{ order: 'asc' },
|
|
{ updatedAt: 'desc' }
|
|
]
|
|
})
|
|
|
|
if (notebookId) {
|
|
return ownNotes.map(parseNote)
|
|
}
|
|
|
|
const [acceptedShares] = await Promise.all([
|
|
prisma.noteShare.findMany({
|
|
where: { userId, status: 'accepted' },
|
|
include: { note: { select: NOTE_LIST_SELECT } }
|
|
})
|
|
])
|
|
|
|
const sharedNotes = acceptedShares
|
|
.map(share => share.note)
|
|
.filter(note => includeArchived || !note.isArchived)
|
|
.map(note => ({ ...note, _isShared: true }))
|
|
|
|
return [...ownNotes.map(parseNote), ...sharedNotes.map(parseNote)]
|
|
} catch (error) {
|
|
console.error('Error fetching notes:', error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
// Get pinned notes only
|
|
export async function getPinnedNotes(notebookId?: string) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) return [];
|
|
|
|
const userId = session.user.id;
|
|
|
|
try {
|
|
const notes = await prisma.note.findMany({
|
|
where: {
|
|
userId: userId,
|
|
isPinned: true,
|
|
isArchived: false,
|
|
trashedAt: null,
|
|
...(notebookId !== undefined ? { notebookId } : {})
|
|
},
|
|
orderBy: [
|
|
{ order: 'asc' },
|
|
{ updatedAt: 'desc' }
|
|
]
|
|
})
|
|
|
|
return notes.map(parseNote)
|
|
} catch (error) {
|
|
console.error('Error fetching pinned notes:', error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
// Get recent notes (notes modified in the last 7 days)
|
|
// Get recent notes (notes modified in the last 7 days)
|
|
export async function getRecentNotes(limit: number = 3) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) return [];
|
|
|
|
const userId = session.user.id;
|
|
|
|
try {
|
|
const sevenDaysAgo = new Date()
|
|
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
|
|
sevenDaysAgo.setHours(0, 0, 0, 0) // Set to start of day
|
|
|
|
const notes = await prisma.note.findMany({
|
|
where: {
|
|
userId: userId,
|
|
contentUpdatedAt: { gte: sevenDaysAgo },
|
|
isArchived: false,
|
|
trashedAt: null,
|
|
dismissedFromRecent: false // Filter out dismissed notes
|
|
},
|
|
orderBy: { contentUpdatedAt: 'desc' },
|
|
take: limit
|
|
})
|
|
|
|
return notes.map(parseNote)
|
|
} catch (error) {
|
|
console.error('Error fetching recent notes:', error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
// Dismiss a note from Recent section
|
|
export async function dismissFromRecent(id: string) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) throw new Error('Unauthorized');
|
|
|
|
try {
|
|
await prisma.note.update({
|
|
where: { id, userId: session.user.id },
|
|
data: { dismissedFromRecent: true }
|
|
})
|
|
|
|
// revalidatePath('/home') // Removed to prevent immediate refill of the list
|
|
return { success: true }
|
|
} catch (error) {
|
|
console.error('Error dismissing note from recent:', error)
|
|
throw new Error('Failed to dismiss note')
|
|
}
|
|
}
|
|
|
|
export async function getNoteById(noteId: string) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) return null;
|
|
|
|
const userId = session.user.id;
|
|
|
|
try {
|
|
const note = await prisma.note.findFirst({
|
|
where: {
|
|
id: noteId,
|
|
OR: [
|
|
{ userId: userId },
|
|
{
|
|
shares: {
|
|
some: {
|
|
userId: userId,
|
|
status: 'accepted'
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}
|
|
})
|
|
|
|
if (!note) return null
|
|
|
|
return parseNote(note)
|
|
} catch (error) {
|
|
console.error('Error fetching note:', error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
// ─── Backward-compat delegating wrappers ────────────────────────────────────
|
|
// 'use server' files only allow `export async function` declarations.
|
|
// Each wrapper is a thin pass-through to the dedicated sub-module.
|
|
import {
|
|
getNoteHistory as _getNoteHistory,
|
|
restoreNoteVersion as _restoreNoteVersion,
|
|
commitNoteHistory as _commitNoteHistory,
|
|
deleteNoteHistoryEntry as _deleteNoteHistoryEntry,
|
|
enableNoteHistory as _enableNoteHistory,
|
|
} from './notes-history'
|
|
import {
|
|
deleteNote as _deleteNote,
|
|
trashNote as _trashNote,
|
|
restoreNote as _restoreNote,
|
|
getTrashedNotes as _getTrashedNotes,
|
|
permanentDeleteNote as _permanentDeleteNote,
|
|
emptyTrash as _emptyTrash,
|
|
removeImageFromNote as _removeImageFromNote,
|
|
cleanupOrphanedImages as _cleanupOrphanedImages,
|
|
getTrashCount as _getTrashCount,
|
|
getTrashedNotebooks as _getTrashedNotebooks,
|
|
} from './notes-trash'
|
|
import {
|
|
addCollaborator as _addCollaborator,
|
|
removeCollaborator as _removeCollaborator,
|
|
getNoteCollaborators as _getNoteCollaborators,
|
|
getNoteAllUsers as _getNoteAllUsers,
|
|
createShareRequest as _createShareRequest,
|
|
getPendingShareRequests as _getPendingShareRequests,
|
|
respondToShareRequest as _respondToShareRequest,
|
|
getAcceptedSharedNotes as _getAcceptedSharedNotes,
|
|
removeSharedNoteFromView as _removeSharedNoteFromView,
|
|
getPendingShareCount as _getPendingShareCount,
|
|
leaveSharedNote as _leaveSharedNote,
|
|
} from './notes-sharing'
|
|
|
|
// History
|
|
export async function getNoteHistory(...args: Parameters<typeof _getNoteHistory>) { return _getNoteHistory(...args) }
|
|
export async function restoreNoteVersion(...args: Parameters<typeof _restoreNoteVersion>) { return _restoreNoteVersion(...args) }
|
|
export async function commitNoteHistory(...args: Parameters<typeof _commitNoteHistory>) { return _commitNoteHistory(...args) }
|
|
export async function deleteNoteHistoryEntry(...args: Parameters<typeof _deleteNoteHistoryEntry>) { return _deleteNoteHistoryEntry(...args) }
|
|
export async function enableNoteHistory(...args: Parameters<typeof _enableNoteHistory>) { return _enableNoteHistory(...args) }
|
|
|
|
// Trash & images
|
|
export async function deleteNote(...args: Parameters<typeof _deleteNote>) { return _deleteNote(...args) }
|
|
export async function trashNote(...args: Parameters<typeof _trashNote>) { return _trashNote(...args) }
|
|
export async function restoreNote(...args: Parameters<typeof _restoreNote>) { return _restoreNote(...args) }
|
|
export async function getTrashedNotes(...args: Parameters<typeof _getTrashedNotes>) { return _getTrashedNotes(...args) }
|
|
export async function permanentDeleteNote(...args: Parameters<typeof _permanentDeleteNote>) { return _permanentDeleteNote(...args) }
|
|
export async function emptyTrash(...args: Parameters<typeof _emptyTrash>) { return _emptyTrash(...args) }
|
|
export async function removeImageFromNote(...args: Parameters<typeof _removeImageFromNote>) { return _removeImageFromNote(...args) }
|
|
export async function cleanupOrphanedImages(...args: Parameters<typeof _cleanupOrphanedImages>) { return _cleanupOrphanedImages(...args) }
|
|
export async function getTrashCount(...args: Parameters<typeof _getTrashCount>) { return _getTrashCount(...args) }
|
|
export async function getTrashedNotebooks(...args: Parameters<typeof _getTrashedNotebooks>) { return _getTrashedNotebooks(...args) }
|
|
|
|
// Sharing
|
|
export async function addCollaborator(...args: Parameters<typeof _addCollaborator>) { return _addCollaborator(...args) }
|
|
export async function removeCollaborator(...args: Parameters<typeof _removeCollaborator>) { return _removeCollaborator(...args) }
|
|
export async function getNoteCollaborators(...args: Parameters<typeof _getNoteCollaborators>) { return _getNoteCollaborators(...args) }
|
|
export async function getNoteAllUsers(...args: Parameters<typeof _getNoteAllUsers>) { return _getNoteAllUsers(...args) }
|
|
export async function createShareRequest(...args: Parameters<typeof _createShareRequest>) { return _createShareRequest(...args) }
|
|
export async function getPendingShareRequests(...args: Parameters<typeof _getPendingShareRequests>) { return _getPendingShareRequests(...args) }
|
|
export async function respondToShareRequest(...args: Parameters<typeof _respondToShareRequest>) { return _respondToShareRequest(...args) }
|
|
export async function getAcceptedSharedNotes(...args: Parameters<typeof _getAcceptedSharedNotes>) { return _getAcceptedSharedNotes(...args) }
|
|
export async function removeSharedNoteFromView(...args: Parameters<typeof _removeSharedNoteFromView>) { return _removeSharedNoteFromView(...args) }
|
|
export async function getPendingShareCount(...args: Parameters<typeof _getPendingShareCount>) { return _getPendingShareCount(...args) }
|
|
export async function leaveSharedNote(...args: Parameters<typeof _leaveSharedNote>) { return _leaveSharedNote(...args) }
|