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
|
||||
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
|
||||
|
||||
@@ -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() {
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<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')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -176,31 +201,31 @@ export default function SettingsPage() {
|
||||
description={t('settings.maintenanceDescription')}
|
||||
>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-4 p-4 border border-border rounded-lg bg-card">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-medium flex items-center gap-2">
|
||||
{t('settings.cleanTags')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.cleanTagsDescription')}
|
||||
</p>
|
||||
</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" />}
|
||||
{t('general.clean')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-4 p-4 border border-border rounded-lg bg-card">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-medium flex items-center gap-2">
|
||||
{t('settings.semanticIndexing')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.semanticIndexingDescription')}
|
||||
</p>
|
||||
</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" />}
|
||||
{t('general.indexAll')}
|
||||
</Button>
|
||||
|
||||
@@ -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<string, string>()
|
||||
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<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()) {
|
||||
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<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({
|
||||
const allNotes = await prisma.note.findMany({
|
||||
where: { userId },
|
||||
select: { id: true, name: true }
|
||||
})
|
||||
const existingLabelMap = new Map<string, string>()
|
||||
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<string>()
|
||||
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<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 {
|
||||
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<string>();
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user