fix(keep-notes): sidebar chevron, labels sync, batch org errors, perf guards
- Notebooks: chevron visible when expanded (remove overflow clip), functional expand state - Labels: sync/cleanup by notebookId, reconcile after note move - Settings: refresh notebooks after cleanup; label dialog routing - ConnectionsBadge lazy-load; reminder check persistence; i18n keys Made-with: Cursor
This commit is contained in:
@@ -154,15 +154,19 @@ export default function HomePage() {
|
|||||||
|
|
||||||
// Load settings + notes in a single effect to avoid cascade re-renders
|
// Load settings + notes in a single effect to avoid cascade re-renders
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
// Load settings first
|
// Load settings first
|
||||||
let showRecent = true
|
let showRecent = true
|
||||||
try {
|
try {
|
||||||
const settings = await getAISettings()
|
const settings = await getAISettings()
|
||||||
|
if (cancelled) return
|
||||||
showRecent = settings?.showRecentNotes !== false
|
showRecent = settings?.showRecentNotes !== false
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Default to true on error
|
// Default to true on error
|
||||||
}
|
}
|
||||||
|
if (cancelled) return
|
||||||
setShowRecentNotes(showRecent)
|
setShowRecentNotes(showRecent)
|
||||||
|
|
||||||
// Then load notes
|
// Then load notes
|
||||||
@@ -174,12 +178,12 @@ export default function HomePage() {
|
|||||||
const notebookFilter = searchParams.get('notebook')
|
const notebookFilter = searchParams.get('notebook')
|
||||||
|
|
||||||
let allNotes = search ? await searchNotes(search, semanticMode, notebookFilter || undefined) : await getAllNotes()
|
let allNotes = search ? await searchNotes(search, semanticMode, notebookFilter || undefined) : await getAllNotes()
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
// Filter by selected notebook
|
// Filter by selected notebook
|
||||||
if (notebookFilter) {
|
if (notebookFilter) {
|
||||||
allNotes = allNotes.filter((note: any) => note.notebookId === notebookFilter)
|
allNotes = allNotes.filter((note: any) => note.notebookId === notebookFilter)
|
||||||
} else {
|
} else {
|
||||||
// If no notebook selected, only show notes without notebook (Notes générales)
|
|
||||||
allNotes = allNotes.filter((note: any) => !note.notebookId)
|
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)
|
// Filter by color
|
||||||
// Note: We use a ref-like pattern to avoid including labels in dependencies
|
|
||||||
// This prevents dialog closing when adding new labels
|
|
||||||
if (colorFilter) {
|
if (colorFilter) {
|
||||||
const labelNamesWithColor = labels
|
const labelNamesWithColor = labels
|
||||||
.filter((label: any) => label.color === colorFilter)
|
.filter((label: any) => label.color === colorFilter)
|
||||||
@@ -226,7 +228,6 @@ export default function HomePage() {
|
|||||||
if (notebookFilter) {
|
if (notebookFilter) {
|
||||||
setRecentNotes(recentFiltered.filter((note: any) => note.notebookId === notebookFilter))
|
setRecentNotes(recentFiltered.filter((note: any) => note.notebookId === notebookFilter))
|
||||||
} else {
|
} else {
|
||||||
// Show ALL recent notes when in inbox (not just notes without notebooks)
|
|
||||||
setRecentNotes(recentFiltered)
|
setRecentNotes(recentFiltered)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -238,6 +239,7 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
load()
|
load()
|
||||||
|
return () => { cancelled = true }
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [searchParams, refreshKey]) // Intentionally omit 'labels' to prevent reload when adding tags
|
}, [searchParams, refreshKey]) // Intentionally omit 'labels' to prevent reload when adding tags
|
||||||
// Get notebooks context to display header
|
// Get notebooks context to display header
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Loader2, CheckCircle, XCircle, RefreshCw, Database, BrainCircuit } from 'lucide-react'
|
import { Loader2, CheckCircle, XCircle, RefreshCw, Database, BrainCircuit } from 'lucide-react'
|
||||||
import { cleanupAllOrphans, syncAllEmbeddings } from '@/app/actions/notes'
|
import { cleanupAllOrphans, syncAllEmbeddings } from '@/app/actions/notes'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import Link from 'next/link'
|
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() {
|
export default function SettingsPage() {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
const router = useRouter()
|
||||||
|
const { refreshLabels } = useLabels()
|
||||||
|
const { refreshNotebooks } = useNotebooks()
|
||||||
|
const { triggerRefresh } = useNoteRefresh()
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [cleanupLoading, setCleanupLoading] = useState(false)
|
const [cleanupLoading, setCleanupLoading] = useState(false)
|
||||||
const [syncLoading, setSyncLoading] = useState(false)
|
const [syncLoading, setSyncLoading] = useState(false)
|
||||||
@@ -52,11 +60,13 @@ export default function SettingsPage() {
|
|||||||
try {
|
try {
|
||||||
const result = await syncAllEmbeddings()
|
const result = await syncAllEmbeddings()
|
||||||
if (result.success) {
|
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) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
toast.error("Error during indexing")
|
toast.error(t('settings.indexingError'))
|
||||||
} finally {
|
} finally {
|
||||||
setSyncLoading(false)
|
setSyncLoading(false)
|
||||||
}
|
}
|
||||||
@@ -67,11 +77,26 @@ export default function SettingsPage() {
|
|||||||
try {
|
try {
|
||||||
const result = await cleanupAllOrphans()
|
const result = await cleanupAllOrphans()
|
||||||
if (result.success) {
|
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) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
toast.error("Error during cleanup")
|
toast.error(t('settings.cleanupError'))
|
||||||
} finally {
|
} finally {
|
||||||
setCleanupLoading(false)
|
setCleanupLoading(false)
|
||||||
}
|
}
|
||||||
@@ -82,7 +107,7 @@ export default function SettingsPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold mb-2">{t('settings.title')}</h1>
|
<h1 className="text-3xl font-bold mb-2">{t('settings.title')}</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-muted-foreground">
|
||||||
{t('settings.description')}
|
{t('settings.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,31 +201,31 @@ export default function SettingsPage() {
|
|||||||
description={t('settings.maintenanceDescription')}
|
description={t('settings.maintenanceDescription')}
|
||||||
>
|
>
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
<div className="flex items-center justify-between gap-4 p-4 border border-border rounded-lg bg-card">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h3 className="font-medium flex items-center gap-2">
|
<h3 className="font-medium flex items-center gap-2">
|
||||||
{t('settings.cleanTags')}
|
{t('settings.cleanTags')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t('settings.cleanTagsDescription')}
|
{t('settings.cleanTagsDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" onClick={handleCleanup} disabled={cleanupLoading}>
|
<Button variant="secondary" onClick={handleCleanup} disabled={cleanupLoading} className="shrink-0">
|
||||||
{cleanupLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Database className="w-4 h-4 mr-2" />}
|
{cleanupLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Database className="w-4 h-4 mr-2" />}
|
||||||
{t('general.clean')}
|
{t('general.clean')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
<div className="flex items-center justify-between gap-4 p-4 border border-border rounded-lg bg-card">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h3 className="font-medium flex items-center gap-2">
|
<h3 className="font-medium flex items-center gap-2">
|
||||||
{t('settings.semanticIndexing')}
|
{t('settings.semanticIndexing')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t('settings.semanticIndexingDescription')}
|
{t('settings.semanticIndexingDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" onClick={handleSync} disabled={syncLoading}>
|
<Button variant="secondary" onClick={handleSync} disabled={syncLoading} className="shrink-0">
|
||||||
{syncLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <BrainCircuit className="w-4 h-4 mr-2" />}
|
{syncLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <BrainCircuit className="w-4 h-4 mr-2" />}
|
||||||
{t('general.indexAll')}
|
{t('general.indexAll')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -37,90 +37,108 @@ function getHashColor(name: string): string {
|
|||||||
return colors[Math.abs(hash) % colors.length]
|
return colors[Math.abs(hash) % colors.length]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Comprehensive sync function for labels - ensures consistency between Note.labels and Label table
|
/** Clé stable (carnet + nom) : les étiquettes sont uniques par (notebookId, name) côté Prisma */
|
||||||
async function syncLabels(userId: string, noteLabels: string[] = []) {
|
function labelScopeKey(notebookId: string | null | undefined, rawName: string): string {
|
||||||
try {
|
const name = rawName.trim().toLowerCase()
|
||||||
// Step 1: Create Label records for any labels in notes that don't exist in Label table
|
if (!name) return ''
|
||||||
// Get all existing labels for this user to do case-insensitive check in JS
|
const nb = notebookId ?? ''
|
||||||
const existingLabels = await prisma.label.findMany({
|
return `${nb}\u0000${name}`
|
||||||
where: { userId },
|
}
|
||||||
select: { id: true, name: true }
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create a map for case-insensitive lookup
|
function collectLabelNamesFromNote(note: {
|
||||||
const existingLabelMap = new Map<string, string>()
|
labels: string | null
|
||||||
existingLabels.forEach(label => {
|
labelRelations?: { name: string }[]
|
||||||
existingLabelMap.set(label.name.toLowerCase(), label.name)
|
}): string[] {
|
||||||
})
|
const names: string[] = []
|
||||||
|
|
||||||
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) {
|
|
||||||
try {
|
|
||||||
await prisma.label.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
name: trimmedLabel,
|
|
||||||
color: getHashColor(trimmedLabel)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
// Add to map to prevent duplicates in same batch
|
|
||||||
existingLabelMap.set(lowerLabel, trimmedLabel)
|
|
||||||
} catch (e: any) {
|
|
||||||
// Ignore unique constraint violations (race condition)
|
|
||||||
if (e.code !== 'P2002') {
|
|
||||||
console.error(`[SYNC] Failed to create label "${trimmedLabel}":`, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Get ALL labels currently used in ALL user's notes
|
|
||||||
const allNotes = await prisma.note.findMany({
|
|
||||||
where: { userId },
|
|
||||||
select: { labels: true }
|
|
||||||
})
|
|
||||||
|
|
||||||
const usedLabelsSet = new Set<string>()
|
|
||||||
allNotes.forEach(note => {
|
|
||||||
if (note.labels) {
|
if (note.labels) {
|
||||||
try {
|
try {
|
||||||
const parsedLabels: string[] = JSON.parse(note.labels)
|
const parsed: unknown = JSON.parse(note.labels)
|
||||||
if (Array.isArray(parsedLabels)) {
|
if (Array.isArray(parsed)) {
|
||||||
parsedLabels.forEach(l => {
|
for (const l of parsed) {
|
||||||
if (l && l.trim()) {
|
if (typeof l === 'string' && l.trim()) names.push(l.trim())
|
||||||
usedLabelsSet.add(l.trim().toLowerCase())
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[SYNC] Failed to parse labels:', e)
|
console.error('[SYNC] Failed to parse labels:', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
for (const rel of note.labelRelations ?? []) {
|
||||||
|
if (rel.name?.trim()) names.push(rel.name.trim())
|
||||||
// Step 3: Delete orphan Label records (labels not in any note)
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
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 {
|
||||||
|
const nbScope = notebookId ?? null
|
||||||
|
|
||||||
|
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 {
|
||||||
|
const created = await prisma.label.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
name: trimmed,
|
||||||
|
color: getHashColor(trimmed),
|
||||||
|
notebookId: nbScope,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
scoped.push(created)
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.code !== 'P2002') {
|
||||||
|
console.error(`[SYNC] Failed to create label "${trimmed}":`, e)
|
||||||
|
}
|
||||||
|
scoped = await prisma.label.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { id: true, name: true, notebookId: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allNotes = await prisma.note.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: {
|
||||||
|
notebookId: true,
|
||||||
|
labels: true,
|
||||||
|
labelRelations: { select: { name: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const usedLabelsSet = new Set<string>()
|
||||||
|
for (const note of allNotes) {
|
||||||
|
for (const name of collectLabelNamesFromNote(note)) {
|
||||||
|
const key = labelScopeKey(note.notebookId, name)
|
||||||
|
if (key) usedLabelsSet.add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allLabels = await prisma.label.findMany({ where: { userId } })
|
||||||
|
for (const label of allLabels) {
|
||||||
|
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) {
|
} 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)
|
// Get all notes (non-archived by default)
|
||||||
export async function getNotes(includeArchived = false) {
|
export async function getNotes(includeArchived = false) {
|
||||||
const session = await auth();
|
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) {
|
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)
|
// Revalidate main page (handles both inbox and notebook views via query params)
|
||||||
@@ -428,7 +469,7 @@ export async function createNote(data: {
|
|||||||
where: { id: noteId },
|
where: { id: noteId },
|
||||||
data: { labels: JSON.stringify(appliedLabels) }
|
data: { labels: JSON.stringify(appliedLabels) }
|
||||||
})
|
})
|
||||||
await syncLabels(userId, appliedLabels)
|
await syncLabels(userId, appliedLabels, notebookId ?? null)
|
||||||
revalidatePath('/')
|
revalidatePath('/')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -526,10 +567,14 @@ export async function updateNote(id: string, data: {
|
|||||||
data: updateData
|
data: updateData
|
||||||
})
|
})
|
||||||
|
|
||||||
// Sync labels to ensure consistency between Note.labels and Label table
|
// Sync Label rows (carnet + noms) quand les étiquettes changent ou que la note change de carnet
|
||||||
// This handles both creating new Label records and cleaning up orphans
|
const notebookMoved =
|
||||||
if (data.labels !== undefined) {
|
data.notebookId !== undefined && data.notebookId !== oldNotebookId
|
||||||
await syncLabels(session.user.id, data.labels || [])
|
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
|
// 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() {
|
export async function cleanupAllOrphans() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||||
@@ -676,82 +721,84 @@ export async function cleanupAllOrphans() {
|
|||||||
let deletedCount = 0;
|
let deletedCount = 0;
|
||||||
let errors: any[] = [];
|
let errors: any[] = [];
|
||||||
try {
|
try {
|
||||||
// Step 1: Get all labels from notes
|
const allNotes = await prisma.note.findMany({
|
||||||
const allNotes = await prisma.note.findMany({ where: { userId }, select: { labels: true } })
|
|
||||||
const allNoteLabels = new Set<string>();
|
|
||||||
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({
|
|
||||||
where: { userId },
|
where: { userId },
|
||||||
select: { id: true, name: true }
|
select: {
|
||||||
})
|
notebookId: true,
|
||||||
const existingLabelMap = new Map<string, string>()
|
labels: true,
|
||||||
existingLabels.forEach(label => {
|
labelRelations: { select: { name: true } },
|
||||||
existingLabelMap.set(label.name.toLowerCase(), label.name)
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Step 3: Create missing Label records
|
const usedSet = new Set<string>()
|
||||||
for (const labelName of allNoteLabels) {
|
for (const note of allNotes) {
|
||||||
const lowerLabel = labelName.toLowerCase();
|
for (const name of collectLabelNamesFromNote(note)) {
|
||||||
const existingName = existingLabelMap.get(lowerLabel);
|
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<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 {
|
try {
|
||||||
await prisma.label.create({
|
const created = await prisma.label.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
name: labelName,
|
name: trimmed,
|
||||||
color: getHashColor(labelName)
|
color: getHashColor(trimmed),
|
||||||
}
|
notebookId: nb,
|
||||||
});
|
},
|
||||||
createdCount++;
|
})
|
||||||
existingLabelMap.set(lowerLabel, labelName);
|
allScoped.push(created)
|
||||||
|
createdCount++
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(`Failed to create label:`, e);
|
console.error(`Failed to create label:`, e)
|
||||||
errors.push({ label: labelName, error: e.message, code: e.code });
|
errors.push({ label: trimmed, notebookId: nb, error: e.message, code: e.code })
|
||||||
// Continue with next label
|
allScoped = await prisma.label.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { id: true, name: true, notebookId: true },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Delete orphan Label records
|
allScoped = await prisma.label.findMany({
|
||||||
const allDefinedLabels = await prisma.label.findMany({ where: { userId }, select: { id: true, name: true } })
|
where: { userId },
|
||||||
const usedLabelsSet = new Set<string>();
|
select: { id: true, name: true, notebookId: true },
|
||||||
allNotes.forEach(note => {
|
})
|
||||||
if (note.labels) {
|
for (const label of allScoped) {
|
||||||
|
const key = labelScopeKey(label.notebookId, label.name)
|
||||||
|
if (!key || usedSet.has(key)) continue
|
||||||
try {
|
try {
|
||||||
const parsedLabels: string[] = JSON.parse(note.labels);
|
await prisma.label.update({
|
||||||
if (Array.isArray(parsedLabels)) parsedLabels.forEach(l => usedLabelsSet.add(l.toLowerCase()));
|
where: { id: label.id },
|
||||||
} catch (e) {
|
data: { notes: { set: [] } },
|
||||||
console.error('Failed to parse labels for orphan check:', e);
|
})
|
||||||
}
|
await prisma.label.delete({ where: { id: label.id } })
|
||||||
}
|
deletedCount++
|
||||||
});
|
} catch (e: any) {
|
||||||
const orphans = allDefinedLabels.filter(label => !usedLabelsSet.has(label.name.toLowerCase()));
|
console.error(`Failed to delete orphan ${label.id}:`, e)
|
||||||
for (const orphan of orphans) {
|
errors.push({ labelId: label.id, name: label.name, error: e?.message, code: e?.code })
|
||||||
try {
|
|
||||||
await prisma.label.delete({ where: { id: orphan.id } });
|
|
||||||
deletedCount++;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Failed to delete orphan:`, e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath('/')
|
revalidatePath('/')
|
||||||
|
revalidatePath('/settings')
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
created: createdCount,
|
created: createdCount,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export async function POST(request: NextRequest) {
|
|||||||
data: plan,
|
data: plan,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('[batch-organize POST] Error:', error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { revalidatePath } from 'next/cache'
|
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)
|
// POST /api/notes/[id]/move - Move a note to a notebook (or to Inbox)
|
||||||
export async function POST(
|
export async function POST(
|
||||||
@@ -60,10 +61,12 @@ export async function POST(
|
|||||||
|
|
||||||
// Update the note's notebook
|
// Update the note's notebook
|
||||||
// notebookId = null or "" means move to Inbox (Notes générales)
|
// notebookId = null or "" means move to Inbox (Notes générales)
|
||||||
|
const targetNotebookId = notebookId && notebookId !== '' ? notebookId : null
|
||||||
|
|
||||||
const updatedNote = await prisma.note.update({
|
const updatedNote = await prisma.note.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
notebookId: notebookId && notebookId !== '' ? notebookId : null
|
notebookId: targetNotebookId
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
notebook: {
|
notebook: {
|
||||||
@@ -72,6 +75,8 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await reconcileLabelsAfterNoteMove(id, targetNotebookId)
|
||||||
|
|
||||||
revalidatePath('/')
|
revalidatePath('/')
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Button } from './ui/button'
|
import { Button } from './ui/button'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -27,21 +27,24 @@ export function BatchOrganizationDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
onNotesMoved,
|
onNotesMoved,
|
||||||
}: BatchOrganizationDialogProps) {
|
}: BatchOrganizationDialogProps) {
|
||||||
const { t } = useLanguage()
|
const { t, language } = useLanguage()
|
||||||
const [plan, setPlan] = useState<OrganizationPlan | null>(null)
|
const [plan, setPlan] = useState<OrganizationPlan | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [applying, setApplying] = useState(false)
|
const [applying, setApplying] = useState(false)
|
||||||
const [selectedNotes, setSelectedNotes] = useState<Set<string>>(new Set())
|
const [selectedNotes, setSelectedNotes] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const [fetchError, setFetchError] = useState<string | null>(null)
|
||||||
|
|
||||||
const fetchOrganizationPlan = async () => {
|
const fetchOrganizationPlan = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
setFetchError(null)
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/ai/batch-organize', {
|
const response = await fetch('/api/ai/batch-organize', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
language: document.documentElement.lang || 'en'
|
language: language || 'en'
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -49,32 +52,38 @@ export function BatchOrganizationDialog({
|
|||||||
|
|
||||||
if (data.success && data.data) {
|
if (data.success && data.data) {
|
||||||
setPlan(data.data)
|
setPlan(data.data)
|
||||||
// Select all notes by default
|
|
||||||
const allNoteIds = new Set<string>()
|
const allNoteIds = new Set<string>()
|
||||||
data.data.notebooks.forEach((nb: NotebookOrganization) => {
|
data.data.notebooks.forEach((nb: NotebookOrganization) => {
|
||||||
nb.notes.forEach(note => allNoteIds.add(note.noteId))
|
nb.notes.forEach(note => allNoteIds.add(note.noteId))
|
||||||
})
|
})
|
||||||
setSelectedNotes(allNoteIds)
|
setSelectedNotes(allNoteIds)
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || t('ai.batchOrganization.error'))
|
const msg = data.error || t('ai.batchOrganization.error')
|
||||||
|
setFetchError(msg)
|
||||||
|
toast.error(msg)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create organization plan:', 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 {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOpenChange = (isOpen: boolean) => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (open) {
|
||||||
// Reset state when closing
|
fetchOrganizationPlan()
|
||||||
|
} else {
|
||||||
setPlan(null)
|
setPlan(null)
|
||||||
setSelectedNotes(new Set())
|
setSelectedNotes(new Set())
|
||||||
} else {
|
setFetchError(null)
|
||||||
// Fetch plan when opening
|
|
||||||
fetchOrganizationPlan()
|
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const handleOpenChange = (isOpen: boolean) => {
|
||||||
onOpenChange(isOpen)
|
onOpenChange(isOpen)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +182,13 @@ export function BatchOrganizationDialog({
|
|||||||
{t('ai.batchOrganization.analyzing')}
|
{t('ai.batchOrganization.analyzing')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : fetchError ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
||||||
|
<p className="text-sm text-destructive text-center">{fetchError}</p>
|
||||||
|
<Button variant="outline" onClick={fetchOrganizationPlan}>
|
||||||
|
{t('general.tryAgain')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : plan ? (
|
) : plan ? (
|
||||||
<div className="space-y-6 py-4">
|
<div className="space-y-6 py-4">
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { memo, useState, useEffect } from 'react'
|
import { memo, useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { Sparkles } from 'lucide-react'
|
import { Sparkles } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||||
@@ -15,45 +15,40 @@ interface ConnectionsBadgeProps {
|
|||||||
export const ConnectionsBadge = memo(function ConnectionsBadge({ noteId, onClick, className }: ConnectionsBadgeProps) {
|
export const ConnectionsBadge = memo(function ConnectionsBadge({ noteId, onClick, className }: ConnectionsBadgeProps) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const [connectionCount, setConnectionCount] = useState<number>(0)
|
const [connectionCount, setConnectionCount] = useState<number>(0)
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
const [isHovered, setIsHovered] = useState(false)
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
const [fetchAttempted, setFetchAttempted] = useState(false)
|
const containerRef = useRef<HTMLSpanElement>(null)
|
||||||
|
const fetchedRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchConnections = useCallback(async () => {
|
||||||
if (fetchAttempted) return
|
if (fetchedRef.current) return
|
||||||
setFetchAttempted(true)
|
fetchedRef.current = true
|
||||||
|
|
||||||
let isMounted = true
|
|
||||||
|
|
||||||
const fetchConnections = async () => {
|
|
||||||
setIsLoading(true)
|
|
||||||
try {
|
try {
|
||||||
const count = await getConnectionsCount(noteId)
|
const count = await getConnectionsCount(noteId)
|
||||||
if (isMounted) {
|
|
||||||
setConnectionCount(count)
|
setConnectionCount(count)
|
||||||
}
|
} catch {
|
||||||
} catch (error) {
|
|
||||||
console.error('[ConnectionsBadge] Failed to fetch connections:', error)
|
|
||||||
if (isMounted) {
|
|
||||||
setConnectionCount(0)
|
setConnectionCount(0)
|
||||||
}
|
}
|
||||||
} finally {
|
}, [noteId])
|
||||||
if (isMounted) {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
fetchConnections()
|
fetchConnections()
|
||||||
|
observer.disconnect()
|
||||||
return () => {
|
|
||||||
isMounted = false
|
|
||||||
}
|
}
|
||||||
}, [noteId]) // eslint-disable-line react-hooks/exhaustive-deps
|
},
|
||||||
|
{ rootMargin: '200px' }
|
||||||
|
)
|
||||||
|
observer.observe(el)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [fetchConnections])
|
||||||
|
|
||||||
// Don't render if no connections or still loading
|
if (connectionCount === 0) {
|
||||||
if (connectionCount === 0 || isLoading) {
|
return <span ref={containerRef} className="hidden" />
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const plural = connectionCount > 1 ? 's' : ''
|
const plural = connectionCount > 1 ? 's' : ''
|
||||||
|
|||||||
@@ -11,19 +11,27 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from './ui/dialog'
|
} from './ui/dialog'
|
||||||
import { Badge } from './ui/badge'
|
|
||||||
import { Settings, Plus, Palette, Trash2, Tag } from 'lucide-react'
|
import { Settings, Plus, Palette, Trash2, Tag } from 'lucide-react'
|
||||||
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
|
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useLabels } from '@/context/LabelContext'
|
import { useLabels } from '@/context/LabelContext'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
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 { labels, loading, addLabel, updateLabel, deleteLabel } = useLabels()
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const [newLabel, setNewLabel] = useState('')
|
const [newLabel, setNewLabel] = useState('')
|
||||||
const [editingColorId, setEditingColorId] = useState<string | null>(null)
|
const [editingColorId, setEditingColorId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const controlled = open !== undefined && onOpenChange !== undefined
|
||||||
|
|
||||||
const handleAddLabel = async () => {
|
const handleAddLabel = async () => {
|
||||||
const trimmed = newLabel.trim()
|
const trimmed = newLabel.trim()
|
||||||
if (trimmed) {
|
if (trimmed) {
|
||||||
@@ -55,13 +63,7 @@ export function LabelManagementDialog() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const dialogContent = (
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" title={t('labels.manage')}>
|
|
||||||
<Settings className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="max-w-md"
|
className="max-w-md"
|
||||||
onInteractOutside={(event) => {
|
onInteractOutside={(event) => {
|
||||||
@@ -117,23 +119,23 @@ export function LabelManagementDialog() {
|
|||||||
{/* List labels */}
|
{/* List labels */}
|
||||||
<div className="max-h-[60vh] overflow-y-auto space-y-2">
|
<div className="max-h-[60vh] overflow-y-auto space-y-2">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-gray-500">{t('labels.loading')}</p>
|
<p className="text-sm text-muted-foreground">{t('labels.loading')}</p>
|
||||||
) : labels.length === 0 ? (
|
) : labels.length === 0 ? (
|
||||||
<p className="text-sm text-gray-500">{t('labels.noLabelsFound')}</p>
|
<p className="text-sm text-muted-foreground">{t('labels.noLabelsFound')}</p>
|
||||||
) : (
|
) : (
|
||||||
labels.map((label) => {
|
labels.map((label) => {
|
||||||
const colorClasses = LABEL_COLORS[label.color]
|
const colorClasses = LABEL_COLORS[label.color]
|
||||||
const isEditing = editingColorId === label.id
|
const isEditing = editingColorId === label.id
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={label.id} className="flex items-center justify-between p-2 rounded-md hover:bg-gray-100 dark:hover:bg-zinc-800/50 group">
|
<div key={label.id} className="flex items-center justify-between p-2 rounded-md hover:bg-accent/50 group">
|
||||||
<div className="flex items-center gap-3 flex-1 relative">
|
<div className="flex items-center gap-3 flex-1 relative">
|
||||||
<Tag className={cn("h-4 w-4", colorClasses.text)} />
|
<Tag className={cn("h-4 w-4", colorClasses.text)} />
|
||||||
<span className="font-medium text-sm">{label.name}</span>
|
<span className="font-medium text-sm">{label.name}</span>
|
||||||
|
|
||||||
{/* Color Picker Popover */}
|
{/* Color Picker Popover */}
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div className="absolute z-20 top-8 left-0 bg-white dark:bg-zinc-900 border rounded-lg shadow-xl p-3 animate-in fade-in zoom-in-95 w-48">
|
<div className="absolute z-20 top-8 left-0 bg-popover text-popover-foreground border border-border rounded-lg shadow-xl p-3 animate-in fade-in zoom-in-95 w-48">
|
||||||
<div className="grid grid-cols-5 gap-2">
|
<div className="grid grid-cols-5 gap-2">
|
||||||
{(Object.keys(LABEL_COLORS) as LabelColorName[]).map((color) => {
|
{(Object.keys(LABEL_COLORS) as LabelColorName[]).map((color) => {
|
||||||
const classes = LABEL_COLORS[color]
|
const classes = LABEL_COLORS[color]
|
||||||
@@ -159,7 +161,7 @@ export function LabelManagementDialog() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
|
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||||
onClick={() => setEditingColorId(isEditing ? null : label.id)}
|
onClick={() => setEditingColorId(isEditing ? null : label.id)}
|
||||||
title={t('labels.changeColor')}
|
title={t('labels.changeColor')}
|
||||||
>
|
>
|
||||||
@@ -182,6 +184,24 @@ export function LabelManagementDialog() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (controlled) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
{dialogContent}
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" title={t('labels.manage')}>
|
||||||
|
<Settings className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
{dialogContent}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { EditNotebookDialog } from './edit-notebook-dialog'
|
|||||||
import { NotebookSummaryDialog } from './notebook-summary-dialog'
|
import { NotebookSummaryDialog } from './notebook-summary-dialog'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { useLabels } from '@/context/LabelContext'
|
import { useLabels } from '@/context/LabelContext'
|
||||||
|
import { LabelManagementDialog } from '@/components/label-management-dialog'
|
||||||
import { Notebook } from '@/lib/types'
|
import { Notebook } from '@/lib/types'
|
||||||
|
|
||||||
// Map icon names to lucide-react components
|
// Map icon names to lucide-react components
|
||||||
@@ -53,6 +54,7 @@ export function NotebooksList() {
|
|||||||
const [deletingNotebook, setDeletingNotebook] = useState<Notebook | null>(null)
|
const [deletingNotebook, setDeletingNotebook] = useState<Notebook | null>(null)
|
||||||
const [summaryNotebook, setSummaryNotebook] = useState<Notebook | null>(null)
|
const [summaryNotebook, setSummaryNotebook] = useState<Notebook | null>(null)
|
||||||
const [expandedNotebook, setExpandedNotebook] = useState<string | null>(null)
|
const [expandedNotebook, setExpandedNotebook] = useState<string | null>(null)
|
||||||
|
const [labelsDialogOpen, setLabelsDialogOpen] = useState(false)
|
||||||
|
|
||||||
const currentNotebookId = searchParams.get('notebook')
|
const currentNotebookId = searchParams.get('notebook')
|
||||||
|
|
||||||
@@ -97,7 +99,7 @@ export function NotebooksList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleToggleExpand = (notebookId: string) => {
|
const handleToggleExpand = (notebookId: string) => {
|
||||||
setExpandedNotebook(expandedNotebook === notebookId ? null : notebookId)
|
setExpandedNotebook((prev) => (prev === notebookId ? null : notebookId))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLabelFilter = (labelName: string, notebookId: string) => {
|
const handleLabelFilter = (labelName: string, notebookId: string) => {
|
||||||
@@ -126,6 +128,7 @@ export function NotebooksList() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<LabelManagementDialog open={labelsDialogOpen} onOpenChange={setLabelsDialogOpen} />
|
||||||
<div className="flex flex-col pt-1">
|
<div className="flex flex-col pt-1">
|
||||||
{/* Header with Add Button */}
|
{/* Header with Add Button */}
|
||||||
<div className="flex items-center justify-between px-6 py-2 mt-2 group cursor-pointer text-gray-500 hover:text-gray-800 dark:hover:text-gray-300">
|
<div className="flex items-center justify-between px-6 py-2 mt-2 group cursor-pointer text-gray-500 hover:text-gray-800 dark:hover:text-gray-300">
|
||||||
@@ -157,7 +160,7 @@ export function NotebooksList() {
|
|||||||
onDragOver={(e) => handleDragOver(e, notebook.id)}
|
onDragOver={(e) => handleDragOver(e, notebook.id)}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
className={cn(
|
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",
|
!notebook.color && "bg-primary/10 dark:bg-primary/20",
|
||||||
isDragOver && "ring-2 ring-primary ring-dashed"
|
isDragOver && "ring-2 ring-primary ring-dashed"
|
||||||
)}
|
)}
|
||||||
@@ -186,39 +189,56 @@ export function NotebooksList() {
|
|||||||
onSummary={() => setSummaryNotebook(notebook)}
|
onSummary={() => setSummaryNotebook(notebook)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleToggleExpand(notebook.id)}
|
type="button"
|
||||||
className={cn("transition-colors p-1", !notebook.color && "text-primary hover:text-primary/80 dark:text-primary-foreground dark:hover:text-primary-foreground/80")}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleToggleExpand(notebook.id)
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-full p-1 transition-colors",
|
||||||
|
!notebook.color &&
|
||||||
|
"text-primary hover:text-primary/80 dark:text-primary-foreground dark:hover:text-primary-foreground/80"
|
||||||
|
)}
|
||||||
style={notebook.color ? { color: notebook.color } : undefined}
|
style={notebook.color ? { color: notebook.color } : undefined}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
>
|
>
|
||||||
<ChevronDown className={cn("w-4 h-4 transition-transform", isExpanded && "rotate-180")} />
|
<ChevronDown className={cn("h-4 w-4 transition-transform", isExpanded && "rotate-180")} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contextual Labels Tree */}
|
{/* Contextual Labels Tree */}
|
||||||
{isExpanded && labels.length > 0 && (
|
{isExpanded && (
|
||||||
<div className="flex flex-col pb-2">
|
<div className="flex flex-col pb-2">
|
||||||
{labels.map((label: any) => (
|
{labels.length === 0 ? (
|
||||||
|
<p className="pointer-events-none pl-12 pr-4 py-2 text-xs text-muted-foreground">
|
||||||
|
{t('sidebar.noLabelsInNotebook')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
labels.map((label: any) => (
|
||||||
<button
|
<button
|
||||||
key={label.id}
|
key={label.id}
|
||||||
|
type="button"
|
||||||
onClick={() => handleLabelFilter(label.name, notebook.id)}
|
onClick={() => handleLabelFilter(label.name, notebook.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-auto flex items-center gap-4 pl-12 pr-4 py-2 hover:bg-black/5 dark:hover:bg-white/10 transition-colors rounded-r-full mr-2",
|
'pointer-events-auto flex items-center gap-4 pl-12 pr-4 py-2 rounded-r-full mr-2 transition-colors',
|
||||||
searchParams.get('labels')?.includes(label.name) && "font-bold text-gray-900 dark:text-white"
|
'hover:bg-accent/60 text-muted-foreground hover:text-foreground',
|
||||||
|
searchParams.get('labels')?.includes(label.name) &&
|
||||||
|
'font-semibold text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Tag className="w-4 h-4 text-gray-500" />
|
<Tag className="h-4 w-4 shrink-0" />
|
||||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">
|
<span className="text-xs font-medium truncate">{label.name}</span>
|
||||||
{label.name}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/settings/labels')}
|
type="button"
|
||||||
className="pointer-events-auto flex items-center gap-2 pl-12 pr-4 py-2 mt-1 text-gray-500 hover:text-gray-800 hover:bg-black/5 dark:hover:bg-white/10 rounded-r-full mr-2 transition-colors group/label"
|
onClick={() => setLabelsDialogOpen(true)}
|
||||||
|
className="pointer-events-auto flex items-center gap-2 pl-12 pr-4 py-2 mt-1 rounded-r-full mr-2 transition-colors text-muted-foreground hover:text-foreground hover:bg-accent/60 group/label"
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3 group-hover/label:scale-110 transition-transform" />
|
<Plus className="h-3 w-3 shrink-0 group-hover/label:scale-110 transition-transform" />
|
||||||
<span className="text-xs font-medium opacity-80">{t('sidebar.editLabels') || 'Edit Labels'}</span>
|
<span className="text-xs font-medium">{t('sidebar.editLabels')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Bell, Check, X, Clock, User } from 'lucide-react'
|
import { Bell, Check, X, Clock, User } from 'lucide-react'
|
||||||
@@ -37,7 +36,6 @@ interface ShareRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function NotificationPanel() {
|
export function NotificationPanel() {
|
||||||
const router = useRouter()
|
|
||||||
const { triggerRefresh } = useNoteRefresh()
|
const { triggerRefresh } = useNoteRefresh()
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const [requests, setRequests] = useState<ShareRequest[]>([])
|
const [requests, setRequests] = useState<ShareRequest[]>([])
|
||||||
@@ -59,17 +57,16 @@ export function NotificationPanel() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadRequests()
|
loadRequests()
|
||||||
const interval = setInterval(loadRequests, 10000)
|
const interval = setInterval(loadRequests, 30000)
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleAccept = async (shareId: string) => {
|
const handleAccept = async (shareId: string) => {
|
||||||
try {
|
try {
|
||||||
await respondToShareRequest(shareId, 'accept')
|
await respondToShareRequest(shareId, 'accept')
|
||||||
router.refresh()
|
|
||||||
triggerRefresh()
|
|
||||||
setRequests(prev => prev.filter(r => r.id !== shareId))
|
setRequests(prev => prev.filter(r => r.id !== shareId))
|
||||||
setPendingCount(prev => prev - 1)
|
setPendingCount(prev => prev - 1)
|
||||||
|
triggerRefresh()
|
||||||
toast.success(t('notes.noteCreated'), {
|
toast.success(t('notes.noteCreated'), {
|
||||||
description: t('collaboration.nowHasAccess', { name: 'Note' }),
|
description: t('collaboration.nowHasAccess', { name: 'Note' }),
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
@@ -83,11 +80,9 @@ export function NotificationPanel() {
|
|||||||
const handleDecline = async (shareId: string) => {
|
const handleDecline = async (shareId: string) => {
|
||||||
try {
|
try {
|
||||||
await respondToShareRequest(shareId, 'decline')
|
await respondToShareRequest(shareId, 'decline')
|
||||||
router.refresh()
|
|
||||||
triggerRefresh()
|
|
||||||
setRequests(prev => prev.filter(r => r.id !== shareId))
|
setRequests(prev => prev.filter(r => r.id !== shareId))
|
||||||
setPendingCount(prev => prev - 1)
|
setPendingCount(prev => prev - 1)
|
||||||
toast.info(t('general.operationFailed'))
|
toast.info(t('notification.declined'))
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[NOTIFICATION] Error:', error)
|
console.error('[NOTIFICATION] Error:', error)
|
||||||
toast.error(error.message || t('general.error'))
|
toast.error(error.message || t('general.error'))
|
||||||
@@ -97,10 +92,8 @@ export function NotificationPanel() {
|
|||||||
const handleRemove = async (shareId: string) => {
|
const handleRemove = async (shareId: string) => {
|
||||||
try {
|
try {
|
||||||
await removeSharedNoteFromView(shareId)
|
await removeSharedNoteFromView(shareId)
|
||||||
router.refresh()
|
|
||||||
triggerRefresh()
|
|
||||||
setRequests(prev => prev.filter(r => r.id !== shareId))
|
setRequests(prev => prev.filter(r => r.id !== shareId))
|
||||||
toast.info(t('general.operationFailed'))
|
toast.info(t('notification.removed'))
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.message || t('general.error'))
|
toast.error(error.message || t('general.error'))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ export interface NotebooksContextValue {
|
|||||||
// Actions: Notes
|
// Actions: Notes
|
||||||
moveNoteToNotebookOptimistic: (noteId: string, notebookId: string | null) => Promise<void>
|
moveNoteToNotebookOptimistic: (noteId: string, notebookId: string | null) => Promise<void>
|
||||||
|
|
||||||
|
/** Recharger la liste des carnets (ex. après maintenance étiquettes) */
|
||||||
|
refreshNotebooks: () => Promise<void>
|
||||||
|
|
||||||
// Actions: AI (stubs pour l'instant)
|
// Actions: AI (stubs pour l'instant)
|
||||||
suggestNotebookForNote: (noteContent: string) => Promise<Notebook | null>
|
suggestNotebookForNote: (noteContent: string) => Promise<Notebook | null>
|
||||||
suggestLabelsForNote: (noteContent: string, notebookId: string) => Promise<string[]>
|
suggestLabelsForNote: (noteContent: string, notebookId: string) => Promise<string[]>
|
||||||
@@ -262,6 +265,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
updateLabel,
|
updateLabel,
|
||||||
deleteLabel,
|
deleteLabel,
|
||||||
moveNoteToNotebookOptimistic,
|
moveNoteToNotebookOptimistic,
|
||||||
|
refreshNotebooks: loadNotebooks,
|
||||||
suggestNotebookForNote,
|
suggestNotebookForNote,
|
||||||
suggestLabelsForNote,
|
suggestLabelsForNote,
|
||||||
}), [
|
}), [
|
||||||
@@ -279,6 +283,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
updateLabel,
|
updateLabel,
|
||||||
deleteLabel,
|
deleteLabel,
|
||||||
moveNoteToNotebookOptimistic,
|
moveNoteToNotebookOptimistic,
|
||||||
|
loadNotebooks,
|
||||||
suggestNotebookForNote,
|
suggestNotebookForNote,
|
||||||
suggestLabelsForNote,
|
suggestLabelsForNote,
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -1,44 +1,53 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { Note } from '@/lib/types';
|
import { Note } from '@/lib/types';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'memento-notified-reminders';
|
||||||
|
|
||||||
|
function getNotifiedFromStorage(): Set<string> {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
return raw ? new Set(JSON.parse(raw)) : new Set();
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistNotified(ids: Set<string>) {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify([...ids]));
|
||||||
|
} catch { /* quota exceeded — non-critical */ }
|
||||||
|
}
|
||||||
|
|
||||||
export function useReminderCheck(notes: Note[]) {
|
export function useReminderCheck(notes: Note[]) {
|
||||||
const [notifiedReminders, setNotifiedReminders] = useState<Set<string>>(new Set());
|
const notifiedRef = useRef<Set<string>>(getNotifiedFromStorage());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkReminders = () => {
|
const checkReminders = () => {
|
||||||
const now = new Date();
|
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);
|
const reminderDate = new Date(note.reminder);
|
||||||
|
if (reminderDate <= now) {
|
||||||
if (reminderDate <= now && !notifiedReminders.has(note.id)) {
|
newIds.push(note.id);
|
||||||
dueReminders.push(note.id);
|
toast.info(`🔔 ${note.title || 'Untitled Note'}`, { id: `reminder-${note.id}` });
|
||||||
toast.info("🔔 Reminder: " + (note.title || "Untitled Note"));
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Second pass: update state only once with all due reminders
|
if (newIds.length > 0) {
|
||||||
if (dueReminders.length > 0) {
|
for (const id of newIds) notifiedRef.current.add(id);
|
||||||
setNotifiedReminders(prev => {
|
persistNotified(notifiedRef.current);
|
||||||
const newSet = new Set(prev);
|
|
||||||
dueReminders.forEach(id => newSet.add(id));
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check immediately
|
|
||||||
checkReminders();
|
checkReminders();
|
||||||
|
const interval = setInterval(checkReminders, 30_000);
|
||||||
// Then check every 30 seconds
|
|
||||||
const interval = setInterval(checkReminders, 30000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [notes]);
|
}, [notes]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
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({
|
await prisma.note.update({
|
||||||
where: { id: noteId },
|
where: { id: noteId },
|
||||||
data: {
|
data: {
|
||||||
|
labels: JSON.stringify(names),
|
||||||
labelRelations: {
|
labelRelations: {
|
||||||
connect: {
|
connect: { id: label.id },
|
||||||
id: label.id,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { getAIProvider } from '@/lib/ai/factory'
|
import { getTagsProvider } from '@/lib/ai/factory'
|
||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
|
|
||||||
export interface NoteForOrganization {
|
export interface NoteForOrganization {
|
||||||
@@ -102,24 +102,12 @@ export class BatchOrganizationService {
|
|||||||
): Promise<OrganizationPlan> {
|
): Promise<OrganizationPlan> {
|
||||||
const prompt = this.buildPrompt(notes, notebooks, language)
|
const prompt = this.buildPrompt(notes, notebooks, language)
|
||||||
|
|
||||||
try {
|
|
||||||
const config = await getSystemConfig()
|
const config = await getSystemConfig()
|
||||||
const provider = getAIProvider(config)
|
const provider = getTagsProvider(config)
|
||||||
const response = await provider.generateText(prompt)
|
const response = await provider.generateText(prompt)
|
||||||
|
|
||||||
// Parse AI response
|
|
||||||
const plan = this.parseAIResponse(response, notes, notebooks)
|
const plan = this.parseAIResponse(response, notes, notebooks)
|
||||||
|
|
||||||
return plan
|
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -870,6 +858,10 @@ ${notesList}
|
|||||||
return instructions[language] || instructions['en'] || instructions['fr']
|
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
|
* Parse AI response into OrganizationPlan
|
||||||
*/
|
*/
|
||||||
@@ -879,7 +871,6 @@ ${notesList}
|
|||||||
notebooks: any[]
|
notebooks: any[]
|
||||||
): OrganizationPlan {
|
): OrganizationPlan {
|
||||||
try {
|
try {
|
||||||
// Try to parse JSON response
|
|
||||||
const jsonMatch = response.match(/\{[\s\S]*\}/)
|
const jsonMatch = response.match(/\{[\s\S]*\}/)
|
||||||
if (!jsonMatch) {
|
if (!jsonMatch) {
|
||||||
throw new Error('No JSON found in response')
|
throw new Error('No JSON found in response')
|
||||||
@@ -889,9 +880,10 @@ ${notesList}
|
|||||||
|
|
||||||
const notebookOrganizations: NotebookOrganization[] = []
|
const notebookOrganizations: NotebookOrganization[] = []
|
||||||
|
|
||||||
// Process each notebook in AI response
|
|
||||||
for (const aiNotebook of aiData.carnets || []) {
|
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
|
if (!notebook) continue
|
||||||
|
|
||||||
const noteAssignments = aiNotebook.notes
|
const noteAssignments = aiNotebook.notes
|
||||||
@@ -933,12 +925,8 @@ ${notesList}
|
|||||||
unorganizedNotes: unorganizedCount,
|
unorganizedNotes: unorganizedCount,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to parse AI response:', error)
|
console.error('Failed to parse AI response:', error, '\nRaw response:', response.substring(0, 500))
|
||||||
return {
|
throw new Error(`AI response parsing failed: ${error instanceof Error ? error.message : 'Invalid JSON'}`)
|
||||||
notebooks: [],
|
|
||||||
totalNotes: notes.length,
|
|
||||||
unorganizedNotes: notes.length,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"reminders": "Reminders",
|
"reminders": "Reminders",
|
||||||
"labels": "Labels",
|
"labels": "Labels",
|
||||||
"editLabels": "Edit labels",
|
"editLabels": "Edit labels",
|
||||||
|
"noLabelsInNotebook": "No labels in this notebook yet",
|
||||||
"archive": "Archive",
|
"archive": "Archive",
|
||||||
"trash": "Trash"
|
"trash": "Trash"
|
||||||
},
|
},
|
||||||
@@ -389,7 +390,9 @@
|
|||||||
"notification": {
|
"notification": {
|
||||||
"shared": "shared \"{title}\"",
|
"shared": "shared \"{title}\"",
|
||||||
"untitled": "Untitled",
|
"untitled": "Untitled",
|
||||||
"notifications": "Notifications"
|
"notifications": "Notifications",
|
||||||
|
"declined": "Share declined",
|
||||||
|
"removed": "Note removed from list"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
@@ -448,6 +451,12 @@
|
|||||||
"maintenanceDescription": "Tools to maintain your database health",
|
"maintenanceDescription": "Tools to maintain your database health",
|
||||||
"cleanTags": "Clean Orphan Tags",
|
"cleanTags": "Clean Orphan Tags",
|
||||||
"cleanTagsDescription": "Remove tags that are no longer used by any notes",
|
"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",
|
"semanticIndexing": "Semantic Indexing",
|
||||||
"semanticIndexingDescription": "Generate vectors for all notes to enable intent-based search",
|
"semanticIndexingDescription": "Generate vectors for all notes to enable intent-based search",
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
|
|||||||
@@ -637,7 +637,9 @@
|
|||||||
"notification": {
|
"notification": {
|
||||||
"shared": "a partagé « {title} »",
|
"shared": "a partagé « {title} »",
|
||||||
"untitled": "Sans titre",
|
"untitled": "Sans titre",
|
||||||
"notifications": "Notifications"
|
"notifications": "Notifications",
|
||||||
|
"declined": "Partage refusé",
|
||||||
|
"removed": "Note retirée de la liste"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"accountSettings": "Paramètres du compte",
|
"accountSettings": "Paramètres du compte",
|
||||||
@@ -924,6 +926,12 @@
|
|||||||
"appearance": "Apparence",
|
"appearance": "Apparence",
|
||||||
"cleanTags": "Nettoyer les étiquettes orphelines",
|
"cleanTags": "Nettoyer les étiquettes orphelines",
|
||||||
"cleanTagsDescription": "Supprimer les étiquettes qui ne sont plus utilisées par aucune note",
|
"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",
|
"description": "Gérez vos paramètres et préférences",
|
||||||
"language": "Langue",
|
"language": "Langue",
|
||||||
"languageAuto": "Langue définie sur Auto",
|
"languageAuto": "Langue définie sur Auto",
|
||||||
@@ -962,6 +970,7 @@
|
|||||||
"archive": "Archives",
|
"archive": "Archives",
|
||||||
"editLabels": "Modifier les étiquettes",
|
"editLabels": "Modifier les étiquettes",
|
||||||
"labels": "Étiquettes",
|
"labels": "Étiquettes",
|
||||||
|
"noLabelsInNotebook": "Aucune étiquette dans ce carnet",
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
"reminders": "Rappels",
|
"reminders": "Rappels",
|
||||||
"trash": "Corbeille"
|
"trash": "Corbeille"
|
||||||
|
|||||||
Reference in New Issue
Block a user