diff --git a/memento-note/app/actions/notes.ts b/memento-note/app/actions/notes.ts index 2ea9d0a..b42ff97 100644 --- a/memento-note/app/actions/notes.ts +++ b/memento-note/app/actions/notes.ts @@ -99,85 +99,55 @@ function collectLabelNamesFromNote(note: { } /** - * Sync Label rows with Note.labels. - * Optimisé: createMany (bulk) + delete en parallèle — uniquement 3-4 requêtes au lieu de N+2. + * 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) { +async function syncLabels(userId: string, noteLabels: string[] = [], notebookId?: string | null): Promise<{ id: string; name: string }[]> { try { const nbScope = notebookId ?? null - // 1. Bulk-upsert les nouveaux labels via upsert en transaction if (noteLabels.length > 0) { const trimmedNames = [...new Set( noteLabels.map(name => name?.trim()).filter((n): n is string => Boolean(n)) )] - - if (trimmedNames.length > 0) { - await prisma.$transaction( - trimmedNames.map(name => - prisma.label.upsert({ - where: { notebookId_name: { notebookId: nbScope ?? '', name } as any }, - update: {}, - create: { - userId, - name, - color: getHashColor(name), - notebookId: nbScope, - }, - }) - ) - ) + for (const name of trimmedNames) { + const existing = await prisma.label.findFirst({ + where: { userId, name, notebookId: nbScope }, + }) + if (!existing) { + await prisma.label.create({ + data: { userId, name, color: getHashColor(name), notebookId: nbScope }, + }) + } } } - // 2. Récupérer les labels utilisés par toutes les notes de l'utilisateur - const [allNotes, allLabels] = await Promise.all([ - prisma.note.findMany({ - where: { userId }, - select: { - notebookId: true, - labels: true, - labelRelations: { select: { name: true } }, - }, - }), - prisma.label.findMany({ - where: { userId }, - select: { id: true, name: true, notebookId: true }, - }) - ]) - - const usedLabelsSet = new Set() - for (const note of allNotes) { - for (const name of collectLabelNamesFromNote(note)) { - const key = labelScopeKey(note.notebookId, name) - if (key) usedLabelsSet.add(key) - } - } - - // 3. Supprimer les labels orphelins - const orphanIds = allLabels - .filter(label => { - const key = labelScopeKey(label.notebookId, label.name) - return key && !usedLabelsSet.has(key) - }) - .map(label => label.id) - - if (orphanIds.length > 0) { - // Dissocier les relations avant la suppression - await prisma.label.updateMany({ - where: { id: { in: orphanIds } }, - data: {} // Nécessaire pour trigger le middleware - }) - // Supprimer en une seule requête - await prisma.label.deleteMany({ - where: { id: { in: orphanIds } } - }) - } + if (noteLabels.length === 0) return [] + const uniqueNames = [...new Set(noteLabels.map(n => n.trim().toLowerCase()).filter(Boolean))] + return prisma.label.findMany({ + where: { userId, notebookId: nbScope, name: { in: uniqueNames } }, + select: { id: true, name: true }, + }) } catch (error) { console.error('Fatal error in syncLabels:', error) + return [] } } +/** Sync both Note.labels (JSON) AND labelRelations for a single note. */ +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 })) }, + }, + }) +} + /** 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() @@ -198,7 +168,7 @@ export async function reconcileLabelsAfterNoteMove(noteId: string, newNotebookId /* ignore */ } } - await syncLabels(session.user.id, labels, newNotebookId) + await syncNoteLabels(noteId, labels, newNotebookId, session.user.id) } // Get all notes (non-archived by default) @@ -441,7 +411,7 @@ export async function createNote(data: { color: data.color || 'default', type: data.type || 'text', checkItems: data.checkItems ? JSON.stringify(data.checkItems) : null, - labels: data.labels && data.labels.length > 0 ? JSON.stringify(data.labels) : 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, @@ -454,9 +424,9 @@ export async function createNote(data: { } }) - // Sync user-provided labels immediately (étiquettes rattachées au carnet de la note) + // Sync labels (JSON + labelRelations + Label rows) in one call if (data.labels && data.labels.length > 0) { - await syncLabels(session.user.id, data.labels, data.notebookId ?? null) + await syncNoteLabels(note.id, data.labels, data.notebookId ?? null, session.user.id) } if (!data.skipRevalidation) { @@ -528,11 +498,22 @@ export async function createNote(data: { .map(s => s.label) if (appliedLabels.length > 0) { - await prisma.note.update({ + // Merge with existing labels + const existing = await prisma.note.findUnique({ where: { id: noteId }, - data: { labels: JSON.stringify(appliedLabels) } + select: { labels: true }, }) - await syncLabels(userId, appliedLabels, notebookId ?? null) + 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('/') } @@ -616,7 +597,8 @@ export async function updateNote(id: string, data: { } if ('checkItems' in data) updateData.checkItems = data.checkItems ? JSON.stringify(data.checkItems) : null - if ('labels' in data) updateData.labels = data.labels ? JSON.stringify(data.labels) : null + // labels handled by syncNoteLabels below + delete updateData.labels if ('images' in data) updateData.images = data.images ? JSON.stringify(data.images) : null if ('links' in data) updateData.links = data.links ? JSON.stringify(data.links) : null if ('notebookId' in data) updateData.notebookId = data.notebookId @@ -637,14 +619,14 @@ export async function updateNote(id: string, data: { data: updateData }) - // Sync Label rows (carnet + noms) quand les étiquettes changent ou que la note change de carnet + // 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 syncLabels(session.user.id, labelsToSync, effectiveNotebookId ?? null) + await syncNoteLabels(id, labelsToSync, effectiveNotebookId ?? null, session.user.id) } // Only revalidate for STRUCTURAL changes that affect the page layout/lists diff --git a/memento-note/lib/ai/services/contextual-auto-tag.service.ts b/memento-note/lib/ai/services/contextual-auto-tag.service.ts index 4da9f80..dcf2713 100644 --- a/memento-note/lib/ai/services/contextual-auto-tag.service.ts +++ b/memento-note/lib/ai/services/contextual-auto-tag.service.ts @@ -55,10 +55,12 @@ export class ContextualAutoTagService { // CASE 1: Notebook has existing labels → suggest from them (IA2) if (notebook.labels.length > 0) { - return await this.suggestFromExistingLabels(noteContent, notebook, language) + const existing = await this.suggestFromExistingLabels(noteContent, notebook, language) + if (existing.length > 0) return existing + // Fallback: no existing label matched → suggest new ones too } - // CASE 2: Notebook has NO labels → suggest NEW labels to create + // CASE 2: No labels in notebook (or none matched) → suggest NEW labels return await this.suggestNewLabels(noteContent, notebook, language) } @@ -126,20 +128,23 @@ export class ContextualAutoTagService { return [] } - // Filter and map suggestions + // Filter and map suggestions (case-insensitive) + const lowerAvailable = availableLabels.map((l: string) => l.toLowerCase()) const suggestions = suggestionsArray .filter((s: any) => { - // Must be in available labels - return availableLabels.includes(s.label) && s.confidence > 0.6 + return s.label && lowerAvailable.includes(s.label.toLowerCase()) && (s.confidence || 0) > 0.3 }) - .map((s: any) => ({ - label: s.label, + .map((s: any) => { + const originalLabel = availableLabels.find((l: string) => l.toLowerCase() === s.label.toLowerCase()) || s.label + return { + label: originalLabel, confidence: Math.round(s.confidence * 100), reasoning: s.reasoning || '', isNewLabel: false, - })) + } + }) .sort((a: any, b: any) => b.confidence - a.confidence) - .slice(0, 3) // Max 3 suggestions + .slice(0, 5) return suggestions as LabelSuggestion[] } catch (error) { @@ -213,7 +218,7 @@ export class ContextualAutoTagService { // Filter and map suggestions const suggestions = suggestionsArray .filter((s: any) => { - return s.label && s.label.length > 0 && s.confidence > 0.6 + return s.label && s.label.length > 0 && (s.confidence || 0) > 0.3 }) .map((s: any) => ({ label: s.label, @@ -222,7 +227,7 @@ export class ContextualAutoTagService { isNewLabel: true, // Mark as new label suggestion })) .sort((a: any, b: any) => b.confidence - a.confidence) - .slice(0, 3) // Max 3 suggestions + .slice(0, 5) return suggestions as LabelSuggestion[] } catch (error) {