diff --git a/keep-notes/app/(main)/page.tsx b/keep-notes/app/(main)/page.tsx index e37f1f9..a714aca 100644 --- a/keep-notes/app/(main)/page.tsx +++ b/keep-notes/app/(main)/page.tsx @@ -154,15 +154,19 @@ export default function HomePage() { // Load settings + notes in a single effect to avoid cascade re-renders useEffect(() => { + let cancelled = false + const load = async () => { // Load settings first let showRecent = true try { const settings = await getAISettings() + if (cancelled) return showRecent = settings?.showRecentNotes !== false - } catch (error) { + } catch { // Default to true on error } + if (cancelled) return setShowRecentNotes(showRecent) // Then load notes @@ -174,12 +178,12 @@ export default function HomePage() { const notebookFilter = searchParams.get('notebook') let allNotes = search ? await searchNotes(search, semanticMode, notebookFilter || undefined) : await getAllNotes() + if (cancelled) return // Filter by selected notebook if (notebookFilter) { allNotes = allNotes.filter((note: any) => note.notebookId === notebookFilter) } else { - // If no notebook selected, only show notes without notebook (Notes générales) allNotes = allNotes.filter((note: any) => !note.notebookId) } @@ -190,9 +194,7 @@ export default function HomePage() { ) } - // Filter by color (filter notes that have labels with this color) - // Note: We use a ref-like pattern to avoid including labels in dependencies - // This prevents dialog closing when adding new labels + // Filter by color if (colorFilter) { const labelNamesWithColor = labels .filter((label: any) => label.color === colorFilter) @@ -226,7 +228,6 @@ export default function HomePage() { if (notebookFilter) { setRecentNotes(recentFiltered.filter((note: any) => note.notebookId === notebookFilter)) } else { - // Show ALL recent notes when in inbox (not just notes without notebooks) setRecentNotes(recentFiltered) } } else { @@ -238,6 +239,7 @@ export default function HomePage() { } load() + return () => { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchParams, refreshKey]) // Intentionally omit 'labels' to prevent reload when adding tags // Get notebooks context to display header diff --git a/keep-notes/app/(main)/settings/page.tsx b/keep-notes/app/(main)/settings/page.tsx index c628060..6dfac31 100644 --- a/keep-notes/app/(main)/settings/page.tsx +++ b/keep-notes/app/(main)/settings/page.tsx @@ -1,16 +1,24 @@ 'use client' import React, { useState } from 'react' -import { SettingsNav, SettingsSection } from '@/components/settings' +import { useRouter } from 'next/navigation' +import { SettingsSection } from '@/components/settings' import { Button } from '@/components/ui/button' import { Loader2, CheckCircle, XCircle, RefreshCw, Database, BrainCircuit } from 'lucide-react' import { cleanupAllOrphans, syncAllEmbeddings } from '@/app/actions/notes' import { toast } from 'sonner' import { useLanguage } from '@/lib/i18n' import Link from 'next/link' +import { useLabels } from '@/context/LabelContext' +import { useNotebooks } from '@/context/notebooks-context' +import { useNoteRefresh } from '@/context/NoteRefreshContext' export default function SettingsPage() { const { t } = useLanguage() + const router = useRouter() + const { refreshLabels } = useLabels() + const { refreshNotebooks } = useNotebooks() + const { triggerRefresh } = useNoteRefresh() const [loading, setLoading] = useState(false) const [cleanupLoading, setCleanupLoading] = useState(false) const [syncLoading, setSyncLoading] = useState(false) @@ -52,11 +60,13 @@ export default function SettingsPage() { try { const result = await syncAllEmbeddings() if (result.success) { - toast.success(`Indexing complete: ${result.count} notes processed`) + toast.success(t('settings.indexingComplete', { count: result.count ?? 0 })) + triggerRefresh() + router.refresh() } } catch (error) { console.error(error) - toast.error("Error during indexing") + toast.error(t('settings.indexingError')) } finally { setSyncLoading(false) } @@ -67,11 +77,26 @@ export default function SettingsPage() { try { const result = await cleanupAllOrphans() if (result.success) { - toast.success(result.message || `Cleanup complete: ${result.created} created, ${result.deleted} removed`) + const errCount = Array.isArray(result.errors) ? result.errors.length : 0 + if (result.created === 0 && result.deleted === 0 && errCount === 0) { + toast.info(t('settings.cleanupNothing')) + } else { + const base = t('settings.cleanupDone', { + created: result.created ?? 0, + deleted: result.deleted ?? 0, + }) + toast.success(errCount > 0 ? `${base} (${t('settings.cleanupWithErrors')})` : base) + } + await refreshLabels() + await refreshNotebooks() + triggerRefresh() + router.refresh() + } else { + toast.error(t('settings.cleanupError')) } } catch (error) { console.error(error) - toast.error("Error during cleanup") + toast.error(t('settings.cleanupError')) } finally { setCleanupLoading(false) } @@ -82,7 +107,7 @@ export default function SettingsPage() {

{t('settings.title')}

-

+

{t('settings.description')}

@@ -176,31 +201,31 @@ export default function SettingsPage() { description={t('settings.maintenanceDescription')} >
-
-
+
+

{t('settings.cleanTags')}

-

+

{t('settings.cleanTagsDescription')}

-
-
-
+
+

{t('settings.semanticIndexing')}

-

+

{t('settings.semanticIndexingDescription')}

- diff --git a/keep-notes/app/actions/notes.ts b/keep-notes/app/actions/notes.ts index e10ae5c..bc1fd01 100644 --- a/keep-notes/app/actions/notes.ts +++ b/keep-notes/app/actions/notes.ts @@ -37,90 +37,108 @@ function getHashColor(name: string): string { return colors[Math.abs(hash) % colors.length] } -// Comprehensive sync function for labels - ensures consistency between Note.labels and Label table -async function syncLabels(userId: string, noteLabels: string[] = []) { +/** 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 +} + +/** + * Sync Label rows with Note.labels + labelRelations. + * Les étiquettes d’un carnet doivent avoir le même notebookId que les notes (liste latérale / filtres). + */ +async function syncLabels(userId: string, noteLabels: string[] = [], notebookId?: string | null) { try { - // Step 1: Create Label records for any labels in notes that don't exist in Label table - // Get all existing labels for this user to do case-insensitive check in JS - const existingLabels = await prisma.label.findMany({ - where: { userId }, - select: { id: true, name: true } - }) + const nbScope = notebookId ?? null - // Create a map for case-insensitive lookup - const existingLabelMap = new Map() - existingLabels.forEach(label => { - existingLabelMap.set(label.name.toLowerCase(), label.name) - }) - - for (const labelName of noteLabels) { - if (!labelName || labelName.trim() === '') continue - - const trimmedLabel = labelName.trim() - const lowerLabel = trimmedLabel.toLowerCase() - - // Check if label already exists (case-insensitive) - const existingName = existingLabelMap.get(lowerLabel) - - // If label doesn't exist, create it - if (!existingName) { + if (noteLabels.length > 0) { + let scoped = await prisma.label.findMany({ + where: { userId }, + select: { id: true, name: true, notebookId: true }, + }) + for (const labelName of noteLabels) { + if (!labelName?.trim()) continue + const trimmed = labelName.trim() + const exists = scoped.some( + l => (l.notebookId ?? null) === nbScope && l.name.toLowerCase() === trimmed.toLowerCase() + ) + if (exists) continue try { - await prisma.label.create({ + const created = await prisma.label.create({ data: { userId, - name: trimmedLabel, - color: getHashColor(trimmedLabel) - } + name: trimmed, + color: getHashColor(trimmed), + notebookId: nbScope, + }, }) - // Add to map to prevent duplicates in same batch - existingLabelMap.set(lowerLabel, trimmedLabel) + scoped.push(created) } catch (e: any) { - // Ignore unique constraint violations (race condition) if (e.code !== 'P2002') { - console.error(`[SYNC] Failed to create label "${trimmedLabel}":`, e) + console.error(`[SYNC] Failed to create label "${trimmed}":`, e) } + scoped = await prisma.label.findMany({ + where: { userId }, + select: { id: true, name: true, notebookId: true }, + }) } } } - // Step 2: Get ALL labels currently used in ALL user's notes const allNotes = await prisma.note.findMany({ where: { userId }, - select: { labels: true } + select: { + notebookId: true, + labels: true, + labelRelations: { select: { name: true } }, + }, }) const usedLabelsSet = new Set() - allNotes.forEach(note => { - if (note.labels) { - try { - const parsedLabels: string[] = JSON.parse(note.labels) - if (Array.isArray(parsedLabels)) { - parsedLabels.forEach(l => { - if (l && l.trim()) { - usedLabelsSet.add(l.trim().toLowerCase()) - } - }) - } - } catch (e) { - console.error('[SYNC] Failed to parse labels:', e) - } + for (const note of allNotes) { + for (const name of collectLabelNamesFromNote(note)) { + const key = labelScopeKey(note.notebookId, name) + if (key) usedLabelsSet.add(key) } - }) - - // Step 3: Delete orphan Label records (labels not in any note) - const allLabels = await prisma.label.findMany({ - where: { userId } - }) + } + const allLabels = await prisma.label.findMany({ where: { userId } }) for (const label of allLabels) { - if (!usedLabelsSet.has(label.name.toLowerCase())) { - try { - await prisma.label.delete({ - where: { id: label.id } - }) - } catch (e) { - console.error(`Failed to delete orphan label:`, e) - } + const key = labelScopeKey(label.notebookId, label.name) + if (!key || usedLabelsSet.has(key)) continue + try { + await prisma.label.update({ + where: { id: label.id }, + data: { notes: { set: [] } }, + }) + await prisma.label.delete({ where: { id: label.id } }) + } catch (e) { + console.error('[SYNC] Failed to delete orphan label:', e) } } } catch (error) { @@ -128,6 +146,29 @@ async function syncLabels(userId: string, noteLabels: string[] = []) { } } +/** 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 syncLabels(session.user.id, labels, newNotebookId) +} + // Get all notes (non-archived by default) export async function getNotes(includeArchived = false) { const session = await auth(); @@ -375,9 +416,9 @@ export async function createNote(data: { } }) - // Sync user-provided labels immediately + // Sync user-provided labels immediately (étiquettes rattachées au carnet de la note) if (data.labels && data.labels.length > 0) { - await syncLabels(session.user.id, data.labels) + await syncLabels(session.user.id, data.labels, data.notebookId ?? null) } // Revalidate main page (handles both inbox and notebook views via query params) @@ -428,7 +469,7 @@ export async function createNote(data: { where: { id: noteId }, data: { labels: JSON.stringify(appliedLabels) } }) - await syncLabels(userId, appliedLabels) + await syncLabels(userId, appliedLabels, notebookId ?? null) revalidatePath('/') } } @@ -526,10 +567,14 @@ export async function updateNote(id: string, data: { data: updateData }) - // Sync labels to ensure consistency between Note.labels and Label table - // This handles both creating new Label records and cleaning up orphans - if (data.labels !== undefined) { - await syncLabels(session.user.id, data.labels || []) + // Sync Label rows (carnet + noms) quand les étiquettes changent ou que la note change de carnet + 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) } // Only revalidate for STRUCTURAL changes that affect the page layout/lists @@ -667,7 +712,7 @@ export async function updateFullOrderWithoutRevalidation(ids: string[]) { } } -// Maintenance - Sync all labels and clean up orphans +// 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'); @@ -676,82 +721,84 @@ export async function cleanupAllOrphans() { let deletedCount = 0; let errors: any[] = []; try { - // Step 1: Get all labels from notes - const allNotes = await prisma.note.findMany({ where: { userId }, select: { labels: true } }) - const allNoteLabels = new Set(); - allNotes.forEach(note => { - if (note.labels) { - try { - const parsedLabels: string[] = JSON.parse(note.labels); - if (Array.isArray(parsedLabels)) { - parsedLabels.forEach(l => { - if (l && l.trim()) allNoteLabels.add(l.trim()); - }); - } - } catch (e) { - console.error('[CLEANUP] Failed to parse labels:', e); - } - } - }); - - // Step 2: Get existing labels for case-insensitive comparison - const existingLabels = await prisma.label.findMany({ + const allNotes = await prisma.note.findMany({ where: { userId }, - select: { id: true, name: true } - }) - const existingLabelMap = new Map() - existingLabels.forEach(label => { - existingLabelMap.set(label.name.toLowerCase(), label.name) + select: { + notebookId: true, + labels: true, + labelRelations: { select: { name: true } }, + }, }) - // Step 3: Create missing Label records - for (const labelName of allNoteLabels) { - const lowerLabel = labelName.toLowerCase(); - const existingName = existingLabelMap.get(lowerLabel); + const usedSet = new Set() + for (const note of allNotes) { + for (const name of collectLabelNamesFromNote(note)) { + const key = labelScopeKey(note.notebookId, name) + if (key) usedSet.add(key) + } + } - if (!existingName) { + let allScoped = await prisma.label.findMany({ + where: { userId }, + select: { id: true, name: true, notebookId: true }, + }) + + const ensuredPairs = new Set() + 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 { - await prisma.label.create({ + const created = await prisma.label.create({ data: { userId, - name: labelName, - color: getHashColor(labelName) - } - }); - createdCount++; - existingLabelMap.set(lowerLabel, labelName); + name: trimmed, + color: getHashColor(trimmed), + notebookId: nb, + }, + }) + allScoped.push(created) + createdCount++ } catch (e: any) { - console.error(`Failed to create label:`, e); - errors.push({ label: labelName, error: e.message, code: e.code }); - // Continue with next label + 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 }, + }) } } } - // Step 4: Delete orphan Label records - const allDefinedLabels = await prisma.label.findMany({ where: { userId }, select: { id: true, name: true } }) - const usedLabelsSet = new Set(); - allNotes.forEach(note => { - if (note.labels) { - try { - const parsedLabels: string[] = JSON.parse(note.labels); - if (Array.isArray(parsedLabels)) parsedLabels.forEach(l => usedLabelsSet.add(l.toLowerCase())); - } catch (e) { - console.error('Failed to parse labels for orphan check:', e); - } - } - }); - const orphans = allDefinedLabels.filter(label => !usedLabelsSet.has(label.name.toLowerCase())); - for (const orphan of orphans) { + 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.delete({ where: { id: orphan.id } }); - deletedCount++; - } catch (e) { - console.error(`Failed to delete orphan:`, e); + 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('/') + revalidatePath('/settings') return { success: true, created: createdCount, diff --git a/keep-notes/app/api/ai/batch-organize/route.ts b/keep-notes/app/api/ai/batch-organize/route.ts index 0b8118a..30f476a 100644 --- a/keep-notes/app/api/ai/batch-organize/route.ts +++ b/keep-notes/app/api/ai/batch-organize/route.ts @@ -42,6 +42,7 @@ export async function POST(request: NextRequest) { data: plan, }) } catch (error) { + console.error('[batch-organize POST] Error:', error) return NextResponse.json( { success: false, diff --git a/keep-notes/app/api/notes/[id]/move/route.ts b/keep-notes/app/api/notes/[id]/move/route.ts index fd627b5..fcae75f 100644 --- a/keep-notes/app/api/notes/[id]/move/route.ts +++ b/keep-notes/app/api/notes/[id]/move/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import prisma from '@/lib/prisma' import { auth } from '@/auth' import { revalidatePath } from 'next/cache' +import { reconcileLabelsAfterNoteMove } from '@/app/actions/notes' // POST /api/notes/[id]/move - Move a note to a notebook (or to Inbox) export async function POST( @@ -60,10 +61,12 @@ export async function POST( // Update the note's notebook // notebookId = null or "" means move to Inbox (Notes générales) + const targetNotebookId = notebookId && notebookId !== '' ? notebookId : null + const updatedNote = await prisma.note.update({ where: { id }, data: { - notebookId: notebookId && notebookId !== '' ? notebookId : null + notebookId: targetNotebookId }, include: { notebook: { @@ -72,6 +75,8 @@ export async function POST( } }) + await reconcileLabelsAfterNoteMove(id, targetNotebookId) + revalidatePath('/') return NextResponse.json({ diff --git a/keep-notes/components/batch-organization-dialog.tsx b/keep-notes/components/batch-organization-dialog.tsx index 5fad0f5..9e10c67 100644 --- a/keep-notes/components/batch-organization-dialog.tsx +++ b/keep-notes/components/batch-organization-dialog.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useState, useEffect } from 'react' import { Button } from './ui/button' import { Dialog, @@ -27,21 +27,24 @@ export function BatchOrganizationDialog({ onOpenChange, onNotesMoved, }: BatchOrganizationDialogProps) { - const { t } = useLanguage() + const { t, language } = useLanguage() const [plan, setPlan] = useState(null) const [loading, setLoading] = useState(false) const [applying, setApplying] = useState(false) const [selectedNotes, setSelectedNotes] = useState>(new Set()) + const [fetchError, setFetchError] = useState(null) + const fetchOrganizationPlan = async () => { setLoading(true) + setFetchError(null) try { const response = await fetch('/api/ai/batch-organize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ - language: document.documentElement.lang || 'en' + language: language || 'en' }), }) @@ -49,32 +52,38 @@ export function BatchOrganizationDialog({ if (data.success && data.data) { setPlan(data.data) - // Select all notes by default const allNoteIds = new Set() data.data.notebooks.forEach((nb: NotebookOrganization) => { nb.notes.forEach(note => allNoteIds.add(note.noteId)) }) setSelectedNotes(allNoteIds) } else { - toast.error(data.error || t('ai.batchOrganization.error')) + const msg = data.error || t('ai.batchOrganization.error') + setFetchError(msg) + toast.error(msg) } } catch (error) { console.error('Failed to create organization plan:', error) - toast.error(t('ai.batchOrganization.error')) + const msg = t('ai.batchOrganization.error') + setFetchError(msg) + toast.error(msg) } finally { setLoading(false) } } - const handleOpenChange = (isOpen: boolean) => { - if (!isOpen) { - // Reset state when closing + useEffect(() => { + if (open) { + fetchOrganizationPlan() + } else { setPlan(null) setSelectedNotes(new Set()) - } else { - // Fetch plan when opening - fetchOrganizationPlan() + setFetchError(null) } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + const handleOpenChange = (isOpen: boolean) => { onOpenChange(isOpen) } @@ -173,6 +182,13 @@ export function BatchOrganizationDialog({ {t('ai.batchOrganization.analyzing')}

+ ) : fetchError ? ( +
+

{fetchError}

+ +
) : plan ? (
{/* Summary */} diff --git a/keep-notes/components/connections-badge.tsx b/keep-notes/components/connections-badge.tsx index 16ca2a5..8da9f51 100644 --- a/keep-notes/components/connections-badge.tsx +++ b/keep-notes/components/connections-badge.tsx @@ -1,6 +1,6 @@ 'use client' -import { memo, useState, useEffect } from 'react' +import { memo, useState, useEffect, useRef, useCallback } from 'react' import { Sparkles } from 'lucide-react' import { cn } from '@/lib/utils' import { useLanguage } from '@/lib/i18n/LanguageProvider' @@ -15,45 +15,40 @@ interface ConnectionsBadgeProps { export const ConnectionsBadge = memo(function ConnectionsBadge({ noteId, onClick, className }: ConnectionsBadgeProps) { const { t } = useLanguage() const [connectionCount, setConnectionCount] = useState(0) - const [isLoading, setIsLoading] = useState(false) const [isHovered, setIsHovered] = useState(false) - const [fetchAttempted, setFetchAttempted] = useState(false) + const containerRef = useRef(null) + const fetchedRef = useRef(false) + + const fetchConnections = useCallback(async () => { + if (fetchedRef.current) return + fetchedRef.current = true + try { + const count = await getConnectionsCount(noteId) + setConnectionCount(count) + } catch { + setConnectionCount(0) + } + }, [noteId]) useEffect(() => { - if (fetchAttempted) return - setFetchAttempted(true) + const el = containerRef.current + if (!el) return - let isMounted = true - - const fetchConnections = async () => { - setIsLoading(true) - try { - const count = await getConnectionsCount(noteId) - if (isMounted) { - setConnectionCount(count) + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + fetchConnections() + observer.disconnect() } - } catch (error) { - console.error('[ConnectionsBadge] Failed to fetch connections:', error) - if (isMounted) { - setConnectionCount(0) - } - } finally { - if (isMounted) { - setIsLoading(false) - } - } - } + }, + { rootMargin: '200px' } + ) + observer.observe(el) + return () => observer.disconnect() + }, [fetchConnections]) - fetchConnections() - - return () => { - isMounted = false - } - }, [noteId]) // eslint-disable-line react-hooks/exhaustive-deps - - // Don't render if no connections or still loading - if (connectionCount === 0 || isLoading) { - return null + if (connectionCount === 0) { + return } const plural = connectionCount > 1 ? 's' : '' diff --git a/keep-notes/components/label-management-dialog.tsx b/keep-notes/components/label-management-dialog.tsx index 9078223..e77847c 100644 --- a/keep-notes/components/label-management-dialog.tsx +++ b/keep-notes/components/label-management-dialog.tsx @@ -11,19 +11,27 @@ import { DialogTitle, DialogTrigger, } from './ui/dialog' -import { Badge } from './ui/badge' import { Settings, Plus, Palette, Trash2, Tag } from 'lucide-react' import { LABEL_COLORS, LabelColorName } from '@/lib/types' import { cn } from '@/lib/utils' import { useLabels } from '@/context/LabelContext' import { useLanguage } from '@/lib/i18n' -export function LabelManagementDialog() { +export interface LabelManagementDialogProps { + /** Mode contrôlé (ex. ouverture depuis la liste des carnets) */ + open?: boolean + onOpenChange?: (open: boolean) => void +} + +export function LabelManagementDialog(props: LabelManagementDialogProps = {}) { + const { open, onOpenChange } = props const { labels, loading, addLabel, updateLabel, deleteLabel } = useLabels() const { t } = useLanguage() const [newLabel, setNewLabel] = useState('') const [editingColorId, setEditingColorId] = useState(null) + const controlled = open !== undefined && onOpenChange !== undefined + const handleAddLabel = async () => { const trimmed = newLabel.trim() if (trimmed) { @@ -55,13 +63,7 @@ export function LabelManagementDialog() { } } - return ( - - - - + const dialogContent = ( { @@ -117,23 +119,23 @@ export function LabelManagementDialog() { {/* List labels */}
{loading ? ( -

{t('labels.loading')}

+

{t('labels.loading')}

) : labels.length === 0 ? ( -

{t('labels.noLabelsFound')}

+

{t('labels.noLabelsFound')}

) : ( labels.map((label) => { const colorClasses = LABEL_COLORS[label.color] const isEditing = editingColorId === label.id return ( -
+
{label.name} {/* Color Picker Popover */} {isEditing && ( -
+
{(Object.keys(LABEL_COLORS) as LabelColorName[]).map((color) => { const classes = LABEL_COLORS[color] @@ -159,7 +161,7 @@ export function LabelManagementDialog() {
+ ) + + if (controlled) { + return ( + + {dialogContent} + + ) + } + + return ( + + + + + {dialogContent} ) } diff --git a/keep-notes/components/notebooks-list.tsx b/keep-notes/components/notebooks-list.tsx index c236cb9..56fdcf5 100644 --- a/keep-notes/components/notebooks-list.tsx +++ b/keep-notes/components/notebooks-list.tsx @@ -15,6 +15,7 @@ import { EditNotebookDialog } from './edit-notebook-dialog' import { NotebookSummaryDialog } from './notebook-summary-dialog' import { useLanguage } from '@/lib/i18n' import { useLabels } from '@/context/LabelContext' +import { LabelManagementDialog } from '@/components/label-management-dialog' import { Notebook } from '@/lib/types' // Map icon names to lucide-react components @@ -53,6 +54,7 @@ export function NotebooksList() { const [deletingNotebook, setDeletingNotebook] = useState(null) const [summaryNotebook, setSummaryNotebook] = useState(null) const [expandedNotebook, setExpandedNotebook] = useState(null) + const [labelsDialogOpen, setLabelsDialogOpen] = useState(false) const currentNotebookId = searchParams.get('notebook') @@ -97,7 +99,7 @@ export function NotebooksList() { } const handleToggleExpand = (notebookId: string) => { - setExpandedNotebook(expandedNotebook === notebookId ? null : notebookId) + setExpandedNotebook((prev) => (prev === notebookId ? null : notebookId)) } const handleLabelFilter = (labelName: string, notebookId: string) => { @@ -126,6 +128,7 @@ export function NotebooksList() { return ( <> +
{/* Header with Add Button */}
@@ -157,7 +160,7 @@ export function NotebooksList() { onDragOver={(e) => handleDragOver(e, notebook.id)} onDragLeave={handleDragLeave} className={cn( - "flex flex-col mr-2 rounded-r-full overflow-hidden transition-all relative", + "flex flex-col mr-2 rounded-r-full transition-all relative", !notebook.color && "bg-primary/10 dark:bg-primary/20", isDragOver && "ring-2 ring-primary ring-dashed" )} @@ -186,39 +189,56 @@ export function NotebooksList() { onSummary={() => setSummaryNotebook(notebook)} />
{/* Contextual Labels Tree */} - {isExpanded && labels.length > 0 && ( + {isExpanded && (
- {labels.map((label: any) => ( - - ))} + {labels.length === 0 ? ( +

+ {t('sidebar.noLabelsInNotebook')} +

+ ) : ( + labels.map((label: any) => ( + + )) + )}
)} diff --git a/keep-notes/components/notification-panel.tsx b/keep-notes/components/notification-panel.tsx index b880b36..8451c8d 100644 --- a/keep-notes/components/notification-panel.tsx +++ b/keep-notes/components/notification-panel.tsx @@ -1,7 +1,6 @@ 'use client' import { useState, useEffect } from 'react' -import { useRouter } from 'next/navigation' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Bell, Check, X, Clock, User } from 'lucide-react' @@ -37,7 +36,6 @@ interface ShareRequest { } export function NotificationPanel() { - const router = useRouter() const { triggerRefresh } = useNoteRefresh() const { t } = useLanguage() const [requests, setRequests] = useState([]) @@ -59,17 +57,16 @@ export function NotificationPanel() { useEffect(() => { loadRequests() - const interval = setInterval(loadRequests, 10000) + const interval = setInterval(loadRequests, 30000) return () => clearInterval(interval) }, []) const handleAccept = async (shareId: string) => { try { await respondToShareRequest(shareId, 'accept') - router.refresh() - triggerRefresh() setRequests(prev => prev.filter(r => r.id !== shareId)) setPendingCount(prev => prev - 1) + triggerRefresh() toast.success(t('notes.noteCreated'), { description: t('collaboration.nowHasAccess', { name: 'Note' }), duration: 3000, @@ -83,11 +80,9 @@ export function NotificationPanel() { const handleDecline = async (shareId: string) => { try { await respondToShareRequest(shareId, 'decline') - router.refresh() - triggerRefresh() setRequests(prev => prev.filter(r => r.id !== shareId)) setPendingCount(prev => prev - 1) - toast.info(t('general.operationFailed')) + toast.info(t('notification.declined')) } catch (error: any) { console.error('[NOTIFICATION] Error:', error) toast.error(error.message || t('general.error')) @@ -97,10 +92,8 @@ export function NotificationPanel() { const handleRemove = async (shareId: string) => { try { await removeSharedNoteFromView(shareId) - router.refresh() - triggerRefresh() setRequests(prev => prev.filter(r => r.id !== shareId)) - toast.info(t('general.operationFailed')) + toast.info(t('notification.removed')) } catch (error: any) { toast.error(error.message || t('general.error')) } diff --git a/keep-notes/context/notebooks-context.tsx b/keep-notes/context/notebooks-context.tsx index c1f15e9..7cd6e1d 100644 --- a/keep-notes/context/notebooks-context.tsx +++ b/keep-notes/context/notebooks-context.tsx @@ -54,6 +54,9 @@ export interface NotebooksContextValue { // Actions: Notes moveNoteToNotebookOptimistic: (noteId: string, notebookId: string | null) => Promise + /** Recharger la liste des carnets (ex. après maintenance étiquettes) */ + refreshNotebooks: () => Promise + // Actions: AI (stubs pour l'instant) suggestNotebookForNote: (noteContent: string) => Promise suggestLabelsForNote: (noteContent: string, notebookId: string) => Promise @@ -262,6 +265,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks updateLabel, deleteLabel, moveNoteToNotebookOptimistic, + refreshNotebooks: loadNotebooks, suggestNotebookForNote, suggestLabelsForNote, }), [ @@ -279,6 +283,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks updateLabel, deleteLabel, moveNoteToNotebookOptimistic, + loadNotebooks, suggestNotebookForNote, suggestLabelsForNote, ]) diff --git a/keep-notes/hooks/use-reminder-check.ts b/keep-notes/hooks/use-reminder-check.ts index 82983c8..b4a5409 100644 --- a/keep-notes/hooks/use-reminder-check.ts +++ b/keep-notes/hooks/use-reminder-check.ts @@ -1,44 +1,53 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { Note } from '@/lib/types'; import { toast } from 'sonner'; +const STORAGE_KEY = 'memento-notified-reminders'; + +function getNotifiedFromStorage(): Set { + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + return raw ? new Set(JSON.parse(raw)) : new Set(); + } catch { + return new Set(); + } +} + +function persistNotified(ids: Set) { + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify([...ids])); + } catch { /* quota exceeded — non-critical */ } +} + export function useReminderCheck(notes: Note[]) { - const [notifiedReminders, setNotifiedReminders] = useState>(new Set()); + const notifiedRef = useRef>(getNotifiedFromStorage()); useEffect(() => { const checkReminders = () => { const now = new Date(); - const dueReminders: string[] = []; + const newIds: string[] = []; + + for (const note of notes) { + if (!note.reminder || note.isReminderDone) continue; + if (notifiedRef.current.has(note.id)) continue; - // First pass: collect which reminders are due - notes.forEach(note => { - if (!note.reminder) return; const reminderDate = new Date(note.reminder); - - if (reminderDate <= now && !notifiedReminders.has(note.id)) { - dueReminders.push(note.id); - toast.info("🔔 Reminder: " + (note.title || "Untitled Note")); + if (reminderDate <= now) { + newIds.push(note.id); + toast.info(`🔔 ${note.title || 'Untitled Note'}`, { id: `reminder-${note.id}` }); } - }); + } - // Second pass: update state only once with all due reminders - if (dueReminders.length > 0) { - setNotifiedReminders(prev => { - const newSet = new Set(prev); - dueReminders.forEach(id => newSet.add(id)); - return newSet; - }); + if (newIds.length > 0) { + for (const id of newIds) notifiedRef.current.add(id); + persistNotified(notifiedRef.current); } }; - // Check immediately checkReminders(); - - // Then check every 30 seconds - const interval = setInterval(checkReminders, 30000); - + const interval = setInterval(checkReminders, 30_000); return () => clearInterval(interval); }, [notes]); } diff --git a/keep-notes/lib/ai/services/auto-label-creation.service.ts b/keep-notes/lib/ai/services/auto-label-creation.service.ts index 53177d8..5ea70e1 100644 --- a/keep-notes/lib/ai/services/auto-label-creation.service.ts +++ b/keep-notes/lib/ai/services/auto-label-creation.service.ts @@ -443,15 +443,37 @@ Deine Antwort (nur JSON): }, }) - // Assign label to all suggested notes (updateMany doesn't support relations) + // Assign to notes: UI reads `Note.labels` (JSON string[]); relations must stay in sync for (const noteId of suggestedLabel.noteIds) { + const note = await prisma.note.findFirst({ + where: { id: noteId, userId, notebookId }, + select: { labels: true }, + }) + if (!note) continue + + let names: string[] = [] + if (note.labels) { + try { + const parsed = JSON.parse(note.labels) as unknown + names = Array.isArray(parsed) + ? parsed.filter((n): n is string => typeof n === 'string' && n.trim().length > 0) + : [] + } catch { + names = [] + } + } + + const trimmed = suggestedLabel.name.trim() + if (!names.some((n) => n.toLowerCase() === trimmed.toLowerCase())) { + names = [...names, suggestedLabel.name] + } + await prisma.note.update({ where: { id: noteId }, data: { + labels: JSON.stringify(names), labelRelations: { - connect: { - id: label.id, - }, + connect: { id: label.id }, }, }, }) diff --git a/keep-notes/lib/ai/services/batch-organization.service.ts b/keep-notes/lib/ai/services/batch-organization.service.ts index 4139d90..037f438 100644 --- a/keep-notes/lib/ai/services/batch-organization.service.ts +++ b/keep-notes/lib/ai/services/batch-organization.service.ts @@ -1,5 +1,5 @@ import { prisma } from '@/lib/prisma' -import { getAIProvider } from '@/lib/ai/factory' +import { getTagsProvider } from '@/lib/ai/factory' import { getSystemConfig } from '@/lib/config' export interface NoteForOrganization { @@ -102,24 +102,12 @@ export class BatchOrganizationService { ): Promise { const prompt = this.buildPrompt(notes, notebooks, language) - try { - const config = await getSystemConfig() - const provider = getAIProvider(config) - const response = await provider.generateText(prompt) + const config = await getSystemConfig() + const provider = getTagsProvider(config) + const response = await provider.generateText(prompt) - // Parse AI response - const plan = this.parseAIResponse(response, notes, notebooks) - - return plan - } catch (error) { - console.error('Failed to create organization plan:', error) - // Return empty plan on error - return { - notebooks: [], - totalNotes: notes.length, - unorganizedNotes: notes.length, - } - } + const plan = this.parseAIResponse(response, notes, notebooks) + return plan } /** @@ -870,6 +858,10 @@ ${notesList} return instructions[language] || instructions['en'] || instructions['fr'] } + private normalizeForMatch(str: string): string { + return str.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim() + } + /** * Parse AI response into OrganizationPlan */ @@ -879,7 +871,6 @@ ${notesList} notebooks: any[] ): OrganizationPlan { try { - // Try to parse JSON response const jsonMatch = response.match(/\{[\s\S]*\}/) if (!jsonMatch) { throw new Error('No JSON found in response') @@ -889,9 +880,10 @@ ${notesList} const notebookOrganizations: NotebookOrganization[] = [] - // Process each notebook in AI response for (const aiNotebook of aiData.carnets || []) { - const notebook = notebooks.find(nb => nb.name === aiNotebook.nom) + const aiName = this.normalizeForMatch(aiNotebook.nom || '') + const notebook = notebooks.find(nb => this.normalizeForMatch(nb.name) === aiName) + || notebooks.find(nb => this.normalizeForMatch(nb.name).includes(aiName) || aiName.includes(this.normalizeForMatch(nb.name))) if (!notebook) continue const noteAssignments = aiNotebook.notes @@ -933,12 +925,8 @@ ${notesList} unorganizedNotes: unorganizedCount, } } catch (error) { - console.error('Failed to parse AI response:', error) - return { - notebooks: [], - totalNotes: notes.length, - unorganizedNotes: notes.length, - } + console.error('Failed to parse AI response:', error, '\nRaw response:', response.substring(0, 500)) + throw new Error(`AI response parsing failed: ${error instanceof Error ? error.message : 'Invalid JSON'}`) } } diff --git a/keep-notes/locales/en.json b/keep-notes/locales/en.json index 1a6f390..a1be1ab 100644 --- a/keep-notes/locales/en.json +++ b/keep-notes/locales/en.json @@ -33,6 +33,7 @@ "reminders": "Reminders", "labels": "Labels", "editLabels": "Edit labels", + "noLabelsInNotebook": "No labels in this notebook yet", "archive": "Archive", "trash": "Trash" }, @@ -389,7 +390,9 @@ "notification": { "shared": "shared \"{title}\"", "untitled": "Untitled", - "notifications": "Notifications" + "notifications": "Notifications", + "declined": "Share declined", + "removed": "Note removed from list" }, "nav": { "home": "Home", @@ -448,6 +451,12 @@ "maintenanceDescription": "Tools to maintain your database health", "cleanTags": "Clean Orphan Tags", "cleanTagsDescription": "Remove tags that are no longer used by any notes", + "cleanupDone": "Synced {created} label record(s), removed {deleted} orphan(s)", + "cleanupNothing": "Nothing to do — labels already match your notes", + "cleanupWithErrors": "some operations failed", + "cleanupError": "Could not clean up labels", + "indexingComplete": "Indexing complete: {count} note(s) processed", + "indexingError": "Error during indexing", "semanticIndexing": "Semantic Indexing", "semanticIndexingDescription": "Generate vectors for all notes to enable intent-based search", "profile": "Profile", diff --git a/keep-notes/locales/fr.json b/keep-notes/locales/fr.json index 1760277..21d7b91 100644 --- a/keep-notes/locales/fr.json +++ b/keep-notes/locales/fr.json @@ -637,7 +637,9 @@ "notification": { "shared": "a partagé « {title} »", "untitled": "Sans titre", - "notifications": "Notifications" + "notifications": "Notifications", + "declined": "Partage refusé", + "removed": "Note retirée de la liste" }, "nav": { "accountSettings": "Paramètres du compte", @@ -924,6 +926,12 @@ "appearance": "Apparence", "cleanTags": "Nettoyer les étiquettes orphelines", "cleanTagsDescription": "Supprimer les étiquettes qui ne sont plus utilisées par aucune note", + "cleanupDone": "{created} étiquette(s) synchronisée(s), {deleted} orpheline(s) supprimée(s)", + "cleanupNothing": "Aucune action nécessaire — les étiquettes sont déjà alignées avec vos notes", + "cleanupWithErrors": "certaines opérations ont échoué", + "cleanupError": "Impossible de nettoyer les étiquettes", + "indexingComplete": "Indexation terminée : {count} note(s) traitée(s)", + "indexingError": "Erreur pendant l’indexation", "description": "Gérez vos paramètres et préférences", "language": "Langue", "languageAuto": "Langue définie sur Auto", @@ -962,6 +970,7 @@ "archive": "Archives", "editLabels": "Modifier les étiquettes", "labels": "Étiquettes", + "noLabelsInNotebook": "Aucune étiquette dans ce carnet", "notes": "Notes", "reminders": "Rappels", "trash": "Corbeille"