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:
Sepehr Ramezani
2026-04-13 22:07:09 +02:00
parent fa7e166f3e
commit 39671c6472
16 changed files with 469 additions and 303 deletions

View File

@@ -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 dun 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,