chore: clean up repo for public release
- Remove BMAD framework, IDE configs, dev screenshots, test files, internal docs, and backup files - Rename keep-notes/ to memento-note/ - Update all references from keep-notes to memento-note - Add Apache 2.0 license with Commons Clause (non-commercial restriction) - Add clean .gitignore and .env.docker.example
This commit is contained in:
101
memento-note/app/api/admin/embeddings/validate/route.ts
Normal file
101
memento-note/app/api/admin/embeddings/validate/route.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { validateEmbedding } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Admin endpoint to validate all embeddings in the database
|
||||
* Returns a list of notes with invalid embeddings
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { role: true }
|
||||
})
|
||||
|
||||
if (!user || user.role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden - Admin only' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Fetch all notes with embeddings
|
||||
const allNotes = await prisma.note.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
noteEmbedding: true
|
||||
}
|
||||
})
|
||||
|
||||
const invalidNotes: Array<{
|
||||
id: string
|
||||
title: string
|
||||
issues: string[]
|
||||
}> = []
|
||||
|
||||
let validCount = 0
|
||||
let missingCount = 0
|
||||
let invalidCount = 0
|
||||
|
||||
for (const note of allNotes) {
|
||||
// Check if embedding is missing
|
||||
if (!note.noteEmbedding?.embedding) {
|
||||
missingCount++
|
||||
invalidNotes.push({
|
||||
id: note.id,
|
||||
title: note.title || 'Untitled',
|
||||
issues: ['Missing embedding']
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate embedding
|
||||
try {
|
||||
if (!note.noteEmbedding?.embedding) continue
|
||||
const embedding = JSON.parse(note.noteEmbedding.embedding) as number[]
|
||||
const validation = validateEmbedding(embedding)
|
||||
|
||||
if (!validation.valid) {
|
||||
invalidCount++
|
||||
invalidNotes.push({
|
||||
id: note.id,
|
||||
title: note.title || 'Untitled',
|
||||
issues: validation.issues
|
||||
})
|
||||
} else {
|
||||
validCount++
|
||||
}
|
||||
} catch (error) {
|
||||
invalidCount++
|
||||
invalidNotes.push({
|
||||
id: note.id,
|
||||
title: note.title || 'Untitled',
|
||||
issues: [`Failed to parse embedding: ${error}`]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
summary: {
|
||||
total: allNotes.length,
|
||||
valid: validCount,
|
||||
missing: missingCount,
|
||||
invalid: invalidCount
|
||||
},
|
||||
invalidNotes
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[EMBEDDING_VALIDATION] Error:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: String(error) },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
31
memento-note/app/api/admin/randomize-labels/route.ts
Normal file
31
memento-note/app/api/admin/randomize-labels/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { LABEL_COLORS } from '@/lib/types';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const labels = await prisma.label.findMany();
|
||||
const colors = Object.keys(LABEL_COLORS).filter(c => c !== 'gray'); // Exclude gray to force colors
|
||||
|
||||
const updates = labels.map((label: any) => {
|
||||
const randomColor = colors[Math.floor(Math.random() * colors.length)];
|
||||
return prisma.label.update({
|
||||
where: { id: label.id },
|
||||
data: { color: randomColor }
|
||||
});
|
||||
});
|
||||
|
||||
await prisma.$transaction(updates);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
updated: updates.length,
|
||||
message: "All labels have been assigned a random non-gray color."
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
56
memento-note/app/api/admin/sync-labels/route.ts
Normal file
56
memento-note/app/api/admin/sync-labels/route.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// 1. Get all notes
|
||||
const notes = await prisma.note.findMany({
|
||||
select: { labels: true }
|
||||
});
|
||||
|
||||
// 2. Extract all unique labels from JSON
|
||||
const uniqueLabels = new Set<string>();
|
||||
notes.forEach((note: any) => {
|
||||
if (note.labels) {
|
||||
try {
|
||||
if (Array.isArray(note.labels)) {
|
||||
(note.labels as string[]).forEach((l: string) => uniqueLabels.add(l));
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore error
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Get existing labels in DB
|
||||
const existingDbLabels = await prisma.label.findMany();
|
||||
const existingNames = new Set(existingDbLabels.map((l: any) => l.name));
|
||||
|
||||
// 4. Create missing labels
|
||||
const created = [];
|
||||
for (const name of uniqueLabels) {
|
||||
if (!existingNames.has(name)) {
|
||||
const newLabel = await prisma.label.create({
|
||||
data: {
|
||||
name,
|
||||
color: 'gray' // Default color
|
||||
}
|
||||
});
|
||||
created.push(newLabel);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
foundInNotes: uniqueLabels.size,
|
||||
alreadyInDb: existingNames.size,
|
||||
created: created.length,
|
||||
createdLabels: created
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
122
memento-note/app/api/ai/auto-labels/route.ts
Normal file
122
memento-note/app/api/ai/auto-labels/route.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { autoLabelCreationService } from '@/lib/ai/services'
|
||||
|
||||
/**
|
||||
* POST /api/ai/auto-labels - Suggest new labels for a notebook
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { notebookId, language = 'en' } = body
|
||||
|
||||
if (!notebookId || typeof notebookId !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Missing required field: notebookId' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if notebook belongs to user
|
||||
const { prisma } = await import('@/lib/prisma')
|
||||
const notebook = await prisma.notebook.findFirst({
|
||||
where: {
|
||||
id: notebookId,
|
||||
userId: session.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
if (!notebook) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Notebook not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get label suggestions
|
||||
const suggestions = await autoLabelCreationService.suggestLabels(
|
||||
notebookId,
|
||||
session.user.id,
|
||||
language
|
||||
)
|
||||
|
||||
if (!suggestions) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: null,
|
||||
message: 'No suggestions available (notebook may have fewer than 15 notes)',
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: suggestions,
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get label suggestions',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/ai/auto-labels - Create suggested labels
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { suggestions, selectedLabels } = body
|
||||
|
||||
if (!suggestions || !Array.isArray(selectedLabels)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Missing required fields: suggestions, selectedLabels' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create labels
|
||||
const createdCount = await autoLabelCreationService.createLabels(
|
||||
suggestions.notebookId,
|
||||
session.user.id,
|
||||
suggestions,
|
||||
selectedLabels
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
createdCount,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create labels',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
102
memento-note/app/api/ai/batch-organize/route.ts
Normal file
102
memento-note/app/api/ai/batch-organize/route.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { batchOrganizationService } from '@/lib/ai/services'
|
||||
|
||||
/**
|
||||
* POST /api/ai/batch-organize - Create organization plan for notes in Inbox
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get language from request headers or body
|
||||
let language = 'en'
|
||||
try {
|
||||
const body = await request.json()
|
||||
if (body.language) {
|
||||
language = body.language
|
||||
}
|
||||
} catch (e) {
|
||||
// If no body or invalid json, check headers
|
||||
const acceptLanguage = request.headers.get('accept-language')
|
||||
if (acceptLanguage) {
|
||||
language = acceptLanguage.split(',')[0].split('-')[0]
|
||||
}
|
||||
}
|
||||
|
||||
// Create organization plan
|
||||
const plan = await batchOrganizationService.createOrganizationPlan(
|
||||
session.user.id,
|
||||
language
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: plan,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[batch-organize POST] Error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create organization plan',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/ai/batch-organize - Apply organization plan
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { plan, selectedNoteIds } = body
|
||||
|
||||
if (!plan || !Array.isArray(selectedNoteIds)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Missing required fields: plan, selectedNoteIds' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Apply organization plan
|
||||
const movedCount = await batchOrganizationService.applyOrganizationPlan(
|
||||
session.user.id,
|
||||
plan,
|
||||
selectedNoteIds
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
movedCount,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to apply organization plan',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
26
memento-note/app/api/ai/config/route.ts
Normal file
26
memento-note/app/api/ai/config/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
|
||||
return NextResponse.json({
|
||||
AI_PROVIDER_TAGS: config.AI_PROVIDER_TAGS || 'not set',
|
||||
AI_MODEL_TAGS: config.AI_MODEL_TAGS || 'not set',
|
||||
AI_PROVIDER_EMBEDDING: config.AI_PROVIDER_EMBEDDING || 'not set',
|
||||
AI_MODEL_EMBEDDING: config.AI_MODEL_EMBEDDING || 'not set',
|
||||
OPENAI_API_KEY: config.OPENAI_API_KEY ? '***configured***' : '',
|
||||
CUSTOM_OPENAI_API_KEY: config.CUSTOM_OPENAI_API_KEY ? '***configured***' : '',
|
||||
CUSTOM_OPENAI_BASE_URL: config.CUSTOM_OPENAI_BASE_URL || '',
|
||||
OLLAMA_BASE_URL: config.OLLAMA_BASE_URL || 'not set'
|
||||
})
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error.message || 'Failed to fetch config'
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
85
memento-note/app/api/ai/echo/connections/route.ts
Normal file
85
memento-note/app/api/ai/echo/connections/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
|
||||
|
||||
/**
|
||||
* GET /api/ai/echo/connections?noteId={id}&page={page}&limit={limit}
|
||||
* Fetch all connections for a specific note
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get query parameters
|
||||
const { searchParams } = new URL(req.url)
|
||||
const noteId = searchParams.get('noteId')
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '10')
|
||||
|
||||
// Validate noteId
|
||||
if (!noteId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'noteId parameter is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate pagination parameters
|
||||
if (page < 1 || limit < 1 || limit > 50) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid pagination parameters. page >= 1, limit between 1 and 50' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get all connections for the note
|
||||
const allConnections = await memoryEchoService.getConnectionsForNote(noteId, session.user.id)
|
||||
|
||||
// Calculate pagination
|
||||
const total = allConnections.length
|
||||
const startIndex = (page - 1) * limit
|
||||
const endIndex = startIndex + limit
|
||||
const paginatedConnections = allConnections.slice(startIndex, endIndex)
|
||||
|
||||
// Format connections for response
|
||||
const connections = paginatedConnections.map(conn => {
|
||||
// Determine which note is the "other" note (not the target note)
|
||||
const isNote1Target = conn.note1.id === noteId
|
||||
const otherNote = isNote1Target ? conn.note2 : conn.note1
|
||||
|
||||
return {
|
||||
noteId: otherNote.id,
|
||||
title: otherNote.title,
|
||||
content: otherNote.content,
|
||||
createdAt: otherNote.createdAt,
|
||||
similarity: conn.similarityScore,
|
||||
daysApart: conn.daysApart
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
connections,
|
||||
pagination: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
hasNext: endIndex < total,
|
||||
hasPrev: page > 1
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch connections' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
60
memento-note/app/api/ai/echo/dismiss/route.ts
Normal file
60
memento-note/app/api/ai/echo/dismiss/route.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
/**
|
||||
* POST /api/ai/echo/dismiss
|
||||
* Dismiss a connection for a specific note
|
||||
* Body: { noteId, connectedNoteId }
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { noteId, connectedNoteId } = body
|
||||
|
||||
if (!noteId || !connectedNoteId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'noteId and connectedNoteId are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Find and mark matching insights as dismissed
|
||||
// We need to find insights where (note1Id = noteId AND note2Id = connectedNoteId) OR (note1Id = connectedNoteId AND note2Id = noteId)
|
||||
await prisma.memoryEchoInsight.updateMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
OR: [
|
||||
{
|
||||
note1Id: noteId,
|
||||
note2Id: connectedNoteId
|
||||
},
|
||||
{
|
||||
note1Id: connectedNoteId,
|
||||
note2Id: noteId
|
||||
}
|
||||
]
|
||||
},
|
||||
data: {
|
||||
dismissed: true
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to dismiss connection' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
108
memento-note/app/api/ai/echo/fusion/route.ts
Normal file
108
memento-note/app/api/ai/echo/fusion/route.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
/**
|
||||
* POST /api/ai/echo/fusion
|
||||
* Generate intelligent fusion of multiple notes
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { noteIds, prompt } = body
|
||||
|
||||
if (!noteIds || !Array.isArray(noteIds) || noteIds.length < 2) {
|
||||
return NextResponse.json(
|
||||
{ error: 'At least 2 note IDs are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch the notes
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
id: { in: noteIds },
|
||||
userId: session.user.id
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
createdAt: true
|
||||
}
|
||||
})
|
||||
|
||||
if (notes.length !== noteIds.length) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Some notes not found or access denied' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get AI provider
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
|
||||
// Build fusion prompt
|
||||
const notesDescriptions = notes.map((note, index) => {
|
||||
return `Note ${index + 1}: "${note.title || 'Untitled'}"
|
||||
${note.content}`
|
||||
}).join('\n\n')
|
||||
|
||||
const fusionPrompt = `You are an expert at synthesizing and merging information from multiple sources.
|
||||
|
||||
TASK: Create a unified, well-structured note by intelligently combining the following notes.
|
||||
|
||||
${prompt ? `ADDITIONAL INSTRUCTIONS: ${prompt}\n` : ''}
|
||||
|
||||
NOTES TO MERGE:
|
||||
${notesDescriptions}
|
||||
|
||||
REQUIREMENTS:
|
||||
1. Create a clear, descriptive title that captures the essence of all notes
|
||||
2. Merge and consolidate related information
|
||||
3. Remove duplicates while preserving unique details from each note
|
||||
4. Organize the content logically (use headers, bullet points, etc.)
|
||||
5. Maintain the important details and context from all notes
|
||||
6. Keep the tone and style consistent
|
||||
7. Use markdown formatting for better readability
|
||||
|
||||
Output format:
|
||||
# [Fused Title]
|
||||
|
||||
[Merged and organized content...]
|
||||
|
||||
Begin:`
|
||||
|
||||
try {
|
||||
const fusedContent = await provider.generateText(fusionPrompt)
|
||||
|
||||
return NextResponse.json({
|
||||
fusedNote: fusedContent,
|
||||
notesCount: notes.length
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to generate fusion' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to process fusion request' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
92
memento-note/app/api/ai/echo/route.ts
Normal file
92
memento-note/app/api/ai/echo/route.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
|
||||
|
||||
/**
|
||||
* GET /api/ai/echo
|
||||
* Fetch next Memory Echo insight for current user
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get next insight (respects frequency limits)
|
||||
const insight = await memoryEchoService.getNextInsight(session.user.id)
|
||||
|
||||
if (!insight) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
insight: null,
|
||||
message: 'No new insights available at the moment. Memory Echo will notify you when we discover connections between your notes.'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ insight })
|
||||
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Memory Echo insight' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ai/echo
|
||||
* Submit feedback or mark as viewed
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { action, insightId, feedback } = body
|
||||
|
||||
if (action === 'view') {
|
||||
// Mark insight as viewed
|
||||
await memoryEchoService.markAsViewed(insightId)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
|
||||
} else if (action === 'feedback') {
|
||||
// Submit feedback (thumbs_up or thumbs_down)
|
||||
if (!feedback || !['thumbs_up', 'thumbs_down'].includes(feedback)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid feedback. Must be thumbs_up or thumbs_down' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await memoryEchoService.submitFeedback(insightId, feedback)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid action. Must be "view" or "feedback"' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to process request' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
96
memento-note/app/api/ai/models/route.ts
Normal file
96
memento-note/app/api/ai/models/route.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
// Modèles populaires pour chaque provider (2025)
|
||||
const PROVIDER_MODELS = {
|
||||
ollama: {
|
||||
tags: [
|
||||
'llama3:latest',
|
||||
'llama3.2:latest',
|
||||
'granite4:latest',
|
||||
'mistral:latest',
|
||||
'mixtral:latest',
|
||||
'phi3:latest',
|
||||
'gemma2:latest',
|
||||
'qwen2:latest'
|
||||
],
|
||||
embeddings: [
|
||||
'embeddinggemma:latest',
|
||||
'mxbai-embed-large:latest',
|
||||
'nomic-embed-text:latest'
|
||||
]
|
||||
},
|
||||
openai: {
|
||||
tags: [
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
'gpt-4-turbo',
|
||||
'gpt-4',
|
||||
'gpt-3.5-turbo'
|
||||
],
|
||||
embeddings: [
|
||||
'text-embedding-3-small',
|
||||
'text-embedding-3-large',
|
||||
'text-embedding-ada-002'
|
||||
]
|
||||
},
|
||||
custom: {
|
||||
tags: [], // Will be loaded dynamically
|
||||
embeddings: [] // Will be loaded dynamically
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = (config.AI_PROVIDER || 'ollama').toLowerCase()
|
||||
|
||||
let models = PROVIDER_MODELS[provider as keyof typeof PROVIDER_MODELS] || { tags: [], embeddings: [] }
|
||||
|
||||
// Pour Ollama, essayer de récupérer la liste réelle depuis l'API locale
|
||||
if (provider === 'ollama') {
|
||||
try {
|
||||
const ollamaBaseUrl = config.OLLAMA_BASE_URL || process.env.OLLAMA_BASE_URL || 'http://localhost:11434'
|
||||
const response = await fetch(`${ollamaBaseUrl}/api/tags`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const allModels = data.models || []
|
||||
|
||||
// Séparer les modèles de tags et d'embeddings
|
||||
const tagModels = allModels
|
||||
.filter((m: any) => !m.name.includes('embed') && !m.name.includes('Embedding'))
|
||||
.map((m: any) => m.name)
|
||||
.slice(0, 20) // Limiter à 20 modèles
|
||||
|
||||
const embeddingModels = allModels
|
||||
.filter((m: any) => m.name.includes('embed') || m.name.includes('Embedding'))
|
||||
.map((m: any) => m.name)
|
||||
|
||||
models = {
|
||||
tags: tagModels.length > 0 ? tagModels : models.tags,
|
||||
embeddings: embeddingModels.length > 0 ? embeddingModels : models.embeddings
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Garder les modèles par défaut
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
provider,
|
||||
models: models || { tags: [], embeddings: [] }
|
||||
})
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error.message || 'Failed to fetch models',
|
||||
models: { tags: [], embeddings: [] }
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
73
memento-note/app/api/ai/notebook-summary/route.ts
Normal file
73
memento-note/app/api/ai/notebook-summary/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { notebookSummaryService } from '@/lib/ai/services'
|
||||
|
||||
/**
|
||||
* POST /api/ai/notebook-summary - Generate summary for a notebook
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { notebookId, language = 'en' } = body
|
||||
|
||||
if (!notebookId || typeof notebookId !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Missing required field: notebookId' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if notebook belongs to user
|
||||
const { prisma } = await import('@/lib/prisma')
|
||||
const notebook = await prisma.notebook.findFirst({
|
||||
where: {
|
||||
id: notebookId,
|
||||
userId: session.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
if (!notebook) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Notebook not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate summary
|
||||
const summary = await notebookSummaryService.generateSummary(
|
||||
notebookId,
|
||||
session.user.id,
|
||||
language
|
||||
)
|
||||
|
||||
if (!summary) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: null,
|
||||
message: 'No summary available (notebook may be empty)',
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: summary,
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to generate notebook summary',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
57
memento-note/app/api/ai/reformulate/route.ts
Normal file
57
memento-note/app/api/ai/reformulate/route.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { paragraphRefactorService } from '@/lib/ai/services/paragraph-refactor.service'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { text, option } = await request.json()
|
||||
|
||||
// Validation
|
||||
if (!text || typeof text !== 'string') {
|
||||
return NextResponse.json({ error: 'Text is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Map option to refactor mode
|
||||
const modeMap: Record<string, 'clarify' | 'shorten' | 'improveStyle'> = {
|
||||
'clarify': 'clarify',
|
||||
'shorten': 'shorten',
|
||||
'improve': 'improveStyle'
|
||||
}
|
||||
|
||||
const mode = modeMap[option]
|
||||
if (!mode) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid option. Use: clarify, shorten, or improve' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate word count
|
||||
const validation = paragraphRefactorService.validateWordCount(text)
|
||||
if (!validation.valid) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
// Use the ParagraphRefactorService
|
||||
const result = await paragraphRefactorService.refactor(text, mode)
|
||||
|
||||
return NextResponse.json({
|
||||
originalText: result.original,
|
||||
reformulatedText: result.refactored,
|
||||
option: option,
|
||||
language: result.language,
|
||||
wordCountChange: result.wordCountChange
|
||||
})
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to reformulate text' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
46
memento-note/app/api/ai/suggest-notebook/route.ts
Normal file
46
memento-note/app/api/ai/suggest-notebook/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { notebookSuggestionService } from '@/lib/ai/services/notebook-suggestion.service'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { noteContent, language = 'en' } = body
|
||||
|
||||
if (!noteContent || typeof noteContent !== 'string') {
|
||||
return NextResponse.json({ error: 'noteContent is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Minimum content length for suggestion (20 words as per specs)
|
||||
const wordCount = noteContent.trim().split(/\s+/).length
|
||||
if (wordCount < 20) {
|
||||
return NextResponse.json({
|
||||
suggestion: null,
|
||||
reason: 'content_too_short',
|
||||
message: 'Note content too short for meaningful suggestion'
|
||||
})
|
||||
}
|
||||
|
||||
// Get suggestion from AI service
|
||||
const suggestedNotebook = await notebookSuggestionService.suggestNotebook(
|
||||
noteContent,
|
||||
session.user.id,
|
||||
language
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
suggestion: suggestedNotebook,
|
||||
confidence: suggestedNotebook ? 0.8 : 0 // Placeholder confidence score
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to generate suggestion' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
62
memento-note/app/api/ai/tags/route.ts
Normal file
62
memento-note/app/api/ai/tags/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service';
|
||||
import { getAIProvider } from '@/lib/ai/factory';
|
||||
import { getSystemConfig } from '@/lib/config';
|
||||
import { z } from 'zod';
|
||||
|
||||
const requestSchema = z.object({
|
||||
content: z.string().min(1, "Le contenu ne peut pas être vide"),
|
||||
notebookId: z.string().optional(),
|
||||
language: z.string().default('en'),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { content, notebookId, language } = requestSchema.parse(body);
|
||||
|
||||
// If notebookId is provided, use contextual suggestions (IA2)
|
||||
if (notebookId) {
|
||||
const suggestions = await contextualAutoTagService.suggestLabels(
|
||||
content,
|
||||
notebookId,
|
||||
session.user.id,
|
||||
language
|
||||
);
|
||||
|
||||
// Convert label → tag to match TagSuggestion interface
|
||||
const convertedTags = suggestions.map(s => ({
|
||||
tag: s.label, // Convert label to tag
|
||||
confidence: s.confidence,
|
||||
// Keep additional properties for client-side use
|
||||
...(s.reasoning && { reasoning: s.reasoning }),
|
||||
...(s.isNewLabel !== undefined && { isNewLabel: s.isNewLabel })
|
||||
}));
|
||||
|
||||
return NextResponse.json({ tags: convertedTags });
|
||||
}
|
||||
|
||||
// Otherwise, use legacy auto-tagging (generates new tags)
|
||||
const config = await getSystemConfig();
|
||||
const provider = getAIProvider(config);
|
||||
const tags = await provider.generateTags(content, language);
|
||||
|
||||
return NextResponse.json({ tags });
|
||||
} catch (error: any) {
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Erreur lors de la génération des tags' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
90
memento-note/app/api/ai/test-embeddings/route.ts
Normal file
90
memento-note/app/api/ai/test-embeddings/route.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getEmbeddingsProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
function getProviderDetails(config: Record<string, string>, providerType: string) {
|
||||
const provider = providerType.toLowerCase()
|
||||
|
||||
switch (provider) {
|
||||
case 'ollama':
|
||||
return {
|
||||
provider: 'Ollama',
|
||||
baseUrl: config.OLLAMA_BASE_URL || 'http://localhost:11434',
|
||||
model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest'
|
||||
}
|
||||
case 'openai':
|
||||
return {
|
||||
provider: 'OpenAI',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: config.AI_MODEL_EMBEDDING || 'text-embedding-3-small'
|
||||
}
|
||||
case 'custom':
|
||||
return {
|
||||
provider: 'Custom OpenAI',
|
||||
baseUrl: config.CUSTOM_OPENAI_BASE_URL || 'Not configured',
|
||||
model: config.AI_MODEL_EMBEDDING || 'text-embedding-3-small'
|
||||
}
|
||||
default:
|
||||
return {
|
||||
provider: provider,
|
||||
baseUrl: 'unknown',
|
||||
model: config.AI_MODEL_EMBEDDING || 'unknown'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getEmbeddingsProvider(config)
|
||||
|
||||
const testText = 'test'
|
||||
const startTime = Date.now()
|
||||
const embeddings = await provider.getEmbeddings(testText)
|
||||
const endTime = Date.now()
|
||||
|
||||
if (!embeddings || embeddings.length === 0) {
|
||||
const providerType = config.AI_PROVIDER_EMBEDDING || 'ollama'
|
||||
const details = getProviderDetails(config, providerType)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'No embeddings returned',
|
||||
provider: providerType,
|
||||
model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest',
|
||||
details
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const providerType = config.AI_PROVIDER_EMBEDDING || 'ollama'
|
||||
const details = getProviderDetails(config, providerType)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
provider: providerType,
|
||||
model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest',
|
||||
embeddingLength: embeddings.length,
|
||||
firstValues: embeddings.slice(0, 5),
|
||||
responseTime: endTime - startTime,
|
||||
details
|
||||
})
|
||||
} catch (error: any) {
|
||||
const config = await getSystemConfig()
|
||||
const providerType = config.AI_PROVIDER_EMBEDDING || 'ollama'
|
||||
const details = getProviderDetails(config, providerType)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message || 'Unknown error',
|
||||
provider: providerType,
|
||||
model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest',
|
||||
details,
|
||||
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
49
memento-note/app/api/ai/test-tags/route.ts
Normal file
49
memento-note/app/api/ai/test-tags/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getTagsProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getTagsProvider(config)
|
||||
|
||||
const testContent = "This is a test note about artificial intelligence and machine learning. It contains keywords like AI, ML, neural networks, and deep learning."
|
||||
|
||||
const startTime = Date.now()
|
||||
const tags = await provider.generateTags(testContent)
|
||||
const endTime = Date.now()
|
||||
|
||||
if (!tags || tags.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'No tags generated',
|
||||
provider: config.AI_PROVIDER_TAGS || 'ollama',
|
||||
model: config.AI_MODEL_TAGS || 'granite4:latest'
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
provider: config.AI_PROVIDER_TAGS || 'ollama',
|
||||
model: config.AI_MODEL_TAGS || 'granite4:latest',
|
||||
tags: tags,
|
||||
responseTime: endTime - startTime
|
||||
})
|
||||
} catch (error: any) {
|
||||
const config = await getSystemConfig()
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message || 'Unknown error',
|
||||
provider: config.AI_PROVIDER_TAGS || 'ollama',
|
||||
model: config.AI_MODEL_TAGS || 'granite4:latest',
|
||||
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
89
memento-note/app/api/ai/test/route.ts
Normal file
89
memento-note/app/api/ai/test/route.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getTagsProvider, getEmbeddingsProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
function getProviderDetails(config: Record<string, string>, providerType: string) {
|
||||
const provider = providerType.toLowerCase()
|
||||
|
||||
switch (provider) {
|
||||
case 'ollama':
|
||||
return {
|
||||
provider: 'Ollama',
|
||||
baseUrl: config.OLLAMA_BASE_URL || process.env.OLLAMA_BASE_URL || 'http://localhost:11434',
|
||||
model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest'
|
||||
}
|
||||
case 'openai':
|
||||
return {
|
||||
provider: 'OpenAI',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: config.AI_MODEL_EMBEDDING || 'text-embedding-3-small'
|
||||
}
|
||||
case 'custom':
|
||||
return {
|
||||
provider: 'Custom OpenAI',
|
||||
baseUrl: config.CUSTOM_OPENAI_BASE_URL || process.env.CUSTOM_OPENAI_BASE_URL || 'Not configured',
|
||||
model: config.AI_MODEL_EMBEDDING || 'text-embedding-3-small'
|
||||
}
|
||||
default:
|
||||
return {
|
||||
provider: provider,
|
||||
baseUrl: 'unknown',
|
||||
model: config.AI_MODEL_EMBEDDING || 'unknown'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const tagsProvider = getTagsProvider(config)
|
||||
const embeddingsProvider = getEmbeddingsProvider(config)
|
||||
|
||||
const testText = 'test'
|
||||
|
||||
// Test embeddings provider
|
||||
const embeddings = await embeddingsProvider.getEmbeddings(testText)
|
||||
|
||||
if (!embeddings || embeddings.length === 0) {
|
||||
const providerType = config.AI_PROVIDER_EMBEDDING || 'ollama'
|
||||
const details = getProviderDetails(config, providerType)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
tagsProvider: config.AI_PROVIDER_TAGS || 'ollama',
|
||||
embeddingsProvider: providerType,
|
||||
error: 'No embeddings returned',
|
||||
details
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const tagsProviderType = config.AI_PROVIDER_TAGS || 'ollama'
|
||||
const embeddingsProviderType = config.AI_PROVIDER_EMBEDDING || 'ollama'
|
||||
const details = getProviderDetails(config, embeddingsProviderType)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
tagsProvider: tagsProviderType,
|
||||
embeddingsProvider: embeddingsProviderType,
|
||||
embeddingLength: embeddings.length,
|
||||
firstValues: embeddings.slice(0, 5),
|
||||
details
|
||||
})
|
||||
} catch (error: any) {
|
||||
const config = await getSystemConfig()
|
||||
const providerType = config.AI_PROVIDER_EMBEDDING || 'ollama'
|
||||
const details = getProviderDetails(config, providerType)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message || 'Unknown error',
|
||||
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined,
|
||||
details
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
107
memento-note/app/api/ai/title-suggestions/route.ts
Normal file
107
memento-note/app/api/ai/title-suggestions/route.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getAIProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { z } from 'zod'
|
||||
|
||||
const requestSchema = z.object({
|
||||
content: z.string().min(1, "Le contenu ne peut pas être vide"),
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { content } = requestSchema.parse(body)
|
||||
|
||||
// Vérifier qu'il y a au moins 10 mots
|
||||
const wordCount = content.split(/\s+/).length
|
||||
|
||||
if (wordCount < 10) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Le contenu doit avoir au moins 10 mots' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getAIProvider(config)
|
||||
|
||||
// Détecter la langue du contenu (simple détection basée sur les caractères et mots)
|
||||
const hasNonLatinChars = /[\u0400-\u04FF\u0600-\u06FF\u4E00-\u9FFF\u0E00-\u0E7F]/.test(content)
|
||||
const isPersian = /[\u0600-\u06FF]/.test(content)
|
||||
const isChinese = /[\u4E00-\u9FFF]/.test(content)
|
||||
const isRussian = /[\u0400-\u04FF]/.test(content)
|
||||
const isArabic = /[\u0600-\u06FF]/.test(content)
|
||||
|
||||
// Détection du français par des mots et caractères caractéristiques
|
||||
const frenchWords = /\b(le|la|les|un|une|des|et|ou|mais|donc|pour|dans|sur|avec|sans|très|plus|moins|tout|tous|toute|toutes|ce|cette|ces|mon|ma|mes|ton|ta|tes|son|sa|ses|notre|nos|votre|vos|leur|leurs|je|tu|il|elle|nous|vous|ils|elles|est|sont|été|être|avoir|faire|aller|venir|voir|savoir|pouvoir|vouloir|falloir|comme|que|qui|dont|où|quand|pourquoi|comment|quel|quelle|quels|quelles)\b/i
|
||||
const frenchAccents = /[éèêàâôûùïüç]/i
|
||||
const isFrench = frenchWords.test(content) || frenchAccents.test(content)
|
||||
|
||||
// Déterminer la langue du prompt système
|
||||
let promptLanguage = 'en'
|
||||
let responseLanguage = 'English'
|
||||
|
||||
if (isFrench) {
|
||||
promptLanguage = 'fr' // Français
|
||||
responseLanguage = 'French'
|
||||
} else if (isPersian) {
|
||||
promptLanguage = 'fa' // Persan
|
||||
responseLanguage = 'Persian'
|
||||
} else if (isChinese) {
|
||||
promptLanguage = 'zh' // Chinois
|
||||
responseLanguage = 'Chinese'
|
||||
} else if (isRussian) {
|
||||
promptLanguage = 'ru' // Russe
|
||||
responseLanguage = 'Russian'
|
||||
} else if (isArabic) {
|
||||
promptLanguage = 'ar' // Arabe
|
||||
responseLanguage = 'Arabic'
|
||||
}
|
||||
|
||||
// Générer des titres appropriés basés sur le contenu
|
||||
const titlePrompt = promptLanguage === 'en'
|
||||
? `You are a title generator. Generate 3 concise, descriptive titles for the following content.
|
||||
|
||||
IMPORTANT INSTRUCTIONS:
|
||||
- Use ONLY the content provided below between the CONTENT_START and CONTENT_END markers
|
||||
- Do NOT use any external knowledge or training data
|
||||
- Focus on the main topics and themes in THIS SPECIFIC content
|
||||
- Be specific to what is actually discussed
|
||||
|
||||
CONTENT_START: ${content.substring(0, 500)} CONTENT_END
|
||||
|
||||
Respond ONLY with a JSON array: [{"title": "title1", "confidence": 0.95}, {"title": "title2", "confidence": 0.85}, {"title": "title3", "confidence": 0.75}]`
|
||||
: `Tu es un générateur de titres. Génère 3 titres concis et descriptifs pour le contenu suivant en ${responseLanguage}.
|
||||
|
||||
INSTRUCTIONS IMPORTANTES :
|
||||
- Utilise SEULEMENT le contenu fourni entre les marqueurs CONTENT_START et CONTENT_END
|
||||
- N'utilise AUCUNE connaissance externe ou données d'entraînement
|
||||
- Concentre-toi sur les sujets principaux et thèmes de CE CONTENU SPÉCIFIQUE
|
||||
- Sois spécifique à ce qui est réellement discuté
|
||||
|
||||
CONTENT_START: ${content.substring(0, 500)} CONTENT_END
|
||||
|
||||
Réponds SEULEMENT avec un tableau JSON: [{"title": "titre1", "confidence": 0.95}, {"title": "titre2", "confidence": 0.85}, {"title": "titre3", "confidence": 0.75}]`
|
||||
|
||||
const titles = await provider.generateTitles(titlePrompt)
|
||||
|
||||
// Créer les suggestions
|
||||
const suggestions = titles.map((t: any) => ({
|
||||
title: t.title,
|
||||
confidence: Math.round(t.confidence * 100),
|
||||
reasoning: `Basé sur le contenu`
|
||||
}))
|
||||
|
||||
return NextResponse.json({ suggestions })
|
||||
} catch (error: any) {
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.issues }, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Erreur lors de la génération des titres' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
90
memento-note/app/api/ai/transform-markdown/route.ts
Normal file
90
memento-note/app/api/ai/transform-markdown/route.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { getAIProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { text } = await request.json()
|
||||
|
||||
// Validation
|
||||
if (!text || typeof text !== 'string') {
|
||||
return NextResponse.json({ error: 'Text is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate word count
|
||||
const wordCount = text.split(/\s+/).length
|
||||
if (wordCount < 10) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Text must have at least 10 words to transform' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (wordCount > 500) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Text must have maximum 500 words to transform' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getAIProvider(config)
|
||||
|
||||
// Detect language from text
|
||||
const hasFrench = /[àâäéèêëïîôùûüÿç]/i.test(text)
|
||||
const responseLanguage = hasFrench ? 'French' : 'English'
|
||||
|
||||
// Build prompt to transform text to Markdown
|
||||
const prompt = hasFrench
|
||||
? `Tu es un expert en Markdown. Transforme ce texte ${responseLanguage} en Markdown bien formaté.
|
||||
|
||||
IMPORTANT :
|
||||
- Ajoute des titres avec ## pour les sections principales
|
||||
- Utilise des listes à puces (-) ou numérotées (1.) quand approprié
|
||||
- Ajoute de l'emphase (gras **texte**, italique *texte*) pour les mots clés
|
||||
- Utilise des blocs de code pour le code ou les commandes
|
||||
- Présente l'information de manière claire et structurée
|
||||
- GARDE le même sens et le contenu, seul le format change
|
||||
|
||||
Texte à transformer :
|
||||
${text}
|
||||
|
||||
Réponds SEULEMENT avec le texte transformé en Markdown, sans explications.`
|
||||
: `You are a Markdown expert. Transform this ${responseLanguage} text into well-formatted Markdown.
|
||||
|
||||
IMPORTANT:
|
||||
- Add headings with ## for main sections
|
||||
- Use bullet lists (-) or numbered lists (1.) when appropriate
|
||||
- Add emphasis (bold **text**, italic *text*) for key terms
|
||||
- Use code blocks for code or commands
|
||||
- Present information clearly and structured
|
||||
- KEEP the same meaning and content, only change the format
|
||||
|
||||
Text to transform:
|
||||
${text}
|
||||
|
||||
Respond ONLY with the transformed Markdown text, no explanations.`
|
||||
|
||||
|
||||
const transformedText = await provider.generateText(prompt)
|
||||
|
||||
|
||||
return NextResponse.json({
|
||||
originalText: text,
|
||||
transformedText: transformedText,
|
||||
language: responseLanguage
|
||||
})
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to transform text to Markdown' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
30
memento-note/app/api/ai/translate/route.ts
Normal file
30
memento-note/app/api/ai/translate/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { getTagsProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { text, targetLanguage } = await request.json()
|
||||
|
||||
if (!text || !targetLanguage) {
|
||||
return NextResponse.json({ error: 'text and targetLanguage are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getTagsProvider(config)
|
||||
|
||||
const prompt = `Translate the following text to ${targetLanguage}. Return ONLY the translated text, no explanation, no preamble, no quotes:\n\n${text}`
|
||||
|
||||
const translatedText = await provider.generateText(prompt)
|
||||
|
||||
return NextResponse.json({ translatedText: translatedText.trim() })
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message || 'Translation failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
2
memento-note/app/api/auth/[...nextauth]/route.ts
Normal file
2
memento-note/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { handlers } from "@/auth"
|
||||
export const { GET, POST } = handlers
|
||||
33
memento-note/app/api/canvas/route.ts
Normal file
33
memento-note/app/api/canvas/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const body = await req.json()
|
||||
const { id, name, data } = body
|
||||
|
||||
if (id) {
|
||||
const canvas = await prisma.canvas.update({
|
||||
where: { id, userId: session.user.id },
|
||||
data: { name, data }
|
||||
})
|
||||
return NextResponse.json({ success: true, canvas })
|
||||
} else {
|
||||
const canvas = await prisma.canvas.create({
|
||||
data: {
|
||||
name,
|
||||
data,
|
||||
userId: session.user.id
|
||||
}
|
||||
})
|
||||
return NextResponse.json({ success: true, canvas })
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
380
memento-note/app/api/chat/route.ts
Normal file
380
memento-note/app/api/chat/route.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import { streamText, UIMessage } from 'ai'
|
||||
import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
|
||||
import { toolRegistry } from '@/lib/ai/tools'
|
||||
import { stepCountIs } from 'ai'
|
||||
|
||||
export const maxDuration = 60
|
||||
|
||||
/**
|
||||
* Extract text content from a UIMessage's parts array.
|
||||
*/
|
||||
function extractTextFromUIMessage(msg: { parts?: Array<{ type: string; text?: string }>; content?: string }): string {
|
||||
if (typeof msg.content === 'string') return msg.content
|
||||
if (msg.parts && Array.isArray(msg.parts)) {
|
||||
return msg.parts
|
||||
.filter((p) => p.type === 'text' && typeof p.text === 'string')
|
||||
.map((p) => p.text!)
|
||||
.join('')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an array of UIMessages (from the client) to CoreMessage[] for streamText.
|
||||
*/
|
||||
function toCoreMessages(uiMessages: UIMessage[]): Array<{ role: 'user' | 'assistant'; content: string }> {
|
||||
return uiMessages
|
||||
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
||||
.map((m) => ({
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: extractTextFromUIMessage(m),
|
||||
}))
|
||||
.filter((m) => m.content.length > 0)
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
// 1. Auth check
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
const userId = session.user.id
|
||||
|
||||
// 2. Parse request body — messages arrive as UIMessage[] from DefaultChatTransport
|
||||
const body = await req.json()
|
||||
const { messages: rawMessages, conversationId, notebookId, language, webSearch } = body as {
|
||||
messages: UIMessage[]
|
||||
conversationId?: string
|
||||
notebookId?: string
|
||||
language?: string
|
||||
webSearch?: boolean
|
||||
}
|
||||
|
||||
// Convert UIMessages to CoreMessages for streamText
|
||||
const incomingMessages = toCoreMessages(rawMessages)
|
||||
|
||||
// 3. Manage conversation (create or fetch)
|
||||
let conversation: { id: string; messages: Array<{ role: string; content: string }> }
|
||||
|
||||
if (conversationId) {
|
||||
const existing = await prisma.conversation.findUnique({
|
||||
where: { id: conversationId, userId },
|
||||
include: { messages: { orderBy: { createdAt: 'asc' } } },
|
||||
})
|
||||
if (!existing) {
|
||||
return new Response('Conversation not found', { status: 404 })
|
||||
}
|
||||
conversation = existing
|
||||
} else {
|
||||
const userMessage = incomingMessages[incomingMessages.length - 1]?.content || 'New conversation'
|
||||
const created = await prisma.conversation.create({
|
||||
data: {
|
||||
userId,
|
||||
notebookId: notebookId || null,
|
||||
title: userMessage.substring(0, 50) + (userMessage.length > 50 ? '...' : ''),
|
||||
},
|
||||
include: { messages: true },
|
||||
})
|
||||
conversation = created
|
||||
}
|
||||
|
||||
// 4. RAG retrieval
|
||||
const currentMessage = incomingMessages[incomingMessages.length - 1]?.content || ''
|
||||
|
||||
// Load translations for the requested language
|
||||
const lang = (language || 'en') as SupportedLanguage
|
||||
const translations = await loadTranslations(lang)
|
||||
const untitledText = getTranslationValue(translations, 'notes.untitled') || 'Untitled'
|
||||
|
||||
// If a notebook is selected, fetch its recent notes directly as context
|
||||
// This ensures the AI always has access to the notebook content,
|
||||
// even for vague queries like "what's in this notebook?"
|
||||
let notebookContext = ''
|
||||
if (notebookId) {
|
||||
const notebookNotes = await prisma.note.findMany({
|
||||
where: {
|
||||
notebookId,
|
||||
userId,
|
||||
trashedAt: null,
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 20,
|
||||
select: { id: true, title: true, content: true, updatedAt: true },
|
||||
})
|
||||
if (notebookNotes.length > 0) {
|
||||
notebookContext = notebookNotes
|
||||
.map(n => `NOTE [${n.title || untitledText}] (updated ${n.updatedAt.toLocaleDateString()}):\n${(n.content || '').substring(0, 1500)}`)
|
||||
.join('\n\n---\n\n')
|
||||
}
|
||||
}
|
||||
|
||||
// Also run semantic search for the specific query
|
||||
const searchResults = await semanticSearchService.search(currentMessage, {
|
||||
notebookId,
|
||||
limit: notebookId ? 10 : 5,
|
||||
threshold: notebookId ? 0.3 : 0.5,
|
||||
defaultTitle: untitledText,
|
||||
})
|
||||
|
||||
const searchNotes = searchResults
|
||||
.map((r) => `NOTE [${r.title || untitledText}]: ${r.content}`)
|
||||
.join('\n\n---\n\n')
|
||||
|
||||
// Combine: full notebook context + semantic search results (deduplicated)
|
||||
const contextNotes = [notebookContext, searchNotes].filter(Boolean).join('\n\n---\n\n')
|
||||
|
||||
// 5. System prompt synthesis with RAG context
|
||||
// Language-aware prompts to avoid forcing French responses
|
||||
// Note: lang is already declared above when loading translations
|
||||
const promptLang: Record<string, { contextWithNotes: string; contextNoNotes: string; system: string }> = {
|
||||
en: {
|
||||
contextWithNotes: `## User's notes\n\n${contextNotes}\n\nWhen using info from the notes above, cite the source note title in parentheses, e.g.: "Deployment is done via Docker (💻 Development Guide)". Don't copy word for word — rephrase. If the notes don't cover the topic, say so and supplement with your general knowledge.`,
|
||||
contextNoNotes: "No relevant notes found for this question. Answer with your general knowledge.",
|
||||
system: `You are the AI assistant of Memento. The user asks you questions about their projects, technical docs, and notes. You must respond in a structured and helpful way.
|
||||
|
||||
## Format rules
|
||||
- Use markdown freely: headings (##, ###), lists, code blocks, bold, tables — anything that makes the response readable.
|
||||
- Structure your response with sections for technical questions or complex topics.
|
||||
- For simple, short questions, a direct paragraph is enough.
|
||||
|
||||
## Tone rules
|
||||
- Natural tone, neither corporate nor too casual.
|
||||
- No unnecessary intro phrases ("Here's what I found", "Based on your notes"). Answer directly.
|
||||
- No upsell questions at the end ("Would you like me to...", "Do you want..."). If you have useful additional info, just give it.
|
||||
- If the user says "Momento" they mean Memento (this app).
|
||||
|
||||
## Available tools
|
||||
You have access to these tools for deeper research:
|
||||
- **note_search**: Search the user's notes by keyword or meaning. Use when the initial context above is insufficient or when the user asks about specific content in their notes. If a notebook is selected, pass its ID to restrict results.
|
||||
- **note_read**: Read a specific note by ID. Use when note_search returns a note you need the full content of.
|
||||
- **web_search**: Search the web for information. Use when the user asks about something not in their notes.
|
||||
- **web_scrape**: Scrape a web page and return its content as markdown. Use when web_search returns a URL you need to read.
|
||||
|
||||
## Tool usage rules
|
||||
- You already have context from the user's notes above. Only use tools if you need more specific or additional information.
|
||||
- Never invent note IDs, URLs, or notebook IDs. Use the IDs provided in the context or from tool results.
|
||||
- For simple conversational questions (greetings, opinions, general knowledge), answer directly without using any tools.`,
|
||||
},
|
||||
fr: {
|
||||
contextWithNotes: `## Notes de l'utilisateur\n\n${contextNotes}\n\nQuand tu utilises une info venant des notes ci-dessus, cite le titre de la note source entre parenthèses, ex: "Le déploiement se fait via Docker (💻 Development Guide)". Ne recopie pas mot pour mot — reformule. Si les notes ne couvrent pas le sujet, dis-le et complète avec tes connaissances générales.`,
|
||||
contextNoNotes: "Aucune note pertinente trouvée pour cette question. Réponds avec tes connaissances générales.",
|
||||
system: `Tu es l'assistant IA de Memento. L'utilisateur te pose des questions sur ses projets, sa doc technique, ses notes. Tu dois répondre de façon structurée et utile.
|
||||
|
||||
## Règles de format
|
||||
- Utilise le markdown librement : titres (##, ###), listes, code blocks, gras, tables — tout ce qui rend la réponse lisible.
|
||||
- Structure ta réponse avec des sections quand c'est une question technique ou un sujet complexe.
|
||||
- Pour les questions simples et courtes, un paragraphe direct suffit.
|
||||
|
||||
## Règles de ton
|
||||
- Ton naturel, ni corporate ni trop familier.
|
||||
- Pas de phrase d'intro inutile ("Voici ce que j'ai trouvé", "Basé sur vos notes"). Réponds directement.
|
||||
- Pas de question upsell à la fin ("Souhaitez-vous que je...", "Acceptez-vous que..."). Si tu as une info complémentaire utile, donne-la.
|
||||
- Si l'utilisateur dit "Momento" il parle de Memento (cette application).
|
||||
|
||||
## Outils disponibles
|
||||
Tu as accès à ces outils pour des recherches approfondies :
|
||||
- **note_search** : Cherche dans les notes de l'utilisateur par mot-clé ou sens. Utilise quand le contexte initial ci-dessus est insuffisant ou quand l'utilisateur demande du contenu spécifique dans ses notes. Si un carnet est sélectionné, passe son ID pour restreindre les résultats.
|
||||
- **note_read** : Lit une note spécifique par son ID. Utilise quand note_search retourne une note dont tu as besoin du contenu complet.
|
||||
- **web_search** : Recherche sur le web. Utilise quand l'utilisateur demande quelque chose qui n'est pas dans ses notes.
|
||||
- **web_scrape** : Scrape une page web et retourne son contenu en markdown. Utilise quand web_search retourne une URL que tu veux lire.
|
||||
|
||||
## Règles d'utilisation des outils
|
||||
- Tu as déjà du contexte des notes de l'utilisateur ci-dessus. Utilise les outils seulement si tu as besoin d'informations plus spécifiques.
|
||||
- N'invente jamais d'IDs de notes, d'URLs ou d'IDs de carnet. Utilise les IDs fournis dans le contexte ou les résultats d'outils.
|
||||
- Pour les questions conversationnelles simples (salutations, opinions, connaissances générales), réponds directement sans utiliser d'outils.`,
|
||||
},
|
||||
fa: {
|
||||
contextWithNotes: `## یادداشتهای کاربر\n\n${contextNotes}\n\nهنگام استفاده از اطلاعات یادداشتهای بالا، عنوان یادداشت منبع را در پرانتز ذکر کنید. کپی نکنید — بازنویسی کنید. اگر یادداشتها موضوع را پوشش نمیدهند، بگویید و با دانش عمومی خود تکمیل کنید.`,
|
||||
contextNoNotes: "هیچ یادداشت مرتبطی برای این سؤال یافت نشد. با دانش عمومی خود پاسخ دهید.",
|
||||
system: `شما دستیار هوش مصنوعی Memento هستید. کاربر از شما درباره پروژهها، مستندات فنی و یادداشتهایش سؤال میکند. باید به شکلی ساختاریافته و مفید پاسخ دهید.
|
||||
|
||||
## قوانین قالببندی
|
||||
- از مارکداون آزادانه استفاده کنید: عناوین (##, ###)، لیستها، بلوکهای کد، پررنگ، جداول.
|
||||
- برای سؤالات فنی یا موضوعات پیچیده، پاسخ خود را بخشبندی کنید.
|
||||
- برای سؤالات ساده و کوتاه، یک پاراگراف مستقیم کافی است.
|
||||
|
||||
## قوانین لحن
|
||||
- لحن طبیعی، نه رسمی بیش از حد و نه خیلی غیررسمی.
|
||||
- بدون جمله مقدمه اضافی. مستقیم پاسخ دهید.
|
||||
- بدون سؤال فروشی در انتها. اگر اطلاعات تکمیلی مفید دارید، مستقیم بدهید.
|
||||
- اگر کاربر "Momento" میگوید، منظورش Memento (این برنامه) است.
|
||||
|
||||
## ابزارهای موجود
|
||||
- **note_search**: جستجو در یادداشتهای کاربر با کلیدواژه یا معنی. زمانی استفاده کنید که زمینه اولیه کافی نباشد. اگر دفترچه انتخاب شده، شناسه آن را ارسال کنید.
|
||||
- **note_read**: خواندن یک یادداشت خاص با شناسه. زمانی استفاده کنید که note_search یادداشتی برگرداند که محتوای کامل آن را نیاز دارید.
|
||||
- **web_search**: جستجو در وب. زمانی استفاده کنید که کاربر درباره چیزی خارج از یادداشتهایش میپرسد.
|
||||
- **web_scrape**: استخراج محتوای صفحه وب. زمانی استفاده کنید که web_search نشانیای برگرداند که میخواهید بخوانید.
|
||||
|
||||
## قوانین استفاده از ابزارها
|
||||
- شما از قبل زمینهای از یادداشتهای کاربر دارید. فقط در صورت نیاز به اطلاعات بیشتر از ابزارها استفاده کنید.
|
||||
- هرگز شناسه یادداشت، نشانی یا شناسه دفترچه نسازید. از شناسههای موجود در زمینه یا نتایج ابزار استفاده کنید.
|
||||
- برای سؤالات مکالمهای ساده (سلام، نظرات، دانش عمومی)، مستقیم پاسخ دهید.`,
|
||||
},
|
||||
es: {
|
||||
contextWithNotes: `## Notas del usuario\n\n${contextNotes}\n\nCuando uses información de las notas anteriores, cita el título de la nota fuente entre paréntesis. No copies palabra por palabra — reformula. Si las notas no cubren el tema, dilo y complementa con tu conocimiento general.`,
|
||||
contextNoNotes: "No se encontraron notas relevantes para esta pregunta. Responde con tu conocimiento general.",
|
||||
system: `Eres el asistente de IA de Memento. El usuario te hace preguntas sobre sus proyectos, documentación técnica y notas. Debes responder de forma estructurada y útil.
|
||||
|
||||
## Reglas de formato
|
||||
- Usa markdown libremente: títulos (##, ###), listas, bloques de código, negritas, tablas.
|
||||
- Estructura tu respuesta con secciones para preguntas técnicas o temas complejos.
|
||||
- Para preguntas simples y cortas, un párrafo directo es suficiente.
|
||||
|
||||
## Reglas de tono
|
||||
- Tono natural, ni corporativo ni demasiado informal.
|
||||
- Sin frases de introducción innecesarias. Responde directamente.
|
||||
- Sin preguntas de venta al final. Si tienes información complementaria útil, dala directamente.
|
||||
|
||||
## Herramientas disponibles
|
||||
- **note_search**: Busca en las notas del usuario por palabra clave o significado. Úsalo cuando el contexto inicial sea insuficiente. Si hay una libreta seleccionada, pasa su ID para restringir los resultados.
|
||||
- **note_read**: Lee una nota específica por su ID. Úsalo cuando note_search devuelva una nota cuyo contenido completo necesites.
|
||||
- **web_search**: Busca en la web. Úsalo cuando el usuario pregunte sobre algo que no está en sus notas.
|
||||
- **web_scrape**: Extrae el contenido de una página web como markdown. Úsalo cuando web_search devuelva una URL que quieras leer.
|
||||
|
||||
## Reglas de uso de herramientas
|
||||
- Ya tienes contexto de las notas del usuario arriba. Solo usa herramientas si necesitas información más específica.
|
||||
- Nunca inventes IDs de notas, URLs o IDs de libreta. Usa los IDs proporcionados en el contexto o en los resultados de herramientas.
|
||||
- Para preguntas conversacionales simples (saludos, opiniones, conocimiento general), responde directamente sin herramientas.`,
|
||||
},
|
||||
de: {
|
||||
contextWithNotes: `## Notizen des Benutzers\n\n${contextNotes}\n\nWenn du Infos aus den obigen Notizen verwendest, zitiere den Titel der Quellnotiz in Klammern. Nicht Wort für Wort kopieren — umformulieren. Wenn die Notizen das Thema nicht abdecken, sag es und ergänze mit deinem Allgemeinwissen.`,
|
||||
contextNoNotes: "Keine relevanten Notizen für diese Frage gefunden. Antworte mit deinem Allgemeinwissen.",
|
||||
system: `Du bist der KI-Assistent von Memento. Der Benutzer stellt dir Fragen zu seinen Projekten, technischen Dokumentationen und Notizen. Du musst strukturiert und hilfreich antworten.
|
||||
|
||||
## Formatregeln
|
||||
- Verwende Markdown frei: Überschriften (##, ###), Listen, Code-Blöcke, Fettdruck, Tabellen.
|
||||
- Strukturiere deine Antwort mit Abschnitten bei technischen Fragen oder komplexen Themen.
|
||||
- Bei einfachen, kurzen Fragen reicht ein direkter Absatz.
|
||||
|
||||
## Tonregeln
|
||||
- Natürlicher Ton, weder zu geschäftsmäßig noch zu umgangssprachlich.
|
||||
- Keine unnötigen Einleitungssätze. Antworte direkt.
|
||||
- Keine Upsell-Fragen am Ende. Gib nützliche Zusatzinfos einfach direkt.
|
||||
|
||||
## Verfügbare Werkzeuge
|
||||
- **note_search**: Durchsuche die Notizen des Benutzers nach Schlagwort oder Bedeutung. Verwende es, wenn der obige Kontext unzureichend ist. Wenn ein Notizbuch ausgewählt ist, gib dessen ID an, um die Ergebnisse einzuschränken.
|
||||
- **note_read**: Lese eine bestimmte Notiz anhand ihrer ID. Verwende es, wenn note_search eine Notiz zurückgibt, deren vollständigen Inhalt du benötigst.
|
||||
- **web_search**: Suche im Web. Verwende es, wenn der Benutzer nach etwas fragt, das nicht in seinen Notizen steht.
|
||||
- **web_scrape**: Lese eine Webseite und gib den Inhalt als Markdown zurück. Verwende es, wenn web_search eine URL zurückgibt, die du lesen möchtest.
|
||||
|
||||
## Werkzeugregeln
|
||||
- Du hast bereits Kontext aus den Notizen des Benutzers oben. Verwende Werkzeuge nur, wenn du spezifischere Informationen benötigst.
|
||||
- Erfinde niemals Notiz-IDs, URLs oder Notizbuch-IDs. Verwende die im Kontext oder in Werkzeugergebnissen bereitgestellten IDs.
|
||||
- Bei einfachen Gesprächsfragen (Begrüßungen, Meinungen, Allgemeinwissen) antworte direkt ohne Werkzeuge.`,
|
||||
},
|
||||
it: {
|
||||
contextWithNotes: `## Note dell'utente\n\n${contextNotes}\n\nQuando usi informazioni dalle note sopra, cita il titolo della nota fonte tra parentesi. Non copiare parola per parola — riformula. Se le note non coprono l'argomento, dillo e integra con la tua conoscenza generale.`,
|
||||
contextNoNotes: "Nessuna nota rilevante trovata per questa domanda. Rispondi con la tua conoscenza generale.",
|
||||
system: `Sei l'assistente IA di Memento. L'utente ti fa domande sui suoi progetti, documentazione tecnica e note. Devi rispondere in modo strutturato e utile.
|
||||
|
||||
## Regole di formato
|
||||
- Usa markdown liberamente: titoli (##, ###), elenchi, blocchi di codice, grassetto, tabelle.
|
||||
- Struttura la risposta con sezioni per domande tecniche o argomenti complessi.
|
||||
- Per domande semplici e brevi, un paragrafo diretto basta.
|
||||
|
||||
## Regole di tono
|
||||
- Tono naturale, né aziendale né troppo informale.
|
||||
- Nessuna frase introduttiva non necessaria. Rispondi direttamente.
|
||||
- Nessuna domanda di upsell alla fine. Se hai informazioni aggiuntive utili, dalle direttamente.
|
||||
|
||||
## Strumenti disponibili
|
||||
- **note_search**: Cerca nelle note dell'utente per parola chiave o significato. Usa quando il contesto iniziale è insufficiente. Se un quaderno è selezionato, passa il suo ID per restringere i risultati.
|
||||
- **note_read**: Leggi una nota specifica per ID. Usa quando note_search restituisce una nota di cui hai bisogno del contenuto completo.
|
||||
- **web_search**: Cerca sul web. Usa quando l'utente chiede qualcosa che non è nelle sue note.
|
||||
- **web_scrape**: Estrai il contenuto di una pagina web come markdown. Usa quando web_search restituisce un URL che vuoi leggere.
|
||||
|
||||
## Regole di utilizzo degli strumenti
|
||||
- Hai già contesto dalle note dell'utente sopra. Usa gli strumenti solo se hai bisogno di informazioni più specifiche.
|
||||
- Non inventare mai ID di note, URL o ID di quaderno. Usa gli ID forniti nel contesto o nei risultati degli strumenti.
|
||||
- Per domande conversazionali semplici (saluti, opinioni, conoscenza generale), rispondi direttamente senza strumenti.`,
|
||||
},
|
||||
}
|
||||
|
||||
// Fallback to English if language not supported
|
||||
const prompts = promptLang[lang] || promptLang.en
|
||||
const contextBlock = contextNotes.length > 0
|
||||
? prompts.contextWithNotes
|
||||
: prompts.contextNoNotes
|
||||
|
||||
const systemPrompt = `${prompts.system}
|
||||
|
||||
${contextBlock}
|
||||
|
||||
${lang === 'en' ? 'Respond in the user\'s language.' : lang === 'fr' ? 'Réponds dans la langue de l\'utilisateur.' : 'Respond in the user\'s language.'}`
|
||||
|
||||
// 6. Build message history from DB + current messages
|
||||
const dbHistory = conversation.messages.map((m: { role: string; content: string }) => ({
|
||||
role: m.role as 'user' | 'assistant' | 'system',
|
||||
content: m.content,
|
||||
}))
|
||||
|
||||
// Only add the current user message if it's not already in DB history
|
||||
const lastIncoming = incomingMessages[incomingMessages.length - 1]
|
||||
const currentDbMessage = dbHistory[dbHistory.length - 1]
|
||||
const isNewMessage =
|
||||
lastIncoming &&
|
||||
(!currentDbMessage ||
|
||||
currentDbMessage.role !== 'user' ||
|
||||
currentDbMessage.content !== lastIncoming.content)
|
||||
|
||||
const allMessages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }> = isNewMessage
|
||||
? [...dbHistory, { role: lastIncoming.role, content: lastIncoming.content }]
|
||||
: dbHistory
|
||||
|
||||
// 7. Get chat provider model
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
const model = provider.getModel()
|
||||
|
||||
// 7b. Build chat tools
|
||||
const chatToolContext = {
|
||||
userId,
|
||||
conversationId: conversation.id,
|
||||
notebookId,
|
||||
webSearch: !!webSearch,
|
||||
config,
|
||||
}
|
||||
const chatTools = toolRegistry.buildToolsForChat(chatToolContext)
|
||||
|
||||
// 8. Save user message to DB before streaming
|
||||
if (isNewMessage && lastIncoming) {
|
||||
await prisma.chatMessage.create({
|
||||
data: {
|
||||
conversationId: conversation.id,
|
||||
role: 'user',
|
||||
content: lastIncoming.content,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 9. Stream response
|
||||
const result = streamText({
|
||||
model,
|
||||
system: systemPrompt,
|
||||
messages: allMessages,
|
||||
tools: chatTools,
|
||||
stopWhen: stepCountIs(5),
|
||||
async onFinish({ text }) {
|
||||
// Save assistant message to DB after streaming completes
|
||||
await prisma.chatMessage.create({
|
||||
data: {
|
||||
conversationId: conversation.id,
|
||||
role: 'assistant',
|
||||
content: text,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// 10. Return streaming response with conversation ID header
|
||||
return result.toUIMessageStreamResponse({
|
||||
headers: {
|
||||
'X-Conversation-Id': conversation.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
63
memento-note/app/api/cron/reminders/route.ts
Normal file
63
memento-note/app/api/cron/reminders/route.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
export const dynamic = 'force-dynamic'; // No caching
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const now = new Date();
|
||||
|
||||
// 1. Find all due reminders that haven't been processed
|
||||
const dueNotes = await prisma.note.findMany({
|
||||
where: {
|
||||
reminder: {
|
||||
lte: now, // Less than or equal to now
|
||||
},
|
||||
isReminderDone: false,
|
||||
isArchived: false, // Optional: exclude archived notes
|
||||
trashedAt: null, // Exclude trashed notes
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
reminder: true,
|
||||
// Add other fields useful for notification
|
||||
},
|
||||
});
|
||||
|
||||
if (dueNotes.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
count: 0,
|
||||
message: 'No due reminders found'
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Mark them as done (Atomic operation logic would be better but simple batch update is fine here)
|
||||
const noteIds = dueNotes.map((n: any) => n.id);
|
||||
|
||||
await prisma.note.updateMany({
|
||||
where: {
|
||||
id: { in: noteIds }
|
||||
},
|
||||
data: {
|
||||
isReminderDone: true
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Return the notes to N8N so it can send emails/messages
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
count: dueNotes.length,
|
||||
reminders: dueNotes
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing cron reminders:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
32
memento-note/app/api/debug/config/route.ts
Normal file
32
memento-note/app/api/debug/config/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getSystemConfig } from '@/lib/config';
|
||||
|
||||
/**
|
||||
* Debug endpoint to check AI configuration
|
||||
* This helps verify that OpenAI is properly configured
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const config = await getSystemConfig();
|
||||
|
||||
// Return only AI-related config for debugging
|
||||
const aiConfig = {
|
||||
AI_PROVIDER_TAGS: config.AI_PROVIDER_TAGS || 'not set',
|
||||
AI_PROVIDER_EMBEDDING: config.AI_PROVIDER_EMBEDDING || 'not set',
|
||||
AI_MODEL_TAGS: config.AI_MODEL_TAGS || 'not set',
|
||||
AI_MODEL_EMBEDDING: config.AI_MODEL_EMBEDDING || 'not set',
|
||||
OPENAI_API_KEY: config.OPENAI_API_KEY ? 'set (hidden)' : 'not set',
|
||||
OLLAMA_BASE_URL: config.OLLAMA_BASE_URL || 'not set',
|
||||
OLLAMA_MODEL: config.OLLAMA_MODEL || 'not set',
|
||||
CUSTOM_OPENAI_BASE_URL: config.CUSTOM_OPENAI_BASE_URL || 'not set',
|
||||
CUSTOM_OPENAI_API_KEY: config.CUSTOM_OPENAI_API_KEY ? 'set (hidden)' : 'not set',
|
||||
};
|
||||
|
||||
return NextResponse.json(aiConfig);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get config', details: error },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
29
memento-note/app/api/debug/test-chat/route.ts
Normal file
29
memento-note/app/api/debug/test-chat/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { chatService } from '@/lib/ai/services/chat.service';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
console.log("TEST ROUTE INCOMING BODY:", body);
|
||||
|
||||
// Simulate what the server action does
|
||||
const result = await chatService.chat(body.message, body.conversationId, body.notebookId);
|
||||
|
||||
return NextResponse.json({ success: true, result });
|
||||
} catch (err: any) {
|
||||
console.error("====== TEST ROUTE CHAT ERROR ======");
|
||||
console.error("NAME:", err.name);
|
||||
console.error("MSG:", err.message);
|
||||
if (err.cause) console.error("CAUSE:", JSON.stringify(err.cause, null, 2));
|
||||
if (err.data) console.error("DATA:", JSON.stringify(err.data, null, 2));
|
||||
if (err.stack) console.error("STACK:", err.stack);
|
||||
console.error("===================================");
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
name: err.name,
|
||||
cause: err.cause,
|
||||
data: err.data
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
118
memento-note/app/api/fix-labels/route.ts
Normal file
118
memento-note/app/api/fix-labels/route.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
function getHashColor(name: string): string {
|
||||
const colors = ['red', 'blue', 'green', 'yellow', 'purple', 'pink', 'orange', 'gray']
|
||||
let hash = 0
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
return colors[Math.abs(hash) % colors.length]
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const result = { created: 0, deleted: 0, missing: [] as string[] }
|
||||
|
||||
// Get ALL users
|
||||
const users = await prisma.user.findMany({
|
||||
select: { id: true, email: true }
|
||||
})
|
||||
|
||||
|
||||
for (const user of users) {
|
||||
const userId = user.id
|
||||
|
||||
// 1. Get all labels from notes
|
||||
const allNotes = await prisma.note.findMany({
|
||||
where: { userId },
|
||||
select: { labels: true }
|
||||
})
|
||||
|
||||
const labelsInNotes = new Set<string>()
|
||||
allNotes.forEach(note => {
|
||||
if (note.labels) {
|
||||
try {
|
||||
const parsed: string[] = Array.isArray(note.labels) ? (note.labels as string[]) : []
|
||||
if (Array.isArray(parsed)) {
|
||||
parsed.forEach(l => {
|
||||
if (l && l.trim()) labelsInNotes.add(l.trim())
|
||||
})
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// 2. Get existing Label records
|
||||
const existingLabels = await prisma.label.findMany({
|
||||
where: { userId },
|
||||
select: { id: true, name: true }
|
||||
})
|
||||
|
||||
|
||||
const existingLabelMap = new Map<string, any>()
|
||||
existingLabels.forEach(label => {
|
||||
existingLabelMap.set(label.name.toLowerCase(), label)
|
||||
})
|
||||
|
||||
// 3. Create missing Label records
|
||||
for (const labelName of labelsInNotes) {
|
||||
if (!existingLabelMap.has(labelName.toLowerCase())) {
|
||||
try {
|
||||
await prisma.label.create({
|
||||
data: {
|
||||
userId,
|
||||
name: labelName,
|
||||
color: getHashColor(labelName)
|
||||
}
|
||||
})
|
||||
result.created++
|
||||
} catch (e: any) {
|
||||
console.error(`[FIX] ✗ Failed to create "${labelName}":`, e.message, e.code)
|
||||
result.missing.push(labelName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Delete orphan Label records
|
||||
const usedLabelsSet = new Set<string>()
|
||||
allNotes.forEach(note => {
|
||||
if (note.labels) {
|
||||
try {
|
||||
const parsed: string[] = Array.isArray(note.labels) ? (note.labels as string[]) : []
|
||||
if (Array.isArray(parsed)) {
|
||||
parsed.forEach(l => usedLabelsSet.add(l.toLowerCase()))
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
})
|
||||
|
||||
for (const label of existingLabels) {
|
||||
if (!usedLabelsSet.has(label.name.toLowerCase())) {
|
||||
try {
|
||||
await prisma.label.delete({
|
||||
where: { id: label.id }
|
||||
})
|
||||
result.deleted++
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...result,
|
||||
message: `Created ${result.created} labels, deleted ${result.deleted} orphans`
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[FIX] Error:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: String(error) },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
252
memento-note/app/api/labels/[id]/route.ts
Normal file
252
memento-note/app/api/labels/[id]/route.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
// GET /api/labels/[id] - Get a specific label
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
const label = await prisma.label.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
notebook: {
|
||||
select: { id: true, name: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!label) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Label not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if (label.notebookId) {
|
||||
const notebook = await prisma.notebook.findUnique({
|
||||
where: { id: label.notebookId },
|
||||
select: { userId: true }
|
||||
})
|
||||
if (notebook?.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
} else if (label.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: label
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching label:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch label' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/labels/[id] - Update a label
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
const { name, color } = body
|
||||
|
||||
// Get the current label first
|
||||
const currentLabel = await prisma.label.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
notebook: {
|
||||
select: { id: true, userId: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!currentLabel) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Label not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if (currentLabel.notebookId) {
|
||||
if (currentLabel.notebook?.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
} else if (currentLabel.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const newName = name ? name.trim() : currentLabel.name
|
||||
|
||||
// For backward compatibility, update old label field in notes if renaming
|
||||
const targetUserIdPut = currentLabel.userId || currentLabel.notebook?.userId || session.user.id;
|
||||
if (name && name.trim() !== currentLabel.name && targetUserIdPut) {
|
||||
const allNotes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: targetUserIdPut,
|
||||
labels: { not: null }
|
||||
},
|
||||
select: { id: true, labels: true }
|
||||
})
|
||||
|
||||
for (const note of allNotes) {
|
||||
if (note.labels) {
|
||||
try {
|
||||
const noteLabels: string[] = Array.isArray(note.labels) ? (note.labels as string[]) : []
|
||||
const updatedLabels = noteLabels.map(l =>
|
||||
l.toLowerCase() === currentLabel.name.toLowerCase() ? newName : l
|
||||
)
|
||||
|
||||
if (JSON.stringify(updatedLabels) !== JSON.stringify(noteLabels)) {
|
||||
await prisma.note.update({
|
||||
where: { id: note.id },
|
||||
data: {
|
||||
labels: updatedLabels as any
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now update the label record
|
||||
const label = await prisma.label.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(name && { name: newName }),
|
||||
...(color && { color })
|
||||
}
|
||||
})
|
||||
|
||||
// Revalidate to refresh UI
|
||||
revalidatePath('/')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: label
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating label:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to update label' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/labels/[id] - Delete a label
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
// First, get the label to know its name and userId
|
||||
const label = await prisma.label.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
notebook: {
|
||||
select: { id: true, userId: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!label) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Label not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if (label.notebookId) {
|
||||
if (label.notebook?.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
} else if (label.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
// For backward compatibility, remove from old label field in notes
|
||||
const targetUserIdDel = label.userId || label.notebook?.userId || session.user.id;
|
||||
if (targetUserIdDel) {
|
||||
const allNotes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: targetUserIdDel,
|
||||
labels: { not: null }
|
||||
},
|
||||
select: { id: true, labels: true }
|
||||
})
|
||||
|
||||
for (const note of allNotes) {
|
||||
if (note.labels) {
|
||||
try {
|
||||
const noteLabels: string[] = Array.isArray(note.labels) ? (note.labels as string[]) : []
|
||||
const filteredLabels = noteLabels.filter(
|
||||
l => l.toLowerCase() !== label.name.toLowerCase()
|
||||
)
|
||||
|
||||
if (filteredLabels.length !== noteLabels.length) {
|
||||
await prisma.note.update({
|
||||
where: { id: note.id },
|
||||
data: {
|
||||
labels: (filteredLabels.length > 0 ? filteredLabels : null) as any
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now delete the label record
|
||||
await prisma.label.delete({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
// Revalidate to refresh UI
|
||||
revalidatePath('/')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Label "${label.name}" deleted successfully`
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error deleting label:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete label' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
131
memento-note/app/api/labels/route.ts
Normal file
131
memento-note/app/api/labels/route.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
const COLORS = ['red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray'];
|
||||
|
||||
// GET /api/labels - Get all labels (supports optional notebookId filter)
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const notebookId = searchParams.get('notebookId')
|
||||
|
||||
// Build where clause
|
||||
const where: any = {}
|
||||
|
||||
if (notebookId === 'null' || notebookId === '') {
|
||||
// Get labels without a notebook (backward compatibility)
|
||||
where.notebookId = null
|
||||
} else if (notebookId) {
|
||||
// Get labels for a specific notebook
|
||||
where.notebookId = notebookId
|
||||
} else {
|
||||
// Get all labels for the user (both old and new system)
|
||||
where.OR = [
|
||||
{ notebookId: { not: null } },
|
||||
{ userId: session.user.id }
|
||||
]
|
||||
}
|
||||
|
||||
const labels = await prisma.label.findMany({
|
||||
where,
|
||||
include: {
|
||||
notebook: {
|
||||
select: { id: true, name: true }
|
||||
}
|
||||
},
|
||||
orderBy: { name: 'asc' }
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: labels
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching labels:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch labels' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/labels - Create a new label
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { name, color, notebookId } = body
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Label name is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!notebookId || typeof notebookId !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'notebookId is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify notebook ownership
|
||||
const notebook = await prisma.notebook.findUnique({
|
||||
where: { id: notebookId },
|
||||
select: { userId: true }
|
||||
})
|
||||
|
||||
if (!notebook || notebook.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Notebook not found or unauthorized' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if label already exists in this notebook
|
||||
const existing = await prisma.label.findFirst({
|
||||
where: {
|
||||
name: name.trim(),
|
||||
notebookId: notebookId
|
||||
}
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Label already exists in this notebook' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const label = await prisma.label.create({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
color: color || COLORS[Math.floor(Math.random() * COLORS.length)],
|
||||
notebookId: notebookId,
|
||||
userId: session.user.id // Keep for backward compatibility
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: label
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error creating label:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to create label' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
135
memento-note/app/api/notebooks/[id]/route.ts
Normal file
135
memento-note/app/api/notebooks/[id]/route.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
// PATCH /api/notebooks/[id] - Update a notebook
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
const { name, icon, color, order } = body
|
||||
|
||||
// Verify ownership
|
||||
const existing = await prisma.notebook.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true }
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Notebook not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (existing.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Forbidden' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Build update data
|
||||
const updateData: any = {}
|
||||
if (name !== undefined) updateData.name = name.trim()
|
||||
if (icon !== undefined) updateData.icon = icon
|
||||
if (color !== undefined) updateData.color = color
|
||||
if (order !== undefined) updateData.order = order
|
||||
|
||||
// Update notebook
|
||||
const notebook = await prisma.notebook.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
labels: true,
|
||||
_count: {
|
||||
select: { notes: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
revalidatePath('/')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...notebook,
|
||||
notesCount: notebook._count.notes
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating notebook:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to update notebook' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/notebooks/[id] - Delete a notebook
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
// Verify ownership and get notebook info
|
||||
const notebook = await prisma.notebook.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
userId: true,
|
||||
name: true,
|
||||
_count: {
|
||||
select: { notes: true, labels: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!notebook) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Notebook not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (notebook.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Forbidden' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Delete notebook (cascade will handle labels and notes)
|
||||
await prisma.notebook.delete({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
revalidatePath('/')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Notebook "${notebook.name}" deleted`,
|
||||
notesCount: notebook._count.notes,
|
||||
labelsCount: notebook._count.labels
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error deleting notebook:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete notebook' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
62
memento-note/app/api/notebooks/reorder/route.ts
Normal file
62
memento-note/app/api/notebooks/reorder/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
// POST /api/notebooks/reorder - Reorder notebooks
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { notebookIds } = body
|
||||
|
||||
if (!Array.isArray(notebookIds)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'notebookIds must be an array' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify all notebooks belong to the user
|
||||
const notebooks = await prisma.notebook.findMany({
|
||||
where: {
|
||||
id: { in: notebookIds },
|
||||
userId: session.user.id
|
||||
},
|
||||
select: { id: true }
|
||||
})
|
||||
|
||||
if (notebooks.length !== notebookIds.length) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'One or more notebooks not found or unauthorized' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Update order for each notebook
|
||||
const updates = notebookIds.map((id, index) =>
|
||||
prisma.notebook.update({
|
||||
where: { id },
|
||||
data: { order: index }
|
||||
})
|
||||
)
|
||||
|
||||
await prisma.$transaction(updates)
|
||||
|
||||
revalidatePath('/')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Notebooks reordered successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to reorder notebooks' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
104
memento-note/app/api/notebooks/route.ts
Normal file
104
memento-note/app/api/notebooks/route.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
const DEFAULT_COLORS = ['#3B82F6', '#8B5CF6', '#EC4899', '#F59E0B', '#10B981', '#06B6D4']
|
||||
const DEFAULT_ICONS = ['📁', '📚', '💼', '🎯', '📊', '🎨', '💡', '🔧']
|
||||
|
||||
// GET /api/notebooks - Get all notebooks for current user
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const notebooks = await prisma.notebook.findMany({
|
||||
where: { userId: session.user.id },
|
||||
include: {
|
||||
labels: {
|
||||
orderBy: { name: 'asc' }
|
||||
},
|
||||
_count: {
|
||||
select: { notes: { where: { isArchived: false } } }
|
||||
}
|
||||
},
|
||||
orderBy: { order: 'asc' }
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
notebooks: notebooks.map(nb => ({
|
||||
...nb,
|
||||
notesCount: nb._count.notes
|
||||
}))
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching notebooks:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch notebooks' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/notebooks - Create a new notebook
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { name, icon, color } = body
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Notebook name is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get the highest order value for this user
|
||||
const highestOrder = await prisma.notebook.findFirst({
|
||||
where: { userId: session.user.id },
|
||||
orderBy: { order: 'desc' },
|
||||
select: { order: true }
|
||||
})
|
||||
|
||||
const nextOrder = (highestOrder?.order ?? -1) + 1
|
||||
|
||||
// Create notebook
|
||||
const notebook = await prisma.notebook.create({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
icon: icon || DEFAULT_ICONS[Math.floor(Math.random() * DEFAULT_ICONS.length)],
|
||||
color: color || DEFAULT_COLORS[Math.floor(Math.random() * DEFAULT_COLORS.length)],
|
||||
order: nextOrder,
|
||||
userId: session.user.id
|
||||
},
|
||||
include: {
|
||||
labels: true,
|
||||
_count: {
|
||||
select: { notes: { where: { isArchived: false } } }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
revalidatePath('/')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...notebook,
|
||||
notesCount: notebook._count.notes
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error creating notebook:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to create notebook' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
96
memento-note/app/api/notes/[id]/move/route.ts
Normal file
96
memento-note/app/api/notes/[id]/move/route.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { reconcileLabelsAfterNoteMove } from '@/app/actions/notes'
|
||||
|
||||
// POST /api/notes/[id]/move - Move a note to a notebook (or to Inbox)
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
const { notebookId } = body
|
||||
|
||||
// Get the note
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
notebookId: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if (note.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Forbidden' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// If notebookId is provided, verify it exists and belongs to the user
|
||||
if (notebookId !== null && notebookId !== '') {
|
||||
const notebook = await prisma.notebook.findUnique({
|
||||
where: { id: notebookId },
|
||||
select: { userId: true }
|
||||
})
|
||||
|
||||
if (!notebook || notebook.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Notebook not found or unauthorized' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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: targetNotebookId
|
||||
},
|
||||
include: {
|
||||
notebook: {
|
||||
select: { id: true, name: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await reconcileLabelsAfterNoteMove(id, targetNotebookId)
|
||||
|
||||
// No revalidatePath('/') here — the client-side triggerRefresh() in
|
||||
// notebooks-context.tsx handles the refresh. Avoiding server-side
|
||||
// revalidation prevents a double-refresh (server + client).
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: updatedNote,
|
||||
message: notebookId && notebookId !== ''
|
||||
? `Note moved to "${updatedNote.notebook?.name || 'notebook'}"`
|
||||
: 'Note moved to Inbox'
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to move note' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
193
memento-note/app/api/notes/[id]/route.ts
Normal file
193
memento-note/app/api/notes/[id]/route.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { parseNote } from '@/lib/utils'
|
||||
|
||||
// GET /api/notes/[id] - Get a single note
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (note.userId !== session.user.id) {
|
||||
const share = await prisma.noteShare.findUnique({
|
||||
where: {
|
||||
noteId_userId: {
|
||||
noteId: note.id,
|
||||
userId: session.user.id
|
||||
}
|
||||
}
|
||||
})
|
||||
if (!share || share.status !== 'accepted') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Forbidden' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: parseNote(note)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching note:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch note' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/notes/[id] - Update a note
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
const existingNote = await prisma.note.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!existingNote) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (existingNote.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Forbidden' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const updateData: any = { ...body }
|
||||
|
||||
if ('checkItems' in body) {
|
||||
updateData.checkItems = body.checkItems ?? null
|
||||
}
|
||||
if ('labels' in body) {
|
||||
updateData.labels = body.labels ?? null
|
||||
}
|
||||
|
||||
// Only update if data actually changed
|
||||
const hasChanges = Object.keys(updateData).some((key) => {
|
||||
const newValue = updateData[key]
|
||||
const oldValue = (existingNote as any)[key]
|
||||
// Handle arrays/objects by comparing JSON
|
||||
if (typeof newValue === 'object' && newValue !== null) {
|
||||
return JSON.stringify(newValue) !== JSON.stringify(oldValue)
|
||||
}
|
||||
return newValue !== oldValue
|
||||
})
|
||||
|
||||
// If no changes, return existing note without updating timestamp
|
||||
if (!hasChanges) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: parseNote(existingNote),
|
||||
})
|
||||
}
|
||||
|
||||
const note = await prisma.note.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: parseNote(note)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating note:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to update note' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/notes/[id] - Delete a note
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
const existingNote = await prisma.note.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!existingNote) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (existingNote.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Forbidden' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.note.update({
|
||||
where: { id },
|
||||
data: { trashedAt: new Date() }
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Note moved to trash'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error deleting note:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete note' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
70
memento-note/app/api/notes/delete-all/route.ts
Normal file
70
memento-note/app/api/notes/delete-all/route.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { deleteImageFileSafely, parseImageUrls } from '@/lib/image-cleanup'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Check authentication
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch notes with images before deleting for cleanup
|
||||
const notesWithImages = await prisma.note.findMany({
|
||||
where: { userId: session.user.id },
|
||||
select: { id: true, images: true },
|
||||
})
|
||||
|
||||
// Delete all notes for the user (cascade will handle labels-note relationships)
|
||||
const result = await prisma.note.deleteMany({
|
||||
where: {
|
||||
userId: session.user.id
|
||||
}
|
||||
})
|
||||
|
||||
// Clean up image files from disk (best-effort, don't block response)
|
||||
const imageCleanup = Promise.allSettled(
|
||||
notesWithImages.flatMap(note =>
|
||||
parseImageUrls(note.images).map(url => deleteImageFileSafely(url, note.id))
|
||||
)
|
||||
)
|
||||
|
||||
// Delete all labels for the user
|
||||
await prisma.label.deleteMany({
|
||||
where: {
|
||||
userId: session.user.id
|
||||
}
|
||||
})
|
||||
|
||||
// Delete all notebooks for the user
|
||||
await prisma.notebook.deleteMany({
|
||||
where: {
|
||||
userId: session.user.id
|
||||
}
|
||||
})
|
||||
|
||||
// Revalidate paths
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/data')
|
||||
|
||||
// Await cleanup in background (don't block response)
|
||||
imageCleanup.catch(() => {})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
deletedNotes: result.count
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Delete all error:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete notes' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
121
memento-note/app/api/notes/export/route.ts
Normal file
121
memento-note/app/api/notes/export/route.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// Check authentication
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch all notes with related data
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
trashedAt: null
|
||||
},
|
||||
include: {
|
||||
labelRelations: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
},
|
||||
notebook: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch labels separately
|
||||
const labels = await prisma.label.findMany({
|
||||
where: {
|
||||
userId: session.user.id
|
||||
},
|
||||
include: {
|
||||
notes: {
|
||||
select: {
|
||||
id: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch notebooks
|
||||
const notebooks = await prisma.notebook.findMany({
|
||||
where: {
|
||||
userId: session.user.id
|
||||
},
|
||||
include: {
|
||||
notes: {
|
||||
select: {
|
||||
id: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Create export object
|
||||
const exportData = {
|
||||
version: '1.0.0',
|
||||
exportDate: new Date().toISOString(),
|
||||
user: {
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name
|
||||
},
|
||||
data: {
|
||||
labels: labels.map(label => ({
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
noteCount: label.notes.length
|
||||
})),
|
||||
notebooks: notebooks.map(notebook => ({
|
||||
id: notebook.id,
|
||||
name: notebook.name,
|
||||
noteCount: notebook.notes.length
|
||||
})),
|
||||
notes: notes.map(note => ({
|
||||
id: note.id,
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
createdAt: note.createdAt,
|
||||
updatedAt: note.updatedAt,
|
||||
isPinned: note.isPinned,
|
||||
notebookId: note.notebookId,
|
||||
labelRelations: note.labelRelations.map(label => ({
|
||||
id: label.id,
|
||||
name: label.name
|
||||
}))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// Return as JSON file
|
||||
const jsonString = JSON.stringify(exportData, null, 2)
|
||||
return new NextResponse(jsonString, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Disposition': `attachment; filename="memento-export-${new Date().toISOString().split('T')[0]}.json"`
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Export error:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to export notes' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
157
memento-note/app/api/notes/import/route.ts
Normal file
157
memento-note/app/api/notes/import/route.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Check authentication
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Parse form data
|
||||
const formData = await req.formData()
|
||||
const file = formData.get('file') as File
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'No file provided' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Parse JSON file
|
||||
const text = await file.text()
|
||||
let importData: any
|
||||
|
||||
try {
|
||||
importData = JSON.parse(text)
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid JSON file' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate import data structure
|
||||
if (!importData.data || !importData.data.notes) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid import format' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
let importedNotes = 0
|
||||
let importedLabels = 0
|
||||
let importedNotebooks = 0
|
||||
|
||||
// Import labels first
|
||||
if (importData.data.labels && Array.isArray(importData.data.labels)) {
|
||||
for (const label of importData.data.labels) {
|
||||
// Check if label already exists
|
||||
const existing = await prisma.label.findFirst({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
name: label.name
|
||||
}
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
await prisma.label.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
name: label.name,
|
||||
color: label.color
|
||||
}
|
||||
})
|
||||
importedLabels++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import notebooks
|
||||
const notebookIdMap = new Map<string, string>()
|
||||
if (importData.data.notebooks && Array.isArray(importData.data.notebooks)) {
|
||||
for (const notebook of importData.data.notebooks) {
|
||||
// Check if notebook already exists
|
||||
const existing = await prisma.notebook.findFirst({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
name: notebook.name
|
||||
}
|
||||
})
|
||||
|
||||
let newNotebookId
|
||||
if (!existing) {
|
||||
const created = await prisma.notebook.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
name: notebook.name,
|
||||
order: 0
|
||||
}
|
||||
})
|
||||
newNotebookId = created.id
|
||||
notebookIdMap.set(notebook.id, newNotebookId)
|
||||
importedNotebooks++
|
||||
} else {
|
||||
notebookIdMap.set(notebook.id, existing.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import notes
|
||||
if (importData.data.notes && Array.isArray(importData.data.notes)) {
|
||||
for (const note of importData.data.notes) {
|
||||
// Map notebook ID
|
||||
const mappedNotebookId = notebookIdMap.get(note.notebookId) || null
|
||||
|
||||
// Get label IDs
|
||||
const labels = await prisma.label.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
name: {
|
||||
in: note.labels.map((l: any) => l.name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Create note
|
||||
await prisma.note.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
title: note.title || 'Untitled',
|
||||
content: note.content,
|
||||
isPinned: note.isPinned || false,
|
||||
notebookId: mappedNotebookId,
|
||||
labelRelations: {
|
||||
connect: labels.map(label => ({ id: label.id }))
|
||||
}
|
||||
}
|
||||
})
|
||||
importedNotes++
|
||||
}
|
||||
}
|
||||
|
||||
// Revalidate paths
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/data')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
count: importedNotes,
|
||||
labels: importedLabels,
|
||||
notebooks: importedNotebooks
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Import error:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to import notes' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
230
memento-note/app/api/notes/route.ts
Normal file
230
memento-note/app/api/notes/route.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { parseNote } from '@/lib/utils'
|
||||
|
||||
// GET /api/notes - Get all notes
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const includeArchived = searchParams.get('archived') === 'true'
|
||||
const search = searchParams.get('search')
|
||||
|
||||
let where: any = {
|
||||
userId: session.user.id,
|
||||
trashedAt: null
|
||||
}
|
||||
|
||||
if (!includeArchived) {
|
||||
where.isArchived = false
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
{ content: { contains: search, mode: 'insensitive' } }
|
||||
]
|
||||
}
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where,
|
||||
orderBy: [
|
||||
{ isPinned: 'desc' },
|
||||
{ order: 'asc' },
|
||||
{ updatedAt: 'desc' }
|
||||
]
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: notes.map(parseNote)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching notes:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch notes' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/notes - Create a new note
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { title, content, color, type, checkItems, labels, images } = body
|
||||
|
||||
if (!content && type !== 'checklist') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Content is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
title: title || null,
|
||||
content: content || '',
|
||||
color: color || 'default',
|
||||
type: type || 'text',
|
||||
checkItems: checkItems ?? null,
|
||||
labels: labels ?? null,
|
||||
images: images ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: parseNote(note)
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error creating note:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to create note' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/notes - Update an existing note
|
||||
export async function PUT(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { id, title, content, color, type, checkItems, labels, isPinned, isArchived, images } = body
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note ID is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const existingNote = await prisma.note.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!existingNote) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (existingNote.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Forbidden' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const updateData: any = {}
|
||||
|
||||
if (title !== undefined) updateData.title = title
|
||||
if (content !== undefined) updateData.content = content
|
||||
if (color !== undefined) updateData.color = color
|
||||
if (type !== undefined) updateData.type = type
|
||||
if (checkItems !== undefined) updateData.checkItems = checkItems ?? null
|
||||
if (labels !== undefined) updateData.labels = labels ?? null
|
||||
if (isPinned !== undefined) updateData.isPinned = isPinned
|
||||
if (isArchived !== undefined) updateData.isArchived = isArchived
|
||||
if (images !== undefined) updateData.images = images ?? null
|
||||
|
||||
const note = await prisma.note.update({
|
||||
where: { id },
|
||||
data: updateData
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: parseNote(note)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating note:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to update note' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/notes?id=xxx - Delete a note
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const id = searchParams.get('id')
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note ID is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const existingNote = await prisma.note.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!existingNote) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (existingNote.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Forbidden' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.note.update({
|
||||
where: { id },
|
||||
data: { trashedAt: new Date() }
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Note moved to trash'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error deleting note:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete note' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
38
memento-note/app/api/upload/route.ts
Normal file
38
memento-note/app/api/upload/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { writeFile, mkdir } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No file uploaded' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
const filename = `${randomUUID()}${path.extname(file.name)}`
|
||||
|
||||
// Ensure directory exists
|
||||
const uploadDir = path.join(process.cwd(), 'public/uploads/notes')
|
||||
await mkdir(uploadDir, { recursive: true })
|
||||
|
||||
const filePath = path.join(uploadDir, filename)
|
||||
await writeFile(filePath, buffer)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
url: `/uploads/notes/${filename}`
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to upload file' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user