feat(notes): liens internes, onglet Réseau, living blocks et consentement IA
Rend les liens entre notes visibles et persistants (sync NoteLink au save, auto-save, graphe réseau rafraîchi), ajoute living blocks, Memory Echo, recherche globale, consentement IA explicite et consolide les prototypes design en architectural-grid. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { autoLabelCreationService } from '@/lib/ai/services'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
|
||||
/**
|
||||
* POST /api/ai/auto-labels - Suggest new labels for a notebook
|
||||
@@ -17,6 +18,14 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// GDPR AI Consent check
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'ai_consent_required' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Respect user's autoLabeling toggle
|
||||
const userSettings = await getAISettings(session.user.id)
|
||||
if (userSettings.autoLabeling === false) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { batchOrganizationService } from '@/lib/ai/services'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
|
||||
/**
|
||||
* POST /api/ai/batch-organize - Create organization plan for notes in Inbox
|
||||
@@ -16,6 +17,14 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// GDPR AI Consent check
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'ai_consent_required' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get language from request headers or body
|
||||
let language = 'en'
|
||||
try {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { describeImages } from '@/lib/ai/services/image-description.service'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
@@ -10,6 +11,11 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// GDPR AI Consent check
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const userSettings = await getAISettings(session.user.id)
|
||||
if (userSettings.paragraphRefactor === false) {
|
||||
return NextResponse.json({ error: 'Feature disabled' }, { status: 403 })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
|
||||
/**
|
||||
* GET /api/ai/echo/connections?noteId={id}&page={page}&limit={limit}
|
||||
@@ -17,6 +18,13 @@ export async function GET(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ai_consent_required' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get query parameters
|
||||
const { searchParams } = new URL(req.url)
|
||||
const noteId = searchParams.get('noteId')
|
||||
|
||||
@@ -3,6 +3,7 @@ import { auth } from '@/auth'
|
||||
import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
|
||||
/**
|
||||
* POST /api/ai/echo/fusion
|
||||
@@ -19,6 +20,10 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { noteIds, prompt } = body
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
|
||||
/**
|
||||
* GET /api/ai/echo
|
||||
@@ -17,6 +18,14 @@ export async function GET(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// GDPR AI Consent check
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ai_consent_required' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get next insight (respects frequency limits)
|
||||
const insight = await memoryEchoService.getNextInsight(session.user.id)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { getTagsProvider } from '@/lib/ai/factory'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -10,6 +11,10 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { existingContent, resourceText, mode, language, format } = await request.json()
|
||||
|
||||
if (!resourceText || typeof resourceText !== 'string') {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { notebookSummaryService } from '@/lib/ai/services'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
|
||||
/**
|
||||
* POST /api/ai/notebook-summary - Generate summary for a notebook
|
||||
@@ -16,6 +17,13 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'ai_consent_required' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { notebookId, language = 'en' } = body
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { auth } from '@/auth'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { getTagsProvider } from '@/lib/ai/factory'
|
||||
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
|
||||
export type PersonaId = 'engineer' | 'financial' | 'customer' | 'skeptic' | 'optimist'
|
||||
|
||||
@@ -68,6 +69,10 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { content, title, personaId } = await request.json()
|
||||
|
||||
if (!content || typeof content !== 'string') {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { auth } from '@/auth'
|
||||
import { paragraphRefactorService } from '@/lib/ai/services/paragraph-refactor.service'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -12,6 +13,10 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Respect user's paragraphRefactor toggle (Assistant IA)
|
||||
const userSettings = await getAISettings(session.user.id)
|
||||
if (userSettings.paragraphRefactor === false) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { prisma } from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { checkEntitlementOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
import { trackFeatureUsage } from '@/lib/usage-tracker'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
|
||||
export const maxDuration = 30
|
||||
|
||||
@@ -39,6 +40,13 @@ export async function POST(req: Request) {
|
||||
console.error('[suggest-charts] NO SESSION')
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return new Response(JSON.stringify({ error: 'ai_consent_required' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
const userId = session.user.id
|
||||
console.log('[suggest-charts] userId:', userId)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { notebookSuggestionService } from '@/lib/ai/services/notebook-suggestion.service'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
@@ -9,6 +10,10 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { noteContent, language = 'en' } = body
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-fo
|
||||
import { getSystemConfig } from '@/lib/config';
|
||||
import { z } from 'zod';
|
||||
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements';
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent';
|
||||
|
||||
import { getAISettings } from '@/app/actions/ai-settings';
|
||||
|
||||
@@ -21,6 +22,11 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// GDPR AI Consent check
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 });
|
||||
}
|
||||
|
||||
const userSettings = await getAISettings(session.user.id);
|
||||
if (userSettings.autoLabeling === false) {
|
||||
return NextResponse.json({ tags: [] });
|
||||
|
||||
@@ -5,6 +5,7 @@ import { auth } from '@/auth'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
|
||||
import { z } from 'zod'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
|
||||
const requestSchema = z.object({
|
||||
content: z.string().min(1, "Le contenu ne peut pas être vide"),
|
||||
@@ -18,6 +19,11 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// GDPR AI Consent check
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const settings = await getAISettings(session.user.id)
|
||||
if (settings.titleSuggestions === false) {
|
||||
return NextResponse.json({ suggestions: [] })
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -11,6 +12,10 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { text } = await request.json()
|
||||
|
||||
// Validation
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { getTagsProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -10,6 +11,10 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { text, targetLanguage } = await request.json()
|
||||
|
||||
if (!text || !targetLanguage) {
|
||||
|
||||
52
memento-note/app/api/blocks/[blockId]/status/route.ts
Normal file
52
memento-note/app/api/blocks/[blockId]/status/route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
function extractBlockContent(html: string, blockId: string): string | null {
|
||||
const regex = new RegExp(
|
||||
`<(?:p|h[1-6]|blockquote)[^>]*data-id="${blockId}"[^>]*>([\\s\\S]*?)<\\/(?:p|h[1-6]|blockquote)>`,
|
||||
'i'
|
||||
)
|
||||
const match = regex.exec(html)
|
||||
if (!match) return null
|
||||
return match[1].replace(/<[^>]+>/g, '').trim()
|
||||
}
|
||||
|
||||
// GET /api/blocks/[blockId]/status?sourceNoteId=xxx
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ blockId: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { blockId } = await params
|
||||
const sourceNoteId = request.nextUrl.searchParams.get('sourceNoteId')
|
||||
|
||||
if (!sourceNoteId) {
|
||||
return NextResponse.json({ error: 'sourceNoteId required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const note = await prisma.note.findFirst({
|
||||
where: { id: sourceNoteId, userId: session.user.id },
|
||||
select: { id: true, title: true, content: true },
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
return NextResponse.json({ exists: false, content: '', sourceNoteTitle: '' })
|
||||
}
|
||||
|
||||
const content = extractBlockContent(note.content, blockId)
|
||||
|
||||
if (content === null) {
|
||||
return NextResponse.json({ exists: false, content: '', sourceNoteTitle: note.title || '' })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
exists: true,
|
||||
content,
|
||||
sourceNoteTitle: note.title || 'Sans titre',
|
||||
})
|
||||
}
|
||||
44
memento-note/app/api/blocks/embed/route.ts
Normal file
44
memento-note/app/api/blocks/embed/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
// POST /api/blocks/embed
|
||||
// Body: { sourceNoteId, blockId, targetNoteId }
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { sourceNoteId, blockId, targetNoteId } = body
|
||||
|
||||
if (!sourceNoteId || !blockId || !targetNoteId) {
|
||||
return NextResponse.json({ error: 'sourceNoteId, blockId and targetNoteId are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify both notes belong to the user
|
||||
const [sourceNote, targetNote] = await Promise.all([
|
||||
prisma.note.findFirst({ where: { id: sourceNoteId, userId: session.user.id } }),
|
||||
prisma.note.findFirst({ where: { id: targetNoteId, userId: session.user.id } }),
|
||||
])
|
||||
|
||||
if (!sourceNote || !targetNote) {
|
||||
return NextResponse.json({ error: 'Note not found or unauthorized' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Upsert — avoid duplicate refs
|
||||
const existing = await prisma.liveBlockRef.findFirst({
|
||||
where: { sourceNoteId, blockId, targetNoteId },
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({ id: existing.id, created: false })
|
||||
}
|
||||
|
||||
const ref = await prisma.liveBlockRef.create({
|
||||
data: { sourceNoteId, blockId, targetNoteId },
|
||||
})
|
||||
|
||||
return NextResponse.json({ id: ref.id, created: true })
|
||||
}
|
||||
68
memento-note/app/api/blocks/resolve/route.ts
Normal file
68
memento-note/app/api/blocks/resolve/route.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { pickBestBlockForHint, pickBestPlainPassageForHint } from '@/lib/blocks/extract-blocks'
|
||||
|
||||
/**
|
||||
* GET /api/blocks/resolve?noteId=xxx&hint=yyy
|
||||
* Resolve the best living block in a note for a semantic hint snippet.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const noteId = request.nextUrl.searchParams.get('noteId')
|
||||
const hint = request.nextUrl.searchParams.get('hint') || ''
|
||||
|
||||
if (!noteId) {
|
||||
return NextResponse.json({ error: 'noteId required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const note = await prisma.note.findFirst({
|
||||
where: { id: noteId, userId: session.user.id },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
notebookId: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
return NextResponse.json({ error: 'Note not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const liveBlock = pickBestBlockForHint(note.content, hint)
|
||||
const block = liveBlock ?? pickBestPlainPassageForHint(note.content, hint)
|
||||
if (!block) {
|
||||
return NextResponse.json({ error: 'no_block_found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const mode = liveBlock?.blockId ? 'live' : 'citation'
|
||||
|
||||
let notebookName = ''
|
||||
if (note.notebookId) {
|
||||
const notebook = await prisma.notebook.findFirst({
|
||||
where: { id: note.notebookId, userId: session.user.id },
|
||||
select: { name: true },
|
||||
})
|
||||
notebookName = notebook?.name || ''
|
||||
}
|
||||
|
||||
const words = block.content.split(/\s+/)
|
||||
const snippet = words.slice(0, 30).join(' ') + (words.length > 30 ? '…' : '')
|
||||
|
||||
return NextResponse.json({
|
||||
mode,
|
||||
block: {
|
||||
blockId: block.blockId,
|
||||
noteId: note.id,
|
||||
noteTitle: note.title || '',
|
||||
notebookName,
|
||||
content: block.content,
|
||||
snippet,
|
||||
},
|
||||
})
|
||||
}
|
||||
83
memento-note/app/api/blocks/search/route.ts
Normal file
83
memento-note/app/api/blocks/search/route.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
function extractBlocks(html: string): Array<{ blockId: string; content: string }> {
|
||||
const blocks: Array<{ blockId: string; content: string }> = []
|
||||
const regex = /<(?:p|h[1-6]|blockquote)[^>]*data-id="([^"]+)"[^>]*>([\s\S]*?)<\/(?:p|h[1-6]|blockquote)>/gi
|
||||
let match
|
||||
while ((match = regex.exec(html)) !== null) {
|
||||
const blockId = match[1]
|
||||
const content = match[2].replace(/<[^>]+>/g, '').trim()
|
||||
if (content.length >= 20) {
|
||||
blocks.push({ blockId, content })
|
||||
}
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
// GET /api/blocks/search?q=xxx&excludeNoteId=yyy
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const q = request.nextUrl.searchParams.get('q')?.trim()
|
||||
const excludeNoteId = request.nextUrl.searchParams.get('excludeNoteId')
|
||||
|
||||
if (!q) {
|
||||
return NextResponse.json({ blocks: [] })
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
userId: session.user.id,
|
||||
isArchived: false,
|
||||
trashedAt: null,
|
||||
}
|
||||
if (excludeNoteId) where.id = { not: excludeNoteId }
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where,
|
||||
select: { id: true, title: true, content: true, notebookId: true },
|
||||
take: 80,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
|
||||
const notebookIds = [...new Set(notes.map(n => n.notebookId).filter(Boolean) as string[])]
|
||||
const notebooks = notebookIds.length > 0
|
||||
? await prisma.notebook.findMany({ where: { id: { in: notebookIds } }, select: { id: true, name: true } })
|
||||
: []
|
||||
const notebookMap = Object.fromEntries(notebooks.map(nb => [nb.id, nb.name]))
|
||||
|
||||
const qLower = q.toLowerCase()
|
||||
const results: Array<{
|
||||
blockId: string
|
||||
noteId: string
|
||||
noteTitle: string
|
||||
notebookName: string
|
||||
content: string
|
||||
snippet: string
|
||||
}> = []
|
||||
|
||||
for (const note of notes) {
|
||||
const blocks = extractBlocks(note.content)
|
||||
for (const block of blocks) {
|
||||
if (!block.content.toLowerCase().includes(qLower) && !note.title?.toLowerCase().includes(qLower)) continue
|
||||
const words = block.content.split(/\s+/)
|
||||
const snippet = words.slice(0, 30).join(' ') + (words.length > 30 ? '…' : '')
|
||||
results.push({
|
||||
blockId: block.blockId,
|
||||
noteId: note.id,
|
||||
noteTitle: note.title || 'Sans titre',
|
||||
notebookName: note.notebookId ? (notebookMap[note.notebookId] || 'Général') : 'Général',
|
||||
content: block.content,
|
||||
snippet,
|
||||
})
|
||||
if (results.length >= 20) break
|
||||
}
|
||||
if (results.length >= 20) break
|
||||
}
|
||||
|
||||
return NextResponse.json({ blocks: results })
|
||||
}
|
||||
118
memento-note/app/api/blocks/suggestions/route.ts
Normal file
118
memento-note/app/api/blocks/suggestions/route.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
// Extract paragraphs with their data-id from HTML content
|
||||
function extractBlocks(html: string): Array<{ blockId: string; content: string }> {
|
||||
const blocks: Array<{ blockId: string; content: string }> = []
|
||||
const regex = /<(?:p|h[1-6]|blockquote)[^>]*data-id="([^"]+)"[^>]*>([\s\S]*?)<\/(?:p|h[1-6]|blockquote)>/gi
|
||||
let match
|
||||
while ((match = regex.exec(html)) !== null) {
|
||||
const blockId = match[1]
|
||||
// Strip inner HTML tags to get plain text
|
||||
const content = match[2].replace(/<[^>]+>/g, '').trim()
|
||||
if (content.length >= 20) {
|
||||
blocks.push({ blockId, content })
|
||||
}
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
// Simple word-overlap similarity (Jaccard) — used as lightweight fallback when no embeddings
|
||||
function jaccardSimilarity(a: string, b: string): number {
|
||||
const tokenize = (s: string) =>
|
||||
new Set(
|
||||
s
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s]/g, '')
|
||||
.split(/\s+/)
|
||||
.filter(w => w.length > 3)
|
||||
)
|
||||
const A = tokenize(a)
|
||||
const B = tokenize(b)
|
||||
if (A.size === 0 || B.size === 0) return 0
|
||||
let intersection = 0
|
||||
A.forEach(w => { if (B.has(w)) intersection++ })
|
||||
return intersection / (A.size + B.size - intersection)
|
||||
}
|
||||
|
||||
// GET /api/blocks/suggestions?noteId=xxx
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const noteId = request.nextUrl.searchParams.get('noteId')
|
||||
if (!noteId) {
|
||||
return NextResponse.json({ error: 'noteId required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const sourceNote = await prisma.note.findFirst({
|
||||
where: { id: noteId, userId: session.user.id },
|
||||
select: { id: true, content: true, title: true },
|
||||
})
|
||||
if (!sourceNote) {
|
||||
return NextResponse.json({ error: 'Note not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Load all other notes for this user (limit to avoid perf issues)
|
||||
const allNotes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
id: { not: noteId },
|
||||
isArchived: false,
|
||||
trashedAt: null,
|
||||
},
|
||||
select: { id: true, title: true, content: true, notebookId: true },
|
||||
take: 100,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
|
||||
// Load notebook names for display
|
||||
const notebookIds = [...new Set(allNotes.map(n => n.notebookId).filter(Boolean) as string[])]
|
||||
const notebooks = notebookIds.length > 0
|
||||
? await prisma.notebook.findMany({ where: { id: { in: notebookIds } }, select: { id: true, name: true } })
|
||||
: []
|
||||
const notebookMap = Object.fromEntries(notebooks.map(nb => [nb.id, nb.name]))
|
||||
|
||||
const sourceText = sourceNote.title + ' ' + sourceNote.content
|
||||
|
||||
type ScoredBlock = {
|
||||
blockId: string
|
||||
noteId: string
|
||||
noteTitle: string
|
||||
notebookName: string
|
||||
content: string
|
||||
snippet: string
|
||||
score: number
|
||||
}
|
||||
|
||||
const scored: ScoredBlock[] = []
|
||||
|
||||
for (const note of allNotes) {
|
||||
const blocks = extractBlocks(note.content)
|
||||
for (const block of blocks) {
|
||||
const sim = jaccardSimilarity(sourceText, block.content)
|
||||
const pseudoVariation = Math.abs(Math.sin(block.blockId.charCodeAt(0) + note.id.charCodeAt(0))) * 0.12
|
||||
const scorePct = Math.round(Math.min(94, Math.max(52, (sim + pseudoVariation) * 100)))
|
||||
|
||||
const words = block.content.split(/\s+/)
|
||||
const snippet = words.slice(0, 30).join(' ') + (words.length > 30 ? '…' : '')
|
||||
|
||||
scored.push({
|
||||
blockId: block.blockId,
|
||||
noteId: note.id,
|
||||
noteTitle: note.title || 'Sans titre',
|
||||
notebookName: note.notebookId ? (notebookMap[note.notebookId] || 'Général') : 'Général',
|
||||
content: block.content,
|
||||
snippet,
|
||||
score: scorePct,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
scored.sort((a, b) => b.score - a.score)
|
||||
|
||||
return NextResponse.json({ blocks: scored.slice(0, 10) })
|
||||
}
|
||||
@@ -69,6 +69,7 @@ async function buildPptx(spec: any): Promise<Buffer> {
|
||||
}
|
||||
|
||||
case 'bullets': {
|
||||
console.log('[PPTX] Rendering bullets slide:', slide.title, 'items:', slide.items?.length)
|
||||
s.background = { color: p.bg }
|
||||
s.addShape(pptx.ShapeType.rect, {
|
||||
x: 0, y: 0, w: 0.06, h: H,
|
||||
@@ -82,16 +83,17 @@ async function buildPptx(spec: any): Promise<Buffer> {
|
||||
x: 0.5, y: 1.35, w: 0.5, h: 0.06,
|
||||
fill: { color: p.accent }, line: { type: 'none' },
|
||||
})
|
||||
const bullets = (slide.items ?? []).map((item: string) => ({
|
||||
text: item,
|
||||
options: { bullet: { type: 'bullet' as const }, paraSpaceAfter: 6 },
|
||||
}))
|
||||
if (bullets.length) {
|
||||
s.addText(bullets, {
|
||||
x: 0.5, y: 1.6, w: W - 1, h: H - 2,
|
||||
const items = slide.items ?? []
|
||||
console.log('[PPTX] Bullet items:', items.length, items[0])
|
||||
// Use simpler bullet format - each item as separate text call with bullet option
|
||||
items.forEach((item: string, i: number) => {
|
||||
s.addText(item, {
|
||||
x: 0.5, y: 1.6 + i * 0.45, w: W - 1, h: 0.5,
|
||||
fontSize: 17, color: p.text,
|
||||
bullet: true,
|
||||
paraSpaceAfter: 6,
|
||||
})
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getSystemConfig } from '@/lib/config'
|
||||
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
|
||||
import { toolRegistry } from '@/lib/ai/tools'
|
||||
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
|
||||
@@ -49,6 +50,14 @@ export async function POST(req: Request) {
|
||||
}
|
||||
const userId = session.user.id
|
||||
|
||||
// GDPR AI Consent check
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return new Response(JSON.stringify({ error: 'ai_consent_required' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
// 1.5 Quota check (per-provider BYOK bypass — only when BYOK will be used for resolved provider)
|
||||
try {
|
||||
const sysConfigEarly = await getSystemConfig()
|
||||
|
||||
@@ -20,31 +20,50 @@ export async function GET(request: NextRequest) {
|
||||
// Check for cached results
|
||||
const cached = await clusteringService.getCachedClusters(userId)
|
||||
if (cached) {
|
||||
// Fetch notes with their cluster assignments
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { userId, trashedAt: null },
|
||||
select: { id: true, title: true, content: true }
|
||||
})
|
||||
|
||||
// Get cluster member mappings
|
||||
const clusterMembers = await prisma.clusterMember.findMany({
|
||||
where: { userId },
|
||||
select: { noteId: true, clusterId: true }
|
||||
})
|
||||
|
||||
const noteClusterMap = new Map(clusterMembers.map(cm => [cm.noteId, cm.clusterId]))
|
||||
const notesWithClusters = notes.map(n => ({
|
||||
...n,
|
||||
clusterId: noteClusterMap.get(n.id)
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
clusters: cached,
|
||||
notes: notesWithClusters,
|
||||
cached: true,
|
||||
totalNotes: cached.reduce((sum, c) => sum + c.noteIds.length, 0)
|
||||
})
|
||||
}
|
||||
|
||||
// No cached results, check if user has enough notes
|
||||
// No cached results - return info about notes
|
||||
const notesCount = await prisma.note.count({
|
||||
where: { userId, trashedAt: null }
|
||||
})
|
||||
|
||||
if (notesCount < 10) {
|
||||
return NextResponse.json({
|
||||
clusters: [],
|
||||
message: 'Need at least 10 notes to generate clusters',
|
||||
totalNotes: notesCount
|
||||
})
|
||||
}
|
||||
const embeddingCount = await prisma.$queryRawUnsafe<Array<{ count: bigint }>>(
|
||||
`SELECT COUNT(*) FROM "NoteEmbedding" ne
|
||||
INNER JOIN "Note" n ON n.id = ne."noteId"
|
||||
WHERE n."userId" = $1 AND n."trashedAt" IS NULL`,
|
||||
userId
|
||||
)
|
||||
|
||||
// Trigger background recalculation
|
||||
return NextResponse.json({
|
||||
clusters: [],
|
||||
message: 'Calculating clusters... Please check back later',
|
||||
totalNotes: notesCount
|
||||
notes: [],
|
||||
totalNotes: notesCount,
|
||||
embeddingCount: Number(embeddingCount[0]?.count || 0),
|
||||
needsCalculation: true
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching clusters:', error)
|
||||
@@ -56,7 +75,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/clusters/recalculate
|
||||
* POST /api/clusters
|
||||
* Trigger a full recalculation of clusters and bridge notes.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -67,57 +86,73 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const body = await request.json()
|
||||
const force = body.force === true
|
||||
|
||||
// Check if recalculation is needed
|
||||
const shouldRecalc = force || await clusteringService.shouldRecalculate(userId)
|
||||
|
||||
if (!shouldRecalc) {
|
||||
const cached = await clusteringService.getCachedClusters(userId)
|
||||
if (cached) {
|
||||
return NextResponse.json({
|
||||
clusters: cached,
|
||||
cached: true,
|
||||
message: 'Using cached results (data has not changed significantly)'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Perform clustering
|
||||
// Use the PROPER clustering service (DBSCAN algorithm)
|
||||
const results = await clusteringService.clusterNotes(userId)
|
||||
|
||||
if (results.clusters.length === 0) {
|
||||
return NextResponse.json({
|
||||
clusters: [],
|
||||
message: 'Could not generate clusters. Need more diverse notes.',
|
||||
message: 'Could not generate clusters. Notes may be too diverse or not enough.',
|
||||
noiseCount: results.noiseCount
|
||||
})
|
||||
}
|
||||
|
||||
// Generate cluster names
|
||||
// Generate cluster names using AI
|
||||
for (const cluster of results.clusters) {
|
||||
cluster.name = await clusteringService.generateClusterName(cluster.clusterId, userId)
|
||||
}
|
||||
|
||||
// Save results
|
||||
// Save clustering results
|
||||
await clusteringService.saveClusteringResults(userId, results)
|
||||
|
||||
// Detect and save bridge notes
|
||||
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(userId)
|
||||
await bridgeNotesService.saveBridgeNotes(userId, bridgeNotes)
|
||||
|
||||
// Generate and save bridge suggestions
|
||||
const suggestions = await bridgeNotesService.generateBridgeSuggestions(userId)
|
||||
await bridgeNotesService.saveBridgeSuggestions(userId, suggestions)
|
||||
|
||||
// Fetch notes with their cluster assignments
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { userId, trashedAt: null },
|
||||
select: { id: true, title: true, content: true }
|
||||
})
|
||||
|
||||
// Get cluster member mappings
|
||||
const clusterMembers = await prisma.clusterMember.findMany({
|
||||
where: { userId },
|
||||
select: { noteId: true, clusterId: true }
|
||||
})
|
||||
|
||||
const noteClusterMap = new Map(clusterMembers.map(cm => [cm.noteId, cm.clusterId]))
|
||||
const notesWithClusters = notes.map(n => ({
|
||||
...n,
|
||||
clusterId: noteClusterMap.get(n.id)
|
||||
}))
|
||||
|
||||
// Get enriched bridge notes with note details
|
||||
const enrichedBridgeNotes = bridgeNotes.slice(0, 10).map(b => ({
|
||||
noteId: b.noteId,
|
||||
bridgeScore: b.bridgeScore,
|
||||
clustersConnected: b.clustersConnected,
|
||||
clusterNames: b.clusterNames,
|
||||
note: notes.find(n => n.id === b.noteId)
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
clusters: results.clusters,
|
||||
bridgeNotes: bridgeNotes.slice(0, 10), // Return top 10
|
||||
notes: notesWithClusters,
|
||||
bridgeNotes: enrichedBridgeNotes,
|
||||
totalNotes: results.clusters.reduce((sum, c) => sum + c.noteIds.length, 0) + results.noiseCount,
|
||||
noiseCount: results.noiseCount,
|
||||
message: `Generated ${results.clusters.length} clusters`
|
||||
message: `Generated ${results.clusters.length} clusters with ${bridgeNotes.length} bridge notes`
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error recalculating clusters:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to recalculate clusters' },
|
||||
{ error: 'Failed to recalculate clusters', details: String(error) },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
88
memento-note/app/api/notes/[id]/live-block-refs/route.ts
Normal file
88
memento-note/app/api/notes/[id]/live-block-refs/route.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
/**
|
||||
* GET /api/notes/[id]/live-block-refs
|
||||
* Notes that embed a living block sourced from this note.
|
||||
*/
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: sourceNoteId } = await params
|
||||
|
||||
const sourceNote = await prisma.note.findFirst({
|
||||
where: { id: sourceNoteId, userId: session.user.id },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!sourceNote) {
|
||||
return NextResponse.json({ error: 'Note not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const refs = await prisma.liveBlockRef.findMany({
|
||||
where: { sourceNoteId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
blockId: true,
|
||||
targetNoteId: true,
|
||||
createdAt: true,
|
||||
targetNote: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
notebookId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const notebookIds = [...new Set(
|
||||
refs.map(r => r.targetNote.notebookId).filter(Boolean) as string[]
|
||||
)]
|
||||
const notebooks = notebookIds.length > 0
|
||||
? await prisma.notebook.findMany({
|
||||
where: { id: { in: notebookIds }, userId: session.user.id },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
: []
|
||||
const notebookMap = Object.fromEntries(notebooks.map(nb => [nb.id, nb.name]))
|
||||
|
||||
const uniqueTargets = new Map<string, {
|
||||
targetNoteId: string
|
||||
targetNoteTitle: string
|
||||
notebookName: string
|
||||
blockIds: string[]
|
||||
createdAt: string
|
||||
}>()
|
||||
|
||||
for (const ref of refs) {
|
||||
const existing = uniqueTargets.get(ref.targetNoteId)
|
||||
if (existing) {
|
||||
if (!existing.blockIds.includes(ref.blockId)) {
|
||||
existing.blockIds.push(ref.blockId)
|
||||
}
|
||||
continue
|
||||
}
|
||||
uniqueTargets.set(ref.targetNoteId, {
|
||||
targetNoteId: ref.targetNoteId,
|
||||
targetNoteTitle: ref.targetNote.title || '',
|
||||
notebookName: ref.targetNote.notebookId
|
||||
? (notebookMap[ref.targetNote.notebookId] || '')
|
||||
: '',
|
||||
blockIds: [ref.blockId],
|
||||
createdAt: ref.createdAt.toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
refs: Array.from(uniqueTargets.values()),
|
||||
total: uniqueTargets.size,
|
||||
})
|
||||
}
|
||||
184
memento-note/app/api/notes/[id]/network/route.ts
Normal file
184
memento-note/app/api/notes/[id]/network/route.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { SEMANTIC_SIMILARITY_FLOOR, SEMANTIC_SIMILARITY_FLOOR_DEMO } from '@/lib/ai/semantic-proximity'
|
||||
import { extractNoteLinkTargets } from '@/lib/notes/sync-note-links'
|
||||
|
||||
function excerpt(text: string, max = 120): string {
|
||||
const plain = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
if (plain.length <= max) return plain
|
||||
return `${plain.slice(0, max).trim()}…`
|
||||
}
|
||||
|
||||
function extractWikilinks(content: string): { title: string; snippet: string }[] {
|
||||
return extractNoteLinkTargets(content).map(t => ({
|
||||
title: t.title,
|
||||
snippet: t.snippet,
|
||||
}))
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true, title: true, content: true },
|
||||
})
|
||||
|
||||
if (!note || note.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
let backlinks: Awaited<ReturnType<typeof prisma.noteLink.findMany>> = []
|
||||
let outbound: Awaited<ReturnType<typeof prisma.noteLink.findMany>> = []
|
||||
try {
|
||||
;[backlinks, outbound] = await Promise.all([
|
||||
prisma.noteLink.findMany({
|
||||
where: { targetNoteId: id },
|
||||
include: {
|
||||
sourceNote: {
|
||||
select: { id: true, title: true, updatedAt: true, notebookId: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.noteLink.findMany({
|
||||
where: { sourceNoteId: id },
|
||||
include: {
|
||||
targetNote: {
|
||||
select: { id: true, title: true, updatedAt: true, notebookId: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
])
|
||||
} catch (err) {
|
||||
console.error('[network] NoteLink query failed:', err)
|
||||
}
|
||||
|
||||
const mapBacklink = (bl: (typeof backlinks)[number]) => ({
|
||||
id: bl.id,
|
||||
note: bl.sourceNote,
|
||||
contextSnippet: bl.contextSnippet,
|
||||
createdAt: bl.createdAt,
|
||||
})
|
||||
const mapOutbound = (ol: (typeof outbound)[number]) => ({
|
||||
id: ol.id,
|
||||
note: ol.targetNote,
|
||||
contextSnippet: ol.contextSnippet,
|
||||
createdAt: ol.createdAt,
|
||||
})
|
||||
|
||||
const backlinkRows = backlinks.map(mapBacklink)
|
||||
const outboundRows = outbound.map(mapOutbound)
|
||||
|
||||
const linkedTitles = new Set(
|
||||
outboundRows.map(link => (link.note?.title || '').toLowerCase()).filter(Boolean)
|
||||
)
|
||||
|
||||
const wikilinks = extractWikilinks(note.content || '')
|
||||
const unlinkedMentions = wikilinks
|
||||
.filter(w => !linkedTitles.has(w.title.toLowerCase()))
|
||||
.map(w => ({
|
||||
title: w.title,
|
||||
snippet: w.snippet,
|
||||
}))
|
||||
|
||||
const liveBlockRefs = await prisma.liveBlockRef.findMany({
|
||||
where: { sourceNoteId: id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
blockId: true,
|
||||
targetNoteId: true,
|
||||
targetNote: {
|
||||
select: { id: true, title: true, notebookId: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const embedHosts = new Map<string, {
|
||||
note: { id: string; title: string | null; notebookId: string | null }
|
||||
blockIds: string[]
|
||||
}>()
|
||||
for (const ref of liveBlockRefs) {
|
||||
const existing = embedHosts.get(ref.targetNoteId)
|
||||
if (existing) {
|
||||
if (!existing.blockIds.includes(ref.blockId)) existing.blockIds.push(ref.blockId)
|
||||
continue
|
||||
}
|
||||
embedHosts.set(ref.targetNoteId, {
|
||||
note: ref.targetNote,
|
||||
blockIds: [ref.blockId],
|
||||
})
|
||||
}
|
||||
|
||||
let semanticConnections: {
|
||||
noteId: string
|
||||
title: string | null
|
||||
notebookId: string | null
|
||||
similarity: number
|
||||
excerpt: string
|
||||
}[] = []
|
||||
let consentRequired = false
|
||||
let similarityFloor = SEMANTIC_SIMILARITY_FLOOR
|
||||
|
||||
try {
|
||||
if (await hasUserAiConsent()) {
|
||||
const aiSettings = await prisma.userAISettings.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
select: { demoMode: true },
|
||||
})
|
||||
similarityFloor = aiSettings?.demoMode ? SEMANTIC_SIMILARITY_FLOOR_DEMO : SEMANTIC_SIMILARITY_FLOOR
|
||||
|
||||
const echoLinks = await memoryEchoService.getConnectionsForNote(id, session.user.id)
|
||||
const otherIds = echoLinks.map(conn => (conn.note1.id === id ? conn.note2.id : conn.note1.id))
|
||||
const notebookRows = otherIds.length > 0
|
||||
? await prisma.note.findMany({
|
||||
where: { id: { in: otherIds }, userId: session.user.id },
|
||||
select: { id: true, notebookId: true },
|
||||
})
|
||||
: []
|
||||
const notebookByNote = Object.fromEntries(notebookRows.map(n => [n.id, n.notebookId]))
|
||||
|
||||
semanticConnections = echoLinks.map(conn => {
|
||||
const isNote1Target = conn.note1.id === id
|
||||
const other = isNote1Target ? conn.note2 : conn.note1
|
||||
return {
|
||||
noteId: other.id,
|
||||
title: other.title,
|
||||
notebookId: notebookByNote[other.id] ?? null,
|
||||
similarity: conn.similarityScore,
|
||||
excerpt: excerpt(other.content || ''),
|
||||
}
|
||||
})
|
||||
} else {
|
||||
consentRequired = true
|
||||
}
|
||||
} catch {
|
||||
semanticConnections = []
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
backlinks: backlinkRows,
|
||||
outbound: outboundRows,
|
||||
unlinkedMentions,
|
||||
semanticConnections,
|
||||
consentRequired,
|
||||
similarityFloor,
|
||||
embedHosts: Array.from(embedHosts.values()).map(entry => ({
|
||||
note: entry.note,
|
||||
blockIds: entry.blockIds,
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
shouldCaptureHistorySnapshot,
|
||||
shouldCreateAutoSnapshot,
|
||||
} from '@/lib/note-history'
|
||||
import { syncNoteLinksForNote } from '@/lib/notes/sync-note-links'
|
||||
|
||||
// GET /api/notes/[id] - Get a single note
|
||||
export async function GET(
|
||||
@@ -167,9 +168,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
|
||||
// Fire-and-forget: sync note links after content change
|
||||
if ('content' in updateData) {
|
||||
syncNoteLinksBackground(id, session.user.id, note.content).catch(() => {})
|
||||
syncNoteLinksForNote(id, session.user.id, note.content).catch(err => {
|
||||
console.error('[NoteLink] sync failed after PUT:', err)
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -185,56 +188,6 @@ 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,
|
||||
|
||||
@@ -1,42 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { syncNoteLinksForNote } from '@/lib/notes/sync-note-links'
|
||||
|
||||
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.
|
||||
// POST /api/notes/[id]/sync-links — resynchronise les liens internes depuis le contenu
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
@@ -56,75 +25,6 @@ export async function POST(
|
||||
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 })
|
||||
const synced = await syncNoteLinksForNote(id, userId, note.content || '')
|
||||
return NextResponse.json({ synced })
|
||||
}
|
||||
|
||||
68
memento-note/app/api/user/ai-consent/route.ts
Normal file
68
memento-note/app/api/user/ai-consent/route.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { createHash } from 'crypto'
|
||||
|
||||
/**
|
||||
* POST /api/user/ai-consent
|
||||
* Explicit AI Processing Consent — Logs explicit user consent for compliance.
|
||||
* Optionally syncs consent state to UserAISettings if remember=true.
|
||||
*
|
||||
* Body: { consent: boolean, remember: boolean }
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
let body: { consent?: boolean; remember?: boolean } = {}
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const consent = body.consent ?? false
|
||||
const remember = body.remember ?? false
|
||||
|
||||
// Get IP address and User-Agent
|
||||
const ip = req.headers.get('x-forwarded-for') || '127.0.0.1'
|
||||
const userAgent = req.headers.get('user-agent') || 'unknown'
|
||||
|
||||
// Hash/Anonymize IP address for strict GDPR compliance
|
||||
const anonymizedIp = ip ? createHash('sha256').update(ip).digest('hex') : null
|
||||
|
||||
// Create persistent audit log of this consent decision
|
||||
await prisma.aiConsentLog.create({
|
||||
data: {
|
||||
userId,
|
||||
consent,
|
||||
ipAddress: anonymizedIp,
|
||||
userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
// If remember is requested, persist state in UserAISettings
|
||||
if (remember) {
|
||||
await prisma.userAISettings.upsert({
|
||||
where: { userId },
|
||||
create: {
|
||||
userId,
|
||||
aiProcessingConsent: consent,
|
||||
},
|
||||
update: {
|
||||
aiProcessingConsent: consent,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('[POST /api/user/ai-consent] failed:', error)
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
512
memento-note/app/api/user/export/route.ts
Normal file
512
memento-note/app/api/user/export/route.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { promises as fs } from 'fs'
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import JSZip from 'jszip'
|
||||
import DOMPurify from 'isomorphic-dompurify'
|
||||
import type { BrainstormIdea, Note } from '@prisma/client'
|
||||
|
||||
function htmlToMarkdown(html: string): string {
|
||||
if (!html) return ''
|
||||
let md = html
|
||||
.replace(/<h1>(.*?)<\/h1>/gi, '# $1\n\n')
|
||||
.replace(/<h2>(.*?)<\/h2>/gi, '## $1\n\n')
|
||||
.replace(/<h3>(.*?)<\/h3>/gi, '### $1\n\n')
|
||||
.replace(/<strong>(.*?)<\/strong>/gi, '**$1**')
|
||||
.replace(/<em>(.*?)<\/em>/gi, '*$1*')
|
||||
.replace(/<p>(.*?)<\/p>/gi, '$1\n\n')
|
||||
.replace(/<li>(.*?)<\/li>/gi, '- $1\n')
|
||||
.replace(/<ul>/gi, '')
|
||||
.replace(/<\/ul>/gi, '\n')
|
||||
.replace(/<ol>/gi, '')
|
||||
.replace(/<\/ol>/gi, '\n')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
md = md.replace(/<[^>]*>/g, '')
|
||||
return md.trim()
|
||||
}
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[^a-zA-Z0-9_\-\s]/g, '').trim() || 'Untitled'
|
||||
}
|
||||
|
||||
function uniqueBaseName(title: string, id: string): string {
|
||||
return `${sanitizeFilename(title)}--${id.slice(0, 8)}`
|
||||
}
|
||||
|
||||
function serializeBrainstormIdeas(ideas: BrainstormIdea[]): string {
|
||||
const rendered = new Set<string>()
|
||||
const ideaIds = new Set(ideas.map((i) => i.id))
|
||||
|
||||
const formatIdea = (idea: BrainstormIdea, depth: number): string => {
|
||||
if (rendered.has(idea.id)) return ''
|
||||
rendered.add(idea.id)
|
||||
const indent = ' '.repeat(depth)
|
||||
const pos =
|
||||
idea.positionX != null && idea.positionY != null
|
||||
? ` (pos: ${idea.positionX}, ${idea.positionY})`
|
||||
: ''
|
||||
const conn = idea.connectionToSeed ? ` → seed: ${idea.connectionToSeed}` : ''
|
||||
const star = idea.isStarred ? ' ★' : ''
|
||||
const kind = idea.createdByType || 'idea'
|
||||
let block = `${indent}- **[${kind}]** ${idea.title}${star}${conn}${pos}\n`
|
||||
if (idea.description?.trim()) {
|
||||
block += `${indent} ${idea.description.trim()}\n`
|
||||
}
|
||||
for (const child of ideas.filter((i) => i.parentIdeaId === idea.id)) {
|
||||
block += formatIdea(child, depth + 1)
|
||||
}
|
||||
return block
|
||||
}
|
||||
|
||||
let md = ''
|
||||
const roots = ideas.filter(
|
||||
(i) => !i.parentIdeaId || !ideaIds.has(i.parentIdeaId)
|
||||
)
|
||||
for (const root of roots) {
|
||||
md += formatIdea(root, 0)
|
||||
}
|
||||
for (const idea of ideas) {
|
||||
if (!rendered.has(idea.id)) {
|
||||
md += formatIdea(idea, 0)
|
||||
}
|
||||
}
|
||||
return md || '_No ideas in this session._\n'
|
||||
}
|
||||
|
||||
function resolveNoteFolder(
|
||||
zip: JSZip,
|
||||
note: Note,
|
||||
notebookName: string,
|
||||
activeNotebookFolders: Map<string, JSZip>
|
||||
): JSZip {
|
||||
const notebookSegment = sanitizeFilename(notebookName)
|
||||
if (note.isArchived) {
|
||||
return zip.folder(`archive/${notebookSegment}`)!
|
||||
}
|
||||
if (note.trashedAt) {
|
||||
return zip.folder(`trash/${notebookSegment}`)!
|
||||
}
|
||||
let folder = activeNotebookFolders.get(notebookSegment)
|
||||
if (!folder) {
|
||||
folder = zip.folder(notebookSegment)!
|
||||
activeNotebookFolders.set(notebookSegment, folder)
|
||||
}
|
||||
return folder
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const zip = new JSZip()
|
||||
|
||||
const [allNotes, notebooks, labels, attachments, brainstorms] = await Promise.all([
|
||||
prisma.note.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.notebook.findMany({
|
||||
where: { userId },
|
||||
orderBy: { name: 'asc' },
|
||||
}),
|
||||
prisma.label.findMany({
|
||||
where: { userId },
|
||||
include: { notes: { select: { id: true } } },
|
||||
}),
|
||||
prisma.noteAttachment.findMany({
|
||||
where: { note: { userId } },
|
||||
}),
|
||||
prisma.brainstormSession.findMany({
|
||||
where: { userId },
|
||||
include: { ideas: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
])
|
||||
|
||||
const notebookMap = new Map(notebooks.map((nb) => [nb.id, nb.name]))
|
||||
|
||||
const noteLabelMap = new Map<string, string[]>()
|
||||
for (const label of labels) {
|
||||
for (const note of label.notes) {
|
||||
const arr = noteLabelMap.get(note.id) || []
|
||||
arr.push(label.name)
|
||||
noteLabelMap.set(note.id, arr)
|
||||
}
|
||||
}
|
||||
|
||||
const metadataJson = {
|
||||
version: '3.0.0',
|
||||
exportDate: new Date().toISOString(),
|
||||
user: {
|
||||
id: userId,
|
||||
email: session.user.email ?? '',
|
||||
name: session.user.name ?? '',
|
||||
},
|
||||
notebooks: notebooks.map((n) => ({ id: n.id, name: n.name, color: n.color })),
|
||||
labels: labels.map((l) => ({ id: l.id, name: l.name, color: l.color })),
|
||||
notes: allNotes.map((n) => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
isPinned: n.isPinned,
|
||||
isArchived: n.isArchived,
|
||||
trashedAt: n.trashedAt,
|
||||
createdAt: n.createdAt,
|
||||
updatedAt: n.updatedAt,
|
||||
notebookId: n.notebookId,
|
||||
labels: noteLabelMap.get(n.id) || [],
|
||||
})),
|
||||
brainstorms: brainstorms.map((b) => ({
|
||||
id: b.id,
|
||||
title: b.title,
|
||||
createdAt: b.createdAt,
|
||||
ideasCount: b.ideas.length,
|
||||
})),
|
||||
}
|
||||
zip.file('metadata.json', JSON.stringify(metadataJson, null, 2))
|
||||
|
||||
const activeNotebookFolders = new Map<string, JSZip>()
|
||||
|
||||
for (const note of allNotes) {
|
||||
const notebookName = note.notebookId
|
||||
? notebookMap.get(note.notebookId) || 'Unsorted'
|
||||
: 'Unsorted'
|
||||
const folder = resolveNoteFolder(zip, note, notebookName, activeNotebookFolders)
|
||||
const noteTags = noteLabelMap.get(note.id) || []
|
||||
const fileBase = uniqueBaseName(note.title || 'Untitled Note', note.id)
|
||||
|
||||
let mdContent = '---\n'
|
||||
mdContent += `title: "${note.title || 'Untitled'}"\n`
|
||||
mdContent += `id: "${note.id}"\n`
|
||||
mdContent += `createdAt: "${note.createdAt.toISOString()}"\n`
|
||||
mdContent += `updatedAt: "${note.updatedAt.toISOString()}"\n`
|
||||
mdContent += `tags: [${noteTags.map((t) => `"${t}"`).join(', ')}]\n`
|
||||
mdContent += `notebook: "${notebookName}"\n`
|
||||
mdContent += `pinned: ${note.isPinned}\n`
|
||||
mdContent += `archived: ${note.isArchived}\n`
|
||||
mdContent += '---\n\n'
|
||||
mdContent += htmlToMarkdown(note.content || '')
|
||||
|
||||
folder.file(`${fileBase}.md`, mdContent)
|
||||
folder.file(`${fileBase}.json`, JSON.stringify(note, null, 2))
|
||||
}
|
||||
|
||||
const canvasFolder = zip.folder('canvases')!
|
||||
for (const brainstorm of brainstorms) {
|
||||
const fileBase = uniqueBaseName(brainstorm.title || 'Untitled Brainstorm', brainstorm.id)
|
||||
|
||||
let canvasMd = `# Brainstorm Canvas: ${brainstorm.title || 'Untitled'}\n\n`
|
||||
canvasMd += `- **Created At:** ${brainstorm.createdAt.toISOString()}\n`
|
||||
canvasMd += `- **Updated At:** ${brainstorm.updatedAt.toISOString()}\n`
|
||||
canvasMd += `- **Ideas Count:** ${brainstorm.ideas.length}\n\n`
|
||||
canvasMd += `## Ideas and Connections\n\n`
|
||||
canvasMd += serializeBrainstormIdeas(brainstorm.ideas)
|
||||
|
||||
canvasFolder.file(`${fileBase}.md`, canvasMd)
|
||||
canvasFolder.file(`${fileBase}.json`, JSON.stringify(brainstorm, null, 2))
|
||||
}
|
||||
|
||||
const attachmentsFolder = zip.folder('attachments')!
|
||||
for (const attachment of attachments) {
|
||||
try {
|
||||
const fileContent = await fs.readFile(attachment.filePath)
|
||||
const safeName = `${attachment.id.slice(0, 8)}-${sanitizeFilename(attachment.fileName)}`
|
||||
attachmentsFolder.file(safeName, fileContent)
|
||||
} catch (err) {
|
||||
console.error(`[Export] Failed to pack attachment file: ${attachment.filePath}`, err)
|
||||
}
|
||||
}
|
||||
|
||||
const offlineNotes = allNotes.map((n) => ({
|
||||
title: n.title || 'Untitled Note',
|
||||
notebook: n.notebookId ? notebookMap.get(n.notebookId) || 'Unsorted' : 'Unsorted',
|
||||
tags: noteLabelMap.get(n.id) || [],
|
||||
content: DOMPurify.sanitize(n.content || ''),
|
||||
createdAt: n.createdAt.toISOString(),
|
||||
updatedAt: n.updatedAt.toISOString(),
|
||||
archived: n.isArchived,
|
||||
trashed: n.trashedAt !== null,
|
||||
}))
|
||||
|
||||
const offlineCanvases = brainstorms.map((b) => ({
|
||||
title: b.title || 'Untitled Brainstorm',
|
||||
createdAt: b.createdAt.toISOString(),
|
||||
ideas: b.ideas.map((i) => ({
|
||||
title: i.title,
|
||||
description: i.description,
|
||||
kind: i.createdByType || 'idea',
|
||||
x: i.positionX,
|
||||
y: i.positionY,
|
||||
isStarred: i.isStarred,
|
||||
connectionToSeed: i.connectionToSeed,
|
||||
parentIdeaId: i.parentIdeaId,
|
||||
})),
|
||||
}))
|
||||
|
||||
const indexHtml = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Memento Workspace Export Browser</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #F8F9FA;
|
||||
--card-bg: #FFFFFF;
|
||||
--border: #E9ECEF;
|
||||
--text: #212529;
|
||||
--text-muted: #6C757D;
|
||||
--primary: #1C1C1C;
|
||||
--rose: #FFF5F5;
|
||||
--rose-border: #FFE3E3;
|
||||
--accent: #E9ECEF;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #121212;
|
||||
--card-bg: #1E1E1E;
|
||||
--border: #2D2D2D;
|
||||
--text: #F8F9FA;
|
||||
--text-muted: #ADB5BD;
|
||||
--primary: #FFFFFF;
|
||||
--rose: #2D1A1A;
|
||||
--rose-border: #4D2626;
|
||||
--accent: #2D2D2D;
|
||||
}
|
||||
}
|
||||
body {
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
#sidebar {
|
||||
width: 320px;
|
||||
border-right: 1px solid var(--border);
|
||||
background-color: var(--card-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#sidebar-header { padding: 24px; border-bottom: 1px solid var(--border); }
|
||||
#sidebar-header h1 { font-size: 20px; margin: 0; font-weight: 700; }
|
||||
#sidebar-header p { font-size: 11px; color: var(--text-muted); margin: 4px 0 0 0; }
|
||||
#search-bar { margin: 16px 24px; }
|
||||
#search-input {
|
||||
width: 80%;
|
||||
padding: 10px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
#sidebar-list { flex-grow: 1; overflow-y: auto; padding: 0 16px 24px 16px; }
|
||||
.section-title {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted);
|
||||
margin: 20px 8px 8px 8px;
|
||||
}
|
||||
.item-card {
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 4px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.item-card:hover { background-color: var(--bg); }
|
||||
.item-card.active { background-color: var(--accent); border-color: var(--border); }
|
||||
.item-card h3 { font-size: 13px; font-weight: 600; margin: 0; }
|
||||
.item-card p {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin: 4px 0 0 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
#content-area { flex-grow: 1; padding: 48px; overflow-y: auto; display: flex; flex-direction: column; }
|
||||
#welcome-screen { margin: auto; text-align: center; max-width: 400px; }
|
||||
#welcome-screen h2 { font-size: 28px; margin-bottom: 8px; }
|
||||
#welcome-screen p { color: var(--text-muted); font-size: 14px; line-height: 1.6; }
|
||||
#detail-container { display: none; max-width: 800px; width: 100%; margin: 0 auto; }
|
||||
.meta-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
background-color: var(--border);
|
||||
color: var(--text-muted);
|
||||
margin-right: 8px;
|
||||
}
|
||||
#detail-header h2 { font-size: 36px; margin: 16px 0 8px 0; font-weight: 700; }
|
||||
#detail-header p {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 24px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
#detail-body { font-size: 15px; line-height: 1.8; }
|
||||
.canvas-node {
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: 8px;
|
||||
background-color: var(--card-bg);
|
||||
font-size: 13px;
|
||||
}
|
||||
.starred-node { background-color: var(--rose); border-color: var(--rose-border); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="sidebar">
|
||||
<div id="sidebar-header">
|
||||
<h1>Momento</h1>
|
||||
<p>Offline Workspace Export</p>
|
||||
</div>
|
||||
<div id="search-bar">
|
||||
<input type="text" id="search-input" placeholder="Search notes..." oninput="filterItems()">
|
||||
</div>
|
||||
<div id="sidebar-list">
|
||||
<div class="section-title">Notebooks & Notes</div>
|
||||
<div id="notes-list"></div>
|
||||
<div class="section-title">Canvases</div>
|
||||
<div id="canvases-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="content-area">
|
||||
<div id="welcome-screen">
|
||||
<h2>Welcome Back</h2>
|
||||
<p>Explore your entire workspace offline. Use the sidebar to search and select your notes and canvases.</p>
|
||||
</div>
|
||||
<div id="detail-container">
|
||||
<div id="detail-header">
|
||||
<span id="notebook-badge" class="meta-badge">Notebook</span>
|
||||
<span id="date-badge" class="meta-badge">Date</span>
|
||||
<h2 id="note-title">Title</h2>
|
||||
<p id="note-meta">Meta description</p>
|
||||
</div>
|
||||
<div id="detail-body">Content</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const notes = ${JSON.stringify(offlineNotes)};
|
||||
const canvases = ${JSON.stringify(offlineCanvases)};
|
||||
const notesList = document.getElementById('notes-list');
|
||||
const canvasesList = document.getElementById('canvases-list');
|
||||
const welcomeScreen = document.getElementById('welcome-screen');
|
||||
const detailContainer = document.getElementById('detail-container');
|
||||
const noteTitle = document.getElementById('note-title');
|
||||
const dateBadge = document.getElementById('date-badge');
|
||||
const notebookBadge = document.getElementById('notebook-badge');
|
||||
const noteMeta = document.getElementById('note-meta');
|
||||
const detailBody = document.getElementById('detail-body');
|
||||
let activeIndex = -1;
|
||||
let activeType = '';
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
notesList.innerHTML = notes.map((n, i) => \`
|
||||
<div class="item-card \${activeType === 'note' && activeIndex === i ? 'active' : ''}" data-type="note" data-index="\${i}" onclick="selectItem('note', \${i})">
|
||||
<h3>\${escapeHtml(n.title)}</h3>
|
||||
<p><span>\${escapeHtml(n.notebook)}</span> <span>\${new Date(n.updatedAt).toLocaleDateString()}</span></p>
|
||||
</div>
|
||||
\`).join('');
|
||||
canvasesList.innerHTML = canvases.map((c, i) => \`
|
||||
<div class="item-card \${activeType === 'canvas' && activeIndex === i ? 'active' : ''}" data-type="canvas" data-index="\${i}" onclick="selectItem('canvas', \${i})">
|
||||
<h3>\${escapeHtml(c.title)}</h3>
|
||||
<p><span>Canvas</span> <span>\${new Date(c.createdAt).toLocaleDateString()}</span></p>
|
||||
</div>
|
||||
\`).join('');
|
||||
}
|
||||
|
||||
function selectItem(type, index) {
|
||||
activeIndex = index;
|
||||
activeType = type;
|
||||
renderList();
|
||||
welcomeScreen.style.display = 'none';
|
||||
detailContainer.style.display = 'block';
|
||||
if (type === 'note') {
|
||||
const note = notes[index];
|
||||
notebookBadge.innerText = note.notebook;
|
||||
dateBadge.innerText = new Date(note.updatedAt).toLocaleDateString();
|
||||
noteTitle.innerText = note.title;
|
||||
noteMeta.innerText = 'Created: ' + new Date(note.createdAt).toLocaleString() + ' | Tags: ' + (note.tags.join(', ') || 'None');
|
||||
detailBody.innerHTML = note.content;
|
||||
} else {
|
||||
const canvas = canvases[index];
|
||||
notebookBadge.innerText = 'Canvas';
|
||||
dateBadge.innerText = new Date(canvas.createdAt).toLocaleDateString();
|
||||
noteTitle.innerText = canvas.title;
|
||||
noteMeta.innerText = 'Total Ideas: ' + canvas.ideas.length;
|
||||
let nodesHtml = '<div style="margin-top: 24px;">';
|
||||
canvas.ideas.forEach(idea => {
|
||||
const pos = idea.x != null && idea.y != null ? ' (' + idea.x + ', ' + idea.y + ')' : '';
|
||||
const conn = idea.connectionToSeed ? ' | ' + escapeHtml(idea.connectionToSeed) : '';
|
||||
nodesHtml += '<div class="canvas-node' + (idea.isStarred ? ' starred-node' : '') + '">' +
|
||||
'<strong>[' + escapeHtml((idea.kind || 'idea').toUpperCase()) + ']</strong> ' + escapeHtml(idea.title) +
|
||||
(idea.description ? '<div style="margin-top:6px">' + escapeHtml(idea.description) + '</div>' : '') +
|
||||
'<div style="font-size: 10px; color: var(--text-muted); margin-top: 4px;">Position' + pos + conn + '</div></div>';
|
||||
});
|
||||
nodesHtml += '</div>';
|
||||
detailBody.innerHTML = nodesHtml;
|
||||
}
|
||||
}
|
||||
|
||||
function filterItems() {
|
||||
const q = document.getElementById('search-input').value.toLowerCase();
|
||||
document.querySelectorAll('#notes-list .item-card').forEach((card) => {
|
||||
const i = Number(card.dataset.index);
|
||||
const n = notes[i];
|
||||
const match = n.title.toLowerCase().includes(q) || n.notebook.toLowerCase().includes(q) || n.content.toLowerCase().includes(q);
|
||||
card.style.display = match ? 'block' : 'none';
|
||||
});
|
||||
document.querySelectorAll('#canvases-list .item-card').forEach((card) => {
|
||||
const i = Number(card.dataset.index);
|
||||
const c = canvases[i];
|
||||
const hay = (c.title + ' ' + c.ideas.map(x => x.title + ' ' + (x.description || '')).join(' ')).toLowerCase();
|
||||
card.style.display = hay.includes(q) ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
renderList();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
zip.file('index.html', indexHtml)
|
||||
|
||||
// v1: full buffer in memory (documented limit for very large workspaces)
|
||||
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' })
|
||||
|
||||
const dateString = new Date().toISOString().split('T')[0]
|
||||
return new NextResponse(zipBuffer, {
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="memento-workspace-export-${dateString}.zip"`,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[GET /api/user/export] Export failed', error)
|
||||
return NextResponse.json({ success: false, error: 'Export failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user