- Added multi-provider AI infrastructure (OpenAI/Ollama) - Implemented real-time tag suggestions with debounced analysis - Created AI diagnostics and database maintenance tools in Settings - Added automated garbage collection for orphan labels - Refined UX with deterministic color hashing and interactive ghost tags
487 lines
14 KiB
TypeScript
487 lines
14 KiB
TypeScript
'use server'
|
|
|
|
import { revalidatePath } from 'next/cache'
|
|
import prisma from '@/lib/prisma'
|
|
import { Note, CheckItem } from '@/lib/types'
|
|
import { auth } from '@/auth'
|
|
|
|
// Helper function to parse JSON strings from database
|
|
function parseNote(dbNote: any): Note {
|
|
return {
|
|
...dbNote,
|
|
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
|
|
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
|
|
images: dbNote.images ? JSON.parse(dbNote.images) : null,
|
|
links: dbNote.links ? JSON.parse(dbNote.links) : null,
|
|
}
|
|
}
|
|
|
|
// Get all notes (non-archived by default)
|
|
export async function getNotes(includeArchived = false) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) return [];
|
|
|
|
try {
|
|
const notes = await prisma.note.findMany({
|
|
where: {
|
|
userId: session.user.id,
|
|
...(includeArchived ? {} : { isArchived: false }),
|
|
},
|
|
orderBy: [
|
|
{ isPinned: 'desc' },
|
|
{ order: 'asc' },
|
|
{ updatedAt: 'desc' }
|
|
]
|
|
})
|
|
|
|
return notes.map(parseNote)
|
|
} catch (error) {
|
|
console.error('Error fetching notes:', error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
// Get archived notes only
|
|
export async function getArchivedNotes() {
|
|
const session = await auth();
|
|
if (!session?.user?.id) return [];
|
|
|
|
try {
|
|
const notes = await prisma.note.findMany({
|
|
where: {
|
|
userId: session.user.id,
|
|
isArchived: true
|
|
},
|
|
orderBy: { updatedAt: 'desc' }
|
|
})
|
|
|
|
return notes.map(parseNote)
|
|
} catch (error) {
|
|
console.error('Error fetching archived notes:', error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
// Search notes
|
|
export async function searchNotes(query: string) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) return [];
|
|
|
|
try {
|
|
if (!query.trim()) {
|
|
return await getNotes()
|
|
}
|
|
|
|
const notes = await prisma.note.findMany({
|
|
where: {
|
|
userId: session.user.id,
|
|
isArchived: false,
|
|
OR: [
|
|
{ title: { contains: query } },
|
|
{ content: { contains: query } },
|
|
{ labels: { contains: query } },
|
|
{ checkItems: { contains: query } }
|
|
]
|
|
},
|
|
orderBy: [
|
|
{ isPinned: 'desc' },
|
|
{ updatedAt: 'desc' }
|
|
]
|
|
})
|
|
|
|
// Enhanced ranking: prioritize title matches
|
|
const rankedNotes = notes.map((note: any) => {
|
|
const parsedNote = parseNote(note)
|
|
let score = 0
|
|
|
|
// Title match gets highest score
|
|
if (parsedNote.title?.toLowerCase().includes(query.toLowerCase())) {
|
|
score += 10
|
|
}
|
|
|
|
// Content match
|
|
if (parsedNote.content.toLowerCase().includes(query.toLowerCase())) {
|
|
score += 5
|
|
}
|
|
|
|
// Label match
|
|
if (parsedNote.labels?.some(label => label.toLowerCase().includes(query.toLowerCase()))) {
|
|
score += 3
|
|
}
|
|
|
|
// CheckItems match
|
|
if (parsedNote.checkItems?.some(item => item.text.toLowerCase().includes(query.toLowerCase()))) {
|
|
score += 2
|
|
}
|
|
|
|
return { note: parsedNote, score }
|
|
})
|
|
|
|
// Sort by score descending, then by existing order (pinned/updated)
|
|
return rankedNotes
|
|
.sort((a: any, b: any) => b.score - a.score)
|
|
.map((item: any) => item.note)
|
|
} catch (error) {
|
|
console.error('Error searching notes:', error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
// Create a new note
|
|
export async function createNote(data: {
|
|
title?: string
|
|
content: string
|
|
color?: string
|
|
type?: 'text' | 'checklist'
|
|
checkItems?: CheckItem[]
|
|
labels?: string[]
|
|
images?: string[]
|
|
links?: any[]
|
|
isArchived?: boolean
|
|
reminder?: Date | null
|
|
isMarkdown?: boolean
|
|
}) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) throw new Error('Unauthorized');
|
|
|
|
try {
|
|
const note = await prisma.note.create({
|
|
data: {
|
|
userId: session.user.id,
|
|
title: data.title || null,
|
|
content: data.content,
|
|
color: data.color || 'default',
|
|
type: data.type || 'text',
|
|
checkItems: data.checkItems ? JSON.stringify(data.checkItems) : null,
|
|
labels: data.labels ? JSON.stringify(data.labels) : null,
|
|
images: data.images ? JSON.stringify(data.images) : null,
|
|
links: data.links ? JSON.stringify(data.links) : null,
|
|
isArchived: data.isArchived || false,
|
|
reminder: data.reminder || null,
|
|
isMarkdown: data.isMarkdown || false,
|
|
}
|
|
})
|
|
|
|
revalidatePath('/')
|
|
return parseNote(note)
|
|
} catch (error) {
|
|
console.error('Error creating note:', error)
|
|
throw new Error('Failed to create note')
|
|
}
|
|
}
|
|
|
|
// Helper to cleanup orphan labels
|
|
async function cleanupOrphanLabels(userId: string, candidateLabels: string[]) {
|
|
if (!candidateLabels || candidateLabels.length === 0) return
|
|
|
|
for (const labelName of candidateLabels) {
|
|
// Check if label is used in any other note
|
|
// Note: We search for the label name within the JSON string array
|
|
// This is a rough check but effective for JSON arrays like ["Label1","Label2"]
|
|
const count = await prisma.note.count({
|
|
where: {
|
|
userId,
|
|
labels: {
|
|
contains: `"${labelName}"`
|
|
}
|
|
}
|
|
})
|
|
|
|
if (count === 0) {
|
|
console.log(`Cleaning up orphan label: ${labelName}`)
|
|
try {
|
|
await prisma.label.deleteMany({
|
|
where: {
|
|
userId,
|
|
name: labelName
|
|
}
|
|
})
|
|
} catch (e) {
|
|
console.error(`Failed to delete orphan label ${labelName}:`, e)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update a note
|
|
export async function updateNote(id: string, data: {
|
|
title?: string | null
|
|
content?: string
|
|
color?: string
|
|
isPinned?: boolean
|
|
isArchived?: boolean
|
|
type?: 'text' | 'checklist'
|
|
checkItems?: CheckItem[] | null
|
|
labels?: string[] | null
|
|
images?: string[] | null
|
|
links?: any[] | null
|
|
reminder?: Date | null
|
|
isMarkdown?: boolean
|
|
}) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) throw new Error('Unauthorized');
|
|
|
|
try {
|
|
// Get old note state to compare labels
|
|
const oldNote = await prisma.note.findUnique({
|
|
where: { id, userId: session.user.id },
|
|
select: { labels: true }
|
|
})
|
|
|
|
const oldLabels: string[] = oldNote?.labels ? JSON.parse(oldNote.labels) : []
|
|
|
|
// Stringify JSON fields if they exist
|
|
const updateData: any = { ...data }
|
|
if ('checkItems' in data) {
|
|
updateData.checkItems = data.checkItems ? JSON.stringify(data.checkItems) : null
|
|
}
|
|
if ('labels' in data) {
|
|
updateData.labels = data.labels ? JSON.stringify(data.labels) : null
|
|
}
|
|
if ('images' in data) {
|
|
updateData.images = data.images ? JSON.stringify(data.images) : null
|
|
}
|
|
if ('links' in data) {
|
|
updateData.links = data.links ? JSON.stringify(data.links) : null
|
|
}
|
|
updateData.updatedAt = new Date()
|
|
|
|
const note = await prisma.note.update({
|
|
where: {
|
|
id,
|
|
userId: session.user.id
|
|
},
|
|
data: updateData
|
|
})
|
|
|
|
// Cleanup orphan labels if labels changed
|
|
if (data.labels && oldLabels.length > 0) {
|
|
const removedLabels = oldLabels.filter(l => !data.labels?.includes(l))
|
|
if (removedLabels.length > 0) {
|
|
// Execute async without awaiting to not block response
|
|
cleanupOrphanLabels(session.user.id, removedLabels)
|
|
}
|
|
}
|
|
|
|
revalidatePath('/')
|
|
return parseNote(note)
|
|
} catch (error) {
|
|
console.error('Error updating note:', error)
|
|
throw new Error('Failed to update note')
|
|
}
|
|
}
|
|
|
|
// Delete a note
|
|
export async function deleteNote(id: string) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) throw new Error('Unauthorized');
|
|
|
|
try {
|
|
// Get labels before delete
|
|
const note = await prisma.note.findUnique({
|
|
where: { id, userId: session.user.id },
|
|
select: { labels: true }
|
|
})
|
|
const labels: string[] = note?.labels ? JSON.parse(note.labels) : []
|
|
|
|
await prisma.note.delete({
|
|
where: {
|
|
id,
|
|
userId: session.user.id
|
|
}
|
|
})
|
|
|
|
// Cleanup potential orphans
|
|
if (labels.length > 0) {
|
|
cleanupOrphanLabels(session.user.id, labels)
|
|
}
|
|
|
|
revalidatePath('/')
|
|
return { success: true }
|
|
} catch (error) {
|
|
console.error('Error deleting note:', error)
|
|
throw new Error('Failed to delete note')
|
|
}
|
|
}
|
|
|
|
// Toggle pin status
|
|
export async function togglePin(id: string, isPinned: boolean) {
|
|
return updateNote(id, { isPinned })
|
|
}
|
|
|
|
// Toggle archive status
|
|
export async function toggleArchive(id: string, isArchived: boolean) {
|
|
return updateNote(id, { isArchived })
|
|
}
|
|
|
|
// Update note color
|
|
export async function updateColor(id: string, color: string) {
|
|
return updateNote(id, { color })
|
|
}
|
|
|
|
// Update note labels
|
|
export async function updateLabels(id: string, labels: string[]) {
|
|
return updateNote(id, { labels })
|
|
}
|
|
|
|
// Get all unique labels
|
|
export async function getAllLabels() {
|
|
try {
|
|
const notes = await prisma.note.findMany({
|
|
select: { labels: true }
|
|
})
|
|
|
|
const labelsSet = new Set<string>()
|
|
notes.forEach((note: any) => {
|
|
const labels = note.labels ? JSON.parse(note.labels) : null
|
|
if (labels) {
|
|
labels.forEach((label: string) => labelsSet.add(label))
|
|
}
|
|
})
|
|
|
|
return Array.from(labelsSet).sort()
|
|
} catch (error) {
|
|
console.error('Error fetching labels:', error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
// Reorder notes (drag and drop)
|
|
export async function reorderNotes(draggedId: string, targetId: string) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) throw new Error('Unauthorized');
|
|
|
|
console.log('[REORDER-DEBUG] reorderNotes called:', { draggedId, targetId })
|
|
|
|
try {
|
|
const draggedNote = await prisma.note.findUnique({ where: { id: draggedId, userId: session.user.id } })
|
|
const targetNote = await prisma.note.findUnique({ where: { id: targetId, userId: session.user.id } })
|
|
|
|
console.log('[REORDER-DEBUG] Notes found:', {
|
|
draggedNote: draggedNote ? { id: draggedNote.id, title: draggedNote.title, isPinned: draggedNote.isPinned, order: draggedNote.order } : null,
|
|
targetNote: targetNote ? { id: targetNote.id, title: targetNote.title, isPinned: targetNote.isPinned, order: targetNote.order } : null
|
|
})
|
|
|
|
if (!draggedNote || !targetNote) {
|
|
console.error('[REORDER-DEBUG] Notes not found')
|
|
throw new Error('Notes not found')
|
|
}
|
|
|
|
// Get all notes in the same category (pinned or unpinned)
|
|
const allNotes = await prisma.note.findMany({
|
|
where: {
|
|
userId: session.user.id,
|
|
isPinned: draggedNote.isPinned,
|
|
isArchived: false
|
|
},
|
|
orderBy: { order: 'asc' }
|
|
})
|
|
|
|
console.log('[REORDER-DEBUG] All notes in category:', allNotes.map((n: any) => ({ id: n.id, title: n.title, order: n.order })))
|
|
|
|
// Create new order array
|
|
const reorderedNotes = allNotes.filter((n: any) => n.id !== draggedId)
|
|
const targetIndex = reorderedNotes.findIndex((n: any) => n.id === targetId)
|
|
reorderedNotes.splice(targetIndex, 0, draggedNote)
|
|
|
|
console.log('[REORDER-DEBUG] New order:', reorderedNotes.map((n: any, i: number) => ({ id: n.id, title: n.title, newOrder: i })))
|
|
|
|
// Update all notes with new order
|
|
const updates = reorderedNotes.map((note: any, index: number) =>
|
|
prisma.note.update({
|
|
where: { id: note.id },
|
|
data: { order: index }
|
|
})
|
|
)
|
|
|
|
console.log('[REORDER-DEBUG] Executing transaction with', updates.length, 'updates')
|
|
await prisma.$transaction(updates)
|
|
console.log('[REORDER-DEBUG] Transaction completed successfully')
|
|
|
|
revalidatePath('/')
|
|
return { success: true }
|
|
} catch (error) {
|
|
console.error('[REORDER-DEBUG] Error reordering notes:', error)
|
|
throw new Error('Failed to reorder notes')
|
|
}
|
|
}
|
|
|
|
// Public action to manually trigger cleanup
|
|
export async function cleanupAllOrphans() {
|
|
const session = await auth();
|
|
if (!session?.user?.id) throw new Error('Unauthorized');
|
|
|
|
const userId = session.user.id;
|
|
let deletedCount = 0;
|
|
|
|
try {
|
|
// 1. Get all labels defined in Label table
|
|
const allDefinedLabels = await prisma.label.findMany({
|
|
where: { userId },
|
|
select: { id: true, name: true }
|
|
})
|
|
|
|
// 2. Get all used labels from Notes (fetch only labels column)
|
|
const allNotes = await prisma.note.findMany({
|
|
where: { userId },
|
|
select: { labels: true }
|
|
})
|
|
|
|
// 3. Build a Set of all used label names
|
|
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())); // Normalize to lowercase for comparison
|
|
}
|
|
} catch (e) {
|
|
// Ignore parse errors
|
|
}
|
|
}
|
|
});
|
|
|
|
// 4. Identify orphans
|
|
const orphans = allDefinedLabels.filter(label => !usedLabelsSet.has(label.name.toLowerCase()));
|
|
|
|
// 5. Delete orphans
|
|
for (const orphan of orphans) {
|
|
console.log(`Deleting orphan label: ${orphan.name}`);
|
|
await prisma.label.delete({ where: { id: orphan.id } });
|
|
deletedCount++;
|
|
}
|
|
|
|
revalidatePath('/')
|
|
return { success: true, count: deletedCount }
|
|
} catch (error) {
|
|
console.error('Error cleaning up orphans:', error)
|
|
throw new Error('Failed to cleanup database')
|
|
}
|
|
}
|
|
|
|
// Update full order of notes
|
|
export async function updateFullOrder(ids: string[]) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) throw new Error('Unauthorized');
|
|
|
|
const userId = session.user.id;
|
|
|
|
try {
|
|
const updates = ids.map((id: string, index: number) =>
|
|
prisma.note.update({
|
|
where: { id, userId },
|
|
data: { order: index }
|
|
})
|
|
)
|
|
|
|
await prisma.$transaction(updates)
|
|
revalidatePath('/')
|
|
return { success: true }
|
|
} catch (error) {
|
|
console.error('Error updating full order:', error)
|
|
throw new Error('Failed to update order')
|
|
}
|
|
}
|