feat: add slides generation tool with multiple slide types
- Add slides.tool.ts with support for title, bullets, chart, stats, table, cards, timeline, quote, comparison, equation, image, summary slide types - Chart types: bar, horizontal-bar, line, donut, radar - Integrate with agent executor and canvas system - Add multilingual support (en/fr) - Various UI improvements and bug fixes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
42
memento-note/app/api/notes/[id]/backlinks/route.ts
Normal file
42
memento-note/app/api/notes/[id]/backlinks/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
// GET /api/notes/[id]/backlinks
|
||||
// Returns all notes that link TO this note via [[wikilinks]]
|
||||
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 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
// Verify the note belongs to the user
|
||||
const note = await prisma.note.findUnique({ where: { id }, select: { userId: true } })
|
||||
if (!note || note.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const backlinks = await (prisma as any).noteLink.findMany({
|
||||
where: { targetNoteId: id },
|
||||
include: {
|
||||
sourceNote: {
|
||||
select: { id: true, title: true, updatedAt: true, notebookId: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
backlinks: backlinks.map((bl: any) => ({
|
||||
id: bl.id,
|
||||
sourceNote: bl.sourceNote,
|
||||
contextSnippet: bl.contextSnippet,
|
||||
createdAt: bl.createdAt,
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -167,6 +167,11 @@ export async function PUT(
|
||||
console.error('[HISTORY] Failed to create snapshot from /api/notes/[id] PUT:', snapshotError)
|
||||
}
|
||||
|
||||
// Fire-and-forget: sync [[wikilinks]] in background after content change
|
||||
if ('content' in updateData) {
|
||||
syncNoteLinksBackground(id, session.user.id, note.content).catch(() => {})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: parseNote(note)
|
||||
@@ -180,6 +185,56 @@ export async function PUT(
|
||||
}
|
||||
}
|
||||
|
||||
/** Background job: parse [[wikilinks]] and sync NoteLink table */
|
||||
async function syncNoteLinksBackground(noteId: string, userId: string, content: string) {
|
||||
const WIKILINK_RE = /\[\[([^\]|#]+?)(?:[|#][^\]]+)?\]\]/g
|
||||
const plain = content.replace(/<[^>]+>/g, ' ')
|
||||
const wikilinks: { title: string; snippet: string }[] = []
|
||||
const seen = new Set<string>()
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
WIKILINK_RE.lastIndex = 0
|
||||
while ((match = WIKILINK_RE.exec(plain)) !== null) {
|
||||
const title = match[1].trim()
|
||||
if (!title || seen.has(title.toLowerCase())) continue
|
||||
seen.add(title.toLowerCase())
|
||||
const start = Math.max(0, match.index - 50)
|
||||
const end = Math.min(plain.length, match.index + match[0].length + 50)
|
||||
const snippet = plain.slice(start, end).replace(/\s+/g, ' ').trim()
|
||||
wikilinks.push({ title, snippet })
|
||||
}
|
||||
|
||||
const upsertedIds: string[] = []
|
||||
|
||||
for (const { title, snippet } of wikilinks) {
|
||||
let targetNote = await prisma.note.findFirst({
|
||||
where: { userId, title: { equals: title, mode: 'insensitive' }, trashedAt: null },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!targetNote) {
|
||||
targetNote = await prisma.note.create({
|
||||
data: { title, content: '', userId, type: 'richtext', color: 'default', isMarkdown: true, order: 0 },
|
||||
select: { id: true },
|
||||
})
|
||||
}
|
||||
if (targetNote.id === noteId) continue
|
||||
await (prisma as any).noteLink.upsert({
|
||||
where: { sourceNoteId_targetNoteId: { sourceNoteId: noteId, targetNoteId: targetNote.id } },
|
||||
update: { contextSnippet: snippet.slice(0, 200) },
|
||||
create: { id: crypto.randomUUID(), sourceNoteId: noteId, targetNoteId: targetNote.id, contextSnippet: snippet.slice(0, 200) },
|
||||
})
|
||||
upsertedIds.push(targetNote.id)
|
||||
}
|
||||
|
||||
if (upsertedIds.length > 0) {
|
||||
await (prisma as any).noteLink.deleteMany({
|
||||
where: { sourceNoteId: noteId, targetNoteId: { notIn: upsertedIds } },
|
||||
})
|
||||
} else {
|
||||
await (prisma as any).noteLink.deleteMany({ where: { sourceNoteId: noteId } })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/notes/[id] - Delete a note
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
|
||||
130
memento-note/app/api/notes/[id]/sync-links/route.ts
Normal file
130
memento-note/app/api/notes/[id]/sync-links/route.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
const WIKILINK_RE = /\[\[([^\]|#]+?)(?:[|#][^\]]+)?\]\]/g
|
||||
|
||||
/**
|
||||
* Extract [[wikilink]] targets from markdown/html content.
|
||||
* Returns deduplicated list of linked note titles.
|
||||
*/
|
||||
function extractWikilinks(content: string): { title: string; snippet: string }[] {
|
||||
// Strip HTML tags
|
||||
const plain = content.replace(/<[^>]+>/g, ' ')
|
||||
const results: { title: string; snippet: string }[] = []
|
||||
const seen = new Set<string>()
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
WIKILINK_RE.lastIndex = 0
|
||||
while ((match = WIKILINK_RE.exec(plain)) !== null) {
|
||||
const title = match[1].trim()
|
||||
if (!title || seen.has(title.toLowerCase())) continue
|
||||
seen.add(title.toLowerCase())
|
||||
|
||||
// Extract snippet: 50 chars before + after the match
|
||||
const start = Math.max(0, match.index - 50)
|
||||
const end = Math.min(plain.length, match.index + match[0].length + 50)
|
||||
const snippet = plain.slice(start, end).replace(/\s+/g, ' ').trim()
|
||||
|
||||
results.push({ title, snippet })
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// POST /api/notes/[id]/sync-links
|
||||
// Parse [[wikilinks]] in note content and sync the NoteLink table.
|
||||
// Called automatically after note save.
|
||||
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 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const userId = session.user.id
|
||||
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true, content: true },
|
||||
})
|
||||
|
||||
if (!note || note.userId !== userId) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const wikilinks = extractWikilinks(note.content)
|
||||
|
||||
// For each wikilink, find or create the target note
|
||||
const upsertedLinks: string[] = []
|
||||
|
||||
for (const { title, snippet } of wikilinks) {
|
||||
// Find target note by title (case-insensitive) belonging to same user
|
||||
let targetNote = await prisma.note.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
title: { equals: title, mode: 'insensitive' },
|
||||
trashedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!targetNote) {
|
||||
// Create a stub note so the link resolves
|
||||
targetNote = await prisma.note.create({
|
||||
data: {
|
||||
title,
|
||||
content: '',
|
||||
userId,
|
||||
type: 'richtext',
|
||||
color: 'default',
|
||||
isMarkdown: true,
|
||||
order: 0,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
}
|
||||
|
||||
// Skip self-links
|
||||
if (targetNote.id === id) continue
|
||||
|
||||
// Upsert the NoteLink
|
||||
await (prisma as any).noteLink.upsert({
|
||||
where: {
|
||||
sourceNoteId_targetNoteId: {
|
||||
sourceNoteId: id,
|
||||
targetNoteId: targetNote.id,
|
||||
},
|
||||
},
|
||||
update: { contextSnippet: snippet.slice(0, 200) },
|
||||
create: {
|
||||
id: crypto.randomUUID(),
|
||||
sourceNoteId: id,
|
||||
targetNoteId: targetNote.id,
|
||||
contextSnippet: snippet.slice(0, 200),
|
||||
},
|
||||
})
|
||||
|
||||
upsertedLinks.push(targetNote.id)
|
||||
}
|
||||
|
||||
// Delete obsolete links (links that existed before but wikilink was removed)
|
||||
if (upsertedLinks.length > 0) {
|
||||
await (prisma as any).noteLink.deleteMany({
|
||||
where: {
|
||||
sourceNoteId: id,
|
||||
targetNoteId: { notIn: upsertedLinks },
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// No wikilinks at all — remove all outgoing links from this note
|
||||
await (prisma as any).noteLink.deleteMany({
|
||||
where: { sourceNoteId: id },
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ synced: upsertedLinks.length })
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { parseNote } from '@/lib/utils'
|
||||
import { CreateNoteSchema, UpdateNoteSchema, GetNotesQuerySchema } from '@/lib/validators'
|
||||
|
||||
// GET /api/notes - Get all notes
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -14,13 +16,23 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const includeArchived = searchParams.get('archived') === 'true'
|
||||
const search = searchParams.get('search')
|
||||
const notebookId = searchParams.get('notebookId')
|
||||
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : undefined
|
||||
const rawQuery = {
|
||||
archived: request.nextUrl.searchParams.get('archived') ?? undefined,
|
||||
search: request.nextUrl.searchParams.get('search') ?? undefined,
|
||||
notebookId: request.nextUrl.searchParams.get('notebookId') ?? undefined,
|
||||
limit: request.nextUrl.searchParams.get('limit') ?? undefined,
|
||||
}
|
||||
const queryResult = GetNotesQuerySchema.safeParse(rawQuery)
|
||||
if (!queryResult.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid query parameters', details: queryResult.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { archived, search, notebookId, limit } = queryResult.data
|
||||
const includeArchived = archived === 'true'
|
||||
|
||||
const where: any = {
|
||||
const where: Prisma.NoteWhereInput = {
|
||||
userId: session.user.id,
|
||||
trashedAt: null
|
||||
}
|
||||
@@ -75,25 +87,25 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { title, content, color, type, checkItems, labels, images } = body
|
||||
|
||||
if (!content && type !== 'checklist') {
|
||||
const parsed = CreateNoteSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Content is required' },
|
||||
{ success: false, error: 'Invalid request body', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { title, content, color, type, checkItems, labels, images } = parsed.data
|
||||
|
||||
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,
|
||||
title: title ?? null,
|
||||
content: content ?? '',
|
||||
color: color ?? 'default',
|
||||
type: type ?? 'text',
|
||||
checkItems: checkItems != null ? JSON.stringify(checkItems) : null,
|
||||
labels: labels != null ? JSON.stringify(labels) : null,
|
||||
images: images != null ? JSON.stringify(images) : null,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -122,34 +134,16 @@ export async function PUT(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { id, title, content, color, type, checkItems, labels, isPinned, isArchived, images } = body
|
||||
|
||||
if (!id) {
|
||||
const parsed = UpdateNoteSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note ID is required' },
|
||||
{ success: false, error: 'Invalid request body', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const { id, title, content, color, type, checkItems, labels, isPinned, isArchived, images } = parsed.data
|
||||
|
||||
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 = {}
|
||||
const updateData: Prisma.NoteUpdateInput = {}
|
||||
|
||||
if (title !== undefined) updateData.title = title
|
||||
if (content !== undefined) updateData.content = content
|
||||
@@ -161,10 +155,21 @@ export async function PUT(request: NextRequest) {
|
||||
if (isArchived !== undefined) updateData.isArchived = isArchived
|
||||
if (images !== undefined) updateData.images = images ?? null
|
||||
|
||||
const note = await prisma.note.update({
|
||||
where: { id },
|
||||
data: updateData
|
||||
})
|
||||
let note
|
||||
try {
|
||||
note = await prisma.note.update({
|
||||
where: { id, userId: session.user.id },
|
||||
data: updateData
|
||||
})
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note not found or access denied' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@@ -200,29 +205,21 @@ export async function DELETE(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const existingNote = await prisma.note.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!existingNote) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
try {
|
||||
await prisma.note.update({
|
||||
where: { id, userId: session.user.id },
|
||||
data: { trashedAt: new Date() }
|
||||
})
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note not found or access denied' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
Reference in New Issue
Block a user