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:
@@ -1,16 +1,29 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { getAllNotes } from '@/app/actions/notes'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { HomeClient } from '@/components/home-client'
|
||||
import {
|
||||
NOTES_LAYOUT_COOKIE,
|
||||
NOTES_VIEW_TYPE_COOKIE,
|
||||
parseNotesLayoutMode,
|
||||
parseNotesViewType,
|
||||
} from '@/lib/notes-view-preference'
|
||||
|
||||
export default async function HomePage() {
|
||||
const [allNotes, settings] = await Promise.all([
|
||||
const [allNotes, settings, cookieStore] = await Promise.all([
|
||||
getAllNotes(),
|
||||
getAISettings(),
|
||||
cookies(),
|
||||
])
|
||||
|
||||
const initialLayoutMode = parseNotesLayoutMode(cookieStore.get(NOTES_LAYOUT_COOKIE)?.value)
|
||||
const initialViewType = parseNotesViewType(cookieStore.get(NOTES_VIEW_TYPE_COOKIE)?.value)
|
||||
|
||||
return (
|
||||
<HomeClient
|
||||
initialNotes={allNotes}
|
||||
initialLayoutMode={initialLayoutMode}
|
||||
initialViewType={initialViewType}
|
||||
initialSettings={{
|
||||
showRecentNotes: settings?.showRecentNotes !== false,
|
||||
noteHistory: settings?.noteHistory === true,
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ClusterVisualization } from '@/components/cluster-visualization'
|
||||
import { BridgeNotesDashboard } from '@/components/bridge-notes-dashboard'
|
||||
import { NetworkGraph } from '@/components/network-graph'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { motion } from 'motion/react'
|
||||
import { Sparkles, RefreshCw, Layers, Trophy, Zap, Lightbulb } from 'lucide-react'
|
||||
|
||||
interface Note {
|
||||
id: string
|
||||
title: string | null
|
||||
content: string
|
||||
clusterId?: number
|
||||
}
|
||||
|
||||
interface Cluster {
|
||||
id: string
|
||||
clusterId: number
|
||||
name?: string
|
||||
noteIds: string[]
|
||||
color?: string
|
||||
}
|
||||
|
||||
interface BridgeNote {
|
||||
noteId: string
|
||||
bridgeScore: number
|
||||
clustersConnected: number[]
|
||||
clusterNames?: string[]
|
||||
note?: {
|
||||
id: string
|
||||
title: string | null
|
||||
@@ -22,40 +33,80 @@ interface BridgeNote {
|
||||
}
|
||||
}
|
||||
|
||||
interface BridgeSuggestion {
|
||||
clusterAId: number
|
||||
clusterBId: number
|
||||
clusterAName: string
|
||||
clusterBName: string
|
||||
suggestedTitle: string
|
||||
suggestedContent: string
|
||||
justification: string
|
||||
}
|
||||
|
||||
const COLOR_PALETTE = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF']
|
||||
|
||||
export default function InsightsPage() {
|
||||
const router = useRouter()
|
||||
const [notes, setNotes] = useState<Note[]>([])
|
||||
const [clusters, setClusters] = useState<Cluster[]>([])
|
||||
const [bridgeNotes, setBridgeNotes] = useState<BridgeNote[]>([])
|
||||
const [suggestions, setSuggestions] = useState<BridgeSuggestion[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [recalculating, setRecalculating] = useState(false)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const [isCalculating, setIsCalculating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadClusters()
|
||||
loadInitialData()
|
||||
}, [])
|
||||
|
||||
const loadClusters = async () => {
|
||||
const loadInitialData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// First, try to get cached clusters
|
||||
const res = await fetch('/api/clusters')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setClusters(data.clusters || [])
|
||||
|
||||
if (data.message) {
|
||||
setMessage(data.message)
|
||||
// Check if we have clusters
|
||||
if (data.clusters && data.clusters.length > 0) {
|
||||
const clustersWithColors = data.clusters.map((c: Cluster, i: number) => ({
|
||||
...c,
|
||||
id: c.clusterId.toString(),
|
||||
color: COLOR_PALETTE[i % COLOR_PALETTE.length]
|
||||
}))
|
||||
setNotes(data.notes || [])
|
||||
setClusters(clustersWithColors)
|
||||
|
||||
// Load bridge notes
|
||||
const bridgeRes = await fetch('/api/bridge-notes?details=true')
|
||||
if (bridgeRes.ok) {
|
||||
const bridgeData = await bridgeRes.json()
|
||||
setBridgeNotes(bridgeData.bridgeNotes || [])
|
||||
}
|
||||
|
||||
// Load suggestions
|
||||
const suggestionsRes = await fetch('/api/bridge-notes/suggestions')
|
||||
if (suggestionsRes.ok) {
|
||||
const suggestionsData = await suggestionsRes.json()
|
||||
setSuggestions(suggestionsData.suggestions || [])
|
||||
}
|
||||
} else {
|
||||
// No clusters - trigger calculation if we have enough notes
|
||||
if (data.totalNotes >= 10) {
|
||||
await performAnalysis()
|
||||
} else {
|
||||
// Not enough notes - show empty state
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading clusters:', error)
|
||||
setMessage('Failed to load clusters')
|
||||
console.error('Error loading data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const recalculateClusters = async () => {
|
||||
setRecalculating(true)
|
||||
const performAnalysis = async () => {
|
||||
setIsCalculating(true)
|
||||
try {
|
||||
const res = await fetch('/api/clusters', {
|
||||
method: 'POST',
|
||||
@@ -65,143 +116,214 @@ export default function InsightsPage() {
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setClusters(data.clusters || [])
|
||||
const clustersWithColors = (data.clusters || []).map((c: Cluster, i: number) => ({
|
||||
...c,
|
||||
id: c.clusterId.toString(),
|
||||
color: COLOR_PALETTE[i % COLOR_PALETTE.length]
|
||||
}))
|
||||
setNotes(data.notes || [])
|
||||
setClusters(clustersWithColors)
|
||||
setBridgeNotes(data.bridgeNotes || [])
|
||||
setMessage(data.message || 'Clusters recalculated successfully')
|
||||
|
||||
// Load suggestions (they were generated during POST)
|
||||
const suggestionsRes = await fetch('/api/bridge-notes/suggestions')
|
||||
if (suggestionsRes.ok) {
|
||||
const suggestionsData = await suggestionsRes.json()
|
||||
setSuggestions(suggestionsData.suggestions || [])
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error recalculating clusters:', error)
|
||||
setMessage('Failed to recalculate clusters')
|
||||
console.error('Error running analysis:', error)
|
||||
} finally {
|
||||
setRecalculating(false)
|
||||
setIsCalculating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNoteClick = (noteId: string, type: 'note' | 'cluster') => {
|
||||
if (type === 'note') {
|
||||
router.push(`/home?note=${noteId}`)
|
||||
}
|
||||
const handleNoteClick = (noteId: string) => {
|
||||
router.push(`/home?note=${noteId}`)
|
||||
}
|
||||
|
||||
const bridgeList = bridgeNotes.map(b => ({
|
||||
...b,
|
||||
title: b.note?.title || 'Unknown Note'
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="h-full flex flex-col bg-paper dark:bg-[#0D0D0D] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Insights
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Discover thematic clusters and connections in your notes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Bar */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-sm text-gray-500">Total Clusters</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{clusters.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-sm text-gray-500">Bridge Notes</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{bridgeNotes.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-sm text-gray-500">Notes Analyzed</div>
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{clusters.reduce((sum, c) => sum + c.noteIds.length, 0)}
|
||||
<div className="p-8 border-b border-border/40 flex items-center justify-between backdrop-blur-xl bg-white/40 dark:bg-black/20 z-10">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="w-8 h-8 rounded-lg bg-indigo-500/10 flex items-center justify-center text-indigo-500">
|
||||
<Sparkles size={18} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-serif font-medium text-ink dark:text-dark-ink">Semantic Insights</h1>
|
||||
</div>
|
||||
<p className="text-[11px] text-concrete tracking-[0.2em] uppercase font-bold">Discovering the hidden architecture of your knowledge</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<button
|
||||
onClick={recalculateClusters}
|
||||
disabled={recalculating || loading}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{recalculating ? 'Calculating...' : 'Recalculate'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={performAnalysis}
|
||||
disabled={isCalculating}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-ink text-paper dark:bg-white dark:text-black rounded-full text-xs font-bold uppercase tracking-widest hover:scale-105 active:scale-95 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isCalculating ? <RefreshCw size={14} className="animate-spin" /> : <RefreshCw size={14} />}
|
||||
{isCalculating ? 'Mapping...' : 'Re-sync Network'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
{message && (
|
||||
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-sm text-blue-800">{message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="bg-white rounded-lg shadow p-12">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
|
||||
<p className="text-gray-600">Analyzing your notes...</p>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-center"
|
||||
>
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-500 mx-auto mb-4"></div>
|
||||
<p className="text-concrete">Analyzing your notes...</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && clusters.length === 0 && (
|
||||
<div className="bg-white rounded-lg shadow p-12">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<svg className="w-24 h-24 text-gray-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Not enough notes to analyze
|
||||
{/* Empty State - only if truly no notes */}
|
||||
{!loading && clusters.length === 0 && !isCalculating && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center max-w-md">
|
||||
<div className="w-24 h-24 rounded-full bg-concrete/10 flex items-center justify-center mx-auto mb-6">
|
||||
<Sparkles size={40} className="text-concrete/40" />
|
||||
</div>
|
||||
<h3 className="text-xl font-serif font-medium text-ink dark:text-dark-ink mb-2">
|
||||
Discover your knowledge clusters
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Create at least 10 notes to start discovering clusters and connections
|
||||
<p className="text-concrete mb-6">
|
||||
Click "Re-sync Network" to analyze your notes and find hidden connections
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push('/home')}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Create Notes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
{!loading && clusters.length > 0 && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Visualization */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<h2 className="text-lg font-semibold mb-4">Cluster Visualization</h2>
|
||||
<ClusterVisualization
|
||||
clusters={clusters}
|
||||
bridgeNotes={bridgeNotes}
|
||||
onNodeClick={handleNoteClick}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left: Graph View */}
|
||||
<div className="flex-[1.5] p-6 relative">
|
||||
<NetworkGraph
|
||||
notes={notes}
|
||||
clusters={clusters}
|
||||
bridgeNotes={bridgeNotes}
|
||||
onNoteSelect={handleNoteClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dashboard */}
|
||||
<div className="lg:col-span-1">
|
||||
<BridgeNotesDashboard onNoteClick={(noteId) => handleNoteClick(noteId, 'note')} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cluster List */}
|
||||
{!loading && clusters.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-lg font-semibold mb-4">All Clusters</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{clusters.map((cluster) => (
|
||||
<div
|
||||
key={cluster.clusterId}
|
||||
className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow cursor-pointer"
|
||||
>
|
||||
<h3 className="font-semibold text-gray-900 mb-1">
|
||||
{cluster.name || `Cluster ${cluster.clusterId}`}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{cluster.noteIds.length} {cluster.noteIds.length === 1 ? 'note' : 'notes'}
|
||||
</p>
|
||||
{/* Right: Insight Dashboard */}
|
||||
<div className="flex-1 border-l border-border/40 flex flex-col h-full bg-paper/50 dark:bg-black/10 backdrop-blur-sm overflow-hidden">
|
||||
<div className="p-8 flex-1 overflow-y-auto custom-scrollbar space-y-12">
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm">
|
||||
<div className="flex items-center gap-2 text-indigo-500 mb-2">
|
||||
<Layers size={14} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">Clusters</span>
|
||||
</div>
|
||||
<div className="text-3xl font-serif font-medium text-ink dark:text-dark-ink">{clusters.length}</div>
|
||||
</div>
|
||||
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm">
|
||||
<div className="flex items-center gap-2 text-ochre mb-2">
|
||||
<Trophy size={14} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">Bridge Notes</span>
|
||||
</div>
|
||||
<div className="text-3xl font-serif font-medium text-ink dark:text-dark-ink">{bridgeNotes.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Bridge Notes Section */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-6 px-1">
|
||||
<Zap size={16} className="text-ochre" />
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Powerful Bridge Notes</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{bridgeList.map((bridge) => (
|
||||
<motion.div
|
||||
key={bridge.noteId}
|
||||
whileHover={{ x: 4 }}
|
||||
onClick={() => handleNoteClick(bridge.noteId)}
|
||||
className="p-4 rounded-xl bg-white dark:bg-white/5 border border-border hover:border-ochre/40 transition-all cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-medium text-ink dark:text-dark-ink truncate flex-1">
|
||||
{bridge.title}
|
||||
</h4>
|
||||
<span className="text-[10px] font-bold text-ochre bg-ochre/10 px-2 py-0.5 rounded-full">
|
||||
Score: {(bridge.bridgeScore * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{bridge.clusterNames?.map((name, i) => {
|
||||
const cluster = clusters.find(c => c.name === name)
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: cluster?.color || '#cbd5e1' }} />
|
||||
<span className="text-[9px] text-concrete font-medium whitespace-nowrap">{name}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
{bridgeList.length === 0 && !isCalculating && (
|
||||
<div className="text-xs text-concrete italic">No significant bridge notes found yet. Deepen your research to find new connections.</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Connection Suggestions */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-6 px-1">
|
||||
<Lightbulb size={16} className="text-indigo-500" />
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Missing Links (AI Generated)</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{suggestions.map((s, idx) => (
|
||||
<div key={`${s.clusterAId}-${s.clusterBId}`} className="p-6 rounded-2xl bg-gradient-to-br from-indigo-500/5 to-transparent border border-indigo-500/10 hover:border-indigo-500/30 transition-all">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex -space-x-2">
|
||||
<div className="w-6 h-6 rounded-full border-2 border-paper bg-indigo-500 flex items-center justify-center text-[10px] text-white">A</div>
|
||||
<div className="w-6 h-6 rounded-full border-2 border-paper bg-ochre flex items-center justify-center text-[10px] text-white">B</div>
|
||||
</div>
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest text-indigo-500/60">
|
||||
Bridging {s.clusterAName} & {s.clusterBName}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="text-base font-serif font-medium text-ink dark:text-dark-ink mb-2">{s.suggestedTitle}</h4>
|
||||
<p className="text-xs text-muted-ink leading-relaxed mb-4">{s.suggestedContent}</p>
|
||||
<div className="p-3 bg-white/40 dark:bg-white/5 rounded-xl border border-border/40 text-[10px] italic text-concrete flex gap-2">
|
||||
<Zap size={12} className="shrink-0" />
|
||||
<span>{s.justification}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{suggestions.length === 0 && !isCalculating && (
|
||||
<div className="text-center py-8 text-concrete">
|
||||
<Lightbulb size={24} className="mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">No connection suggestions yet</p>
|
||||
<p className="text-xs mt-1">All your clusters may already be connected!</p>
|
||||
</div>
|
||||
)}
|
||||
{isCalculating && (
|
||||
<div className="animate-pulse space-y-4">
|
||||
{[1, 2].map(i => (
|
||||
<div key={i} className="h-32 bg-indigo-500/5 rounded-2xl border border-indigo-500/10" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -30,7 +30,11 @@ export default async function MainLayout({
|
||||
const showAIAssistant = aiSettings?.paragraphRefactor !== false;
|
||||
|
||||
return (
|
||||
<ProvidersWrapper initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
|
||||
<ProvidersWrapper
|
||||
initialLanguage={initialLanguage}
|
||||
initialTranslations={initialTranslations}
|
||||
initialAiProcessingConsent={aiSettings?.aiProcessingConsent === true}
|
||||
>
|
||||
{/* No top-bar header — sidebar-only navigation (architectural-grid design) */}
|
||||
<div className="flex h-screen overflow-hidden bg-memento-desk dark:bg-background">
|
||||
<Suspense fallback={<div className="hidden w-80 shrink-0 md:block" />}>
|
||||
|
||||
@@ -7,6 +7,11 @@ import { useLanguage } from '@/lib/i18n'
|
||||
import { toast } from 'sonner'
|
||||
import { Palette, Type, LayoutGrid, Maximize } from 'lucide-react'
|
||||
import { applyDocumentTheme, normalizeThemeId, type ThemeId } from '@/lib/apply-document-theme'
|
||||
import {
|
||||
NOTES_LAYOUT_STORAGE_KEY,
|
||||
parseNotesLayoutMode,
|
||||
setNotesLayoutPreference,
|
||||
} from '@/lib/notes-view-preference'
|
||||
import { motion } from 'motion/react'
|
||||
|
||||
const PRESET_COLORS = [
|
||||
@@ -38,6 +43,11 @@ export function AppearanceSettingsClient({
|
||||
const [fontSize, setFontSize] = useState(initialFontSize || 'medium')
|
||||
const [fontFamily, setFontFamily] = useState(initialFontFamily)
|
||||
const [accentColor, setAccentColor] = useState(initialAccentColor)
|
||||
const [notesLayout, setNotesLayout] = useState<'grid' | 'list' | 'table'>('list')
|
||||
|
||||
useEffect(() => {
|
||||
setNotesLayout(parseNotesLayoutMode(localStorage.getItem(NOTES_LAYOUT_STORAGE_KEY)))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty('--color-brand-accent', accentColor)
|
||||
@@ -83,6 +93,14 @@ export function AppearanceSettingsClient({
|
||||
toast.success(t('settings.settingsSaved'))
|
||||
}
|
||||
|
||||
const handleNotesLayoutChange = (value: string) => {
|
||||
const layout = value === 'grid' ? 'grid' : value === 'table' ? 'table' : 'list'
|
||||
setNotesLayout(layout)
|
||||
setNotesLayoutPreference(layout)
|
||||
window.dispatchEvent(new CustomEvent('memento-notes-layout-change', { detail: { layout } }))
|
||||
toast.success(t('settings.settingsSaved'))
|
||||
}
|
||||
|
||||
const SelectCard = ({
|
||||
icon: Icon,
|
||||
title,
|
||||
@@ -275,6 +293,19 @@ export function AppearanceSettingsClient({
|
||||
]}
|
||||
onChange={handleFontFamilyChange}
|
||||
/>
|
||||
|
||||
<SelectCard
|
||||
icon={LayoutGrid}
|
||||
title={t('settings.notesViewLabel')}
|
||||
description={t('settings.notesViewDescription')}
|
||||
value={notesLayout === 'grid' ? 'masonry' : notesLayout === 'table' ? 'table' : 'list'}
|
||||
options={[
|
||||
{ value: 'masonry', label: t('settings.notesViewMasonry') },
|
||||
{ value: 'list', label: t('settings.notesViewList') },
|
||||
{ value: 'table', label: t('settings.notesViewTable') },
|
||||
]}
|
||||
onChange={(v) => handleNotesLayoutChange(v === 'masonry' ? 'grid' : v === 'table' ? 'table' : 'list')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Download, Upload, Trash2, Loader2, RefreshCw, Sparkles, Database, ShieldAlert } from 'lucide-react'
|
||||
import { Download, Upload, Trash2, Loader2, RefreshCw, Sparkles, Database, ShieldAlert, FolderArchive } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useSession } from 'next-auth/react'
|
||||
@@ -20,6 +20,39 @@ export default function DataSettingsPage() {
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [isReindexing, setIsReindexing] = useState(false)
|
||||
const [isCleaningUp, setIsCleaningUp] = useState(false)
|
||||
const [isZipExporting, setIsZipExporting] = useState(false)
|
||||
|
||||
const handleZipExport = async () => {
|
||||
setIsZipExporting(true)
|
||||
try {
|
||||
const response = await fetch('/api/user/export')
|
||||
if (!response.ok) {
|
||||
let message = t('dataManagement.zipExport.failed')
|
||||
try {
|
||||
const body = (await response.json()) as { error?: string }
|
||||
if (body.error) message = body.error
|
||||
} catch {
|
||||
/* non-JSON error body */
|
||||
}
|
||||
toast.error(message)
|
||||
return
|
||||
}
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `memento-workspace-export-${new Date().toISOString().split('T')[0]}.zip`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
toast.success(t('dataManagement.zipExport.success'))
|
||||
} catch {
|
||||
toast.error(t('dataManagement.zipExport.failed'))
|
||||
} finally {
|
||||
setIsZipExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true)
|
||||
@@ -135,6 +168,18 @@ export default function DataSettingsPage() {
|
||||
onAction: handleExport,
|
||||
btnClass: 'bg-ink text-paper shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
|
||||
},
|
||||
{
|
||||
icon: FolderArchive,
|
||||
iconColor: 'text-amber-600 dark:text-amber-400',
|
||||
iconBg: 'bg-amber-500/10 dark:bg-amber-500/20',
|
||||
title: t('dataManagement.zipExport.title'),
|
||||
description: t('dataManagement.zipExport.description'),
|
||||
loading: isZipExporting,
|
||||
loadingText: t('dataManagement.zipExporting'),
|
||||
buttonText: t('dataManagement.zipExport.button'),
|
||||
onAction: handleZipExport,
|
||||
btnClass: 'bg-ink text-paper shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
|
||||
},
|
||||
{
|
||||
icon: Upload,
|
||||
iconColor: 'text-emerald-600 dark:text-emerald-400',
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { updateAISettings } from '@/app/actions/ai-settings'
|
||||
import { toast } from 'sonner'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Globe, Bell, Shield } from 'lucide-react'
|
||||
import { Globe, Bell, Shield, Brain, HelpCircle } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { openCookiePreferences } from '@/lib/consent/cookie-consent'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
||||
interface GeneralSettingsClientProps {
|
||||
@@ -21,6 +25,7 @@ interface GeneralSettingsClientProps {
|
||||
|
||||
export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClientProps) {
|
||||
const { t, setLanguage: setContextLanguage } = useLanguage()
|
||||
const { hasAiConsent, revokeConsent, requestAiConsent } = useAiConsent()
|
||||
const router = useRouter()
|
||||
const [language, setLanguage] = useState(initialSettings.preferredLanguage || 'auto')
|
||||
const [emailNotifications, setEmailNotifications] = useState(initialSettings.emailNotifications ?? false)
|
||||
@@ -196,6 +201,82 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-xl p-8 space-y-6 md:col-span-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-concrete border border-border">
|
||||
<Brain size={18} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-base font-bold text-ink">{t('consent.ai.revocationTitle')}</h4>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex text-concrete hover:text-ink transition-colors"
|
||||
aria-label={t('consent.ai.helpAriaLabel')}
|
||||
>
|
||||
<HelpCircle size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-sm text-balance leading-relaxed">
|
||||
{t('consent.ai.helpTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="text-[11px] text-concrete max-w-2xl">{t('consent.ai.revocationDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider border',
|
||||
hasAiConsent
|
||||
? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/20'
|
||||
: 'bg-concrete/10 text-concrete border-border'
|
||||
)}
|
||||
>
|
||||
{hasAiConsent ? t('consent.ai.statusActive') : t('consent.ai.statusInactive')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!hasAiConsent && (
|
||||
<div className="rounded-xl border border-border/60 bg-paper/50 dark:bg-black/20 p-5 space-y-3 text-left">
|
||||
<p className="text-xs font-semibold text-ink">{t('consent.ai.whatItMeansTitle')}</p>
|
||||
<p className="text-[11px] text-concrete leading-relaxed">{t('consent.ai.inactiveHint')}</p>
|
||||
<ul className="text-[11px] text-concrete leading-relaxed list-disc pl-4 space-y-1">
|
||||
<li>{t('consent.ai.noCommercialUse')}</li>
|
||||
<li>{t('consent.ai.affectedFeatures')}</li>
|
||||
<li>{t('consent.ai.dataPortabilityHint')}</li>
|
||||
</ul>
|
||||
<Link
|
||||
href="/settings/data"
|
||||
className="inline-flex text-[11px] font-semibold text-ink underline underline-offset-2 hover:opacity-80"
|
||||
>
|
||||
{t('consent.ai.dataPortabilityLink')} →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 pt-1">
|
||||
{hasAiConsent ? (
|
||||
<button
|
||||
onClick={() => revokeConsent()}
|
||||
className="flex-1 px-5 py-3.5 bg-white dark:bg-white/10 border border-border rounded-xl text-xs font-bold uppercase tracking-[0.25em] text-ink dark:text-paper hover:scale-[1.01] active:scale-95 transition-all duration-300 shadow-sm"
|
||||
>
|
||||
{t('consent.ai.revokeButton')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => requestAiConsent()}
|
||||
className="flex-1 px-5 py-3.5 bg-ink text-paper border border-border rounded-xl text-xs font-bold uppercase tracking-[0.25em] hover:scale-[1.01] active:scale-95 transition-all duration-300 shadow-sm"
|
||||
>
|
||||
{t('consent.ai.grantButton')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ export type UserAISettingsData = {
|
||||
noteHistoryMode?: 'manual' | 'auto'
|
||||
fontFamily?: 'inter' | 'playfair' | 'jetbrains' | 'system'
|
||||
autoSave?: boolean
|
||||
aiProcessingConsent?: boolean
|
||||
}
|
||||
|
||||
/** Only fields that exist on `UserAISettings` in Prisma (excludes e.g. `theme`, which lives on `User`). */
|
||||
@@ -47,6 +48,7 @@ const USER_AI_SETTINGS_PRISMA_KEYS = [
|
||||
'noteHistoryMode',
|
||||
'fontFamily',
|
||||
'autoSave',
|
||||
'aiProcessingConsent',
|
||||
] as const
|
||||
|
||||
type UserAISettingsPrismaKey = (typeof USER_AI_SETTINGS_PRISMA_KEYS)[number]
|
||||
@@ -150,6 +152,7 @@ const getCachedAISettings = unstable_cache(
|
||||
noteHistoryMode: 'manual' as const,
|
||||
fontFamily: 'inter' as const,
|
||||
autoSave: true,
|
||||
aiProcessingConsent: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +178,7 @@ const getCachedAISettings = unstable_cache(
|
||||
noteHistoryMode: (settings.noteHistoryMode ?? 'manual') as 'manual' | 'auto',
|
||||
fontFamily: (settings.fontFamily || 'inter') as 'inter' | 'playfair' | 'jetbrains' | 'system',
|
||||
autoSave: settings.autoSave ?? true,
|
||||
aiProcessingConsent: settings.aiProcessingConsent ?? false,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting AI settings:', error)
|
||||
@@ -200,6 +204,7 @@ const getCachedAISettings = unstable_cache(
|
||||
noteHistoryMode: 'manual' as const,
|
||||
fontFamily: 'inter' as const,
|
||||
autoSave: true,
|
||||
aiProcessingConsent: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -238,6 +243,7 @@ export async function getAISettings(userId?: string) {
|
||||
noteHistoryMode: 'manual' as const,
|
||||
fontFamily: 'inter' as const,
|
||||
autoSave: true,
|
||||
aiProcessingConsent: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,10 @@ function sanitizeSvgMarkup(svg: string): string {
|
||||
* Génère une miniature SVG abstraite pour le flux éditorial (via modèle chat configuré).
|
||||
* Respecte les préférences utilisateur (assistant IA activé) et nettoie le SVG.
|
||||
*/
|
||||
export async function generateNoteIllustrationSvg(noteId: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
export async function generateNoteIllustrationSvg(
|
||||
noteId: string,
|
||||
options?: { skipRevalidation?: boolean },
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { ok: false, error: 'Non autorisé' }
|
||||
|
||||
@@ -119,7 +122,9 @@ ${plainBody.slice(0, 200)}`
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath('/home')
|
||||
if (!options?.skipRevalidation) {
|
||||
revalidatePath('/home')
|
||||
}
|
||||
return { ok: true }
|
||||
} catch (e) {
|
||||
console.error('[note-illustration]', e)
|
||||
|
||||
@@ -255,7 +255,7 @@ export async function getPendingShareCount() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function leaveSharedNote(noteId: string) {
|
||||
export async function leaveSharedNote(noteId: string, options?: { skipRevalidation?: boolean }) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
@@ -268,7 +268,9 @@ export async function leaveSharedNote(noteId: string) {
|
||||
|
||||
await prisma.noteShare.update({ where: { id: share.id }, data: { status: 'removed' } })
|
||||
|
||||
revalidatePath('/home')
|
||||
if (!options?.skipRevalidation) {
|
||||
revalidatePath('/home')
|
||||
}
|
||||
return { success: true }
|
||||
} catch (error: unknown) {
|
||||
console.error('Error leaving shared note:', error)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { auth } from '@/auth'
|
||||
import { getAIProvider } from '@/lib/ai/factory'
|
||||
import { parseNote, getHashColor } from '@/lib/utils'
|
||||
import { upsertNoteEmbedding } from '@/lib/embeddings'
|
||||
import { syncNoteLinksForNote } from '@/lib/notes/sync-note-links'
|
||||
import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config'
|
||||
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
|
||||
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
|
||||
@@ -572,18 +573,18 @@ export async function updateNote(id: string, data: {
|
||||
|
||||
if (data.content !== undefined) {
|
||||
const noteId = id
|
||||
const content = data.content
|
||||
; (async () => {
|
||||
try {
|
||||
const provider = getAIProvider(await getSystemConfig());
|
||||
const embedding = await provider.getEmbeddings(content);
|
||||
if (embedding) {
|
||||
await upsertNoteEmbedding(noteId, embedding)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[BG] Embedding regeneration failed:', e);
|
||||
const content = data.content;
|
||||
(async () => {
|
||||
try {
|
||||
const provider = getAIProvider(await getSystemConfig());
|
||||
const embedding = await provider.getEmbeddings(content);
|
||||
if (embedding) {
|
||||
await upsertNoteEmbedding(noteId, embedding);
|
||||
}
|
||||
}).catch(e => console.error('[BG] Uncaught background error:', e))
|
||||
} catch (e) {
|
||||
console.error('[BG] Embedding regeneration failed:', e);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
if ('checkItems' in data) updateData.checkItems = data.checkItems ? JSON.stringify(data.checkItems) : null
|
||||
@@ -655,6 +656,12 @@ export async function updateNote(id: string, data: {
|
||||
console.error('[HISTORY] Failed to create snapshot after update:', snapshotError)
|
||||
}
|
||||
|
||||
if (data.content !== undefined) {
|
||||
syncNoteLinksForNote(id, session.user.id, data.content).catch(err => {
|
||||
console.error('[NoteLink] sync failed after updateNote:', err)
|
||||
})
|
||||
}
|
||||
|
||||
// Only revalidate for STRUCTURAL changes that affect the page layout/lists
|
||||
// Content edits (title, content, size, color) use optimistic UI — no refresh needed
|
||||
const structuralFields = ['isPinned', 'isArchived', 'labels', 'notebookId']
|
||||
@@ -665,19 +672,19 @@ export async function updateNote(id: string, data: {
|
||||
if (!options?.skipRevalidation) {
|
||||
try { revalidatePath(`/note/${id}`) } catch {}
|
||||
try { revalidatePath('/home') } catch {}
|
||||
}
|
||||
|
||||
if (isStructuralChange) {
|
||||
if (data.isArchived !== undefined) {
|
||||
revalidatePath('/archive')
|
||||
}
|
||||
|
||||
if (data.notebookId !== undefined && data.notebookId !== oldNotebookId) {
|
||||
if (oldNotebookId) {
|
||||
revalidatePath(`/notebook/${oldNotebookId}`)
|
||||
if (isStructuralChange) {
|
||||
if (data.isArchived !== undefined) {
|
||||
revalidatePath('/archive')
|
||||
}
|
||||
if (data.notebookId) {
|
||||
revalidatePath(`/notebook/${data.notebookId}`)
|
||||
|
||||
if (data.notebookId !== undefined && data.notebookId !== oldNotebookId) {
|
||||
if (oldNotebookId) {
|
||||
revalidatePath(`/notebook/${oldNotebookId}`)
|
||||
}
|
||||
if (data.notebookId) {
|
||||
revalidatePath(`/notebook/${data.notebookId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -695,8 +702,20 @@ export async function updateNote(id: string, data: {
|
||||
}
|
||||
|
||||
// Toggle functions
|
||||
export async function togglePin(id: string, isPinned: boolean) { return updateNote(id, { isPinned }) }
|
||||
export async function toggleArchive(id: string, isArchived: boolean) { return updateNote(id, { isArchived }) }
|
||||
export async function togglePin(
|
||||
id: string,
|
||||
isPinned: boolean,
|
||||
options?: { skipRevalidation?: boolean }
|
||||
) {
|
||||
return updateNote(id, { isPinned }, options)
|
||||
}
|
||||
export async function toggleArchive(
|
||||
id: string,
|
||||
isArchived: boolean,
|
||||
options?: { skipRevalidation?: boolean }
|
||||
) {
|
||||
return updateNote(id, { isArchived }, options)
|
||||
}
|
||||
export async function updateColor(id: string, color: string) { return updateNote(id, { color }) }
|
||||
export async function updateLabels(id: string, labels: string[]) { return updateNote(id, { labels }) }
|
||||
export async function removeFusedBadge(id: string) { return updateNote(id, { autoGenerated: null, aiProvider: null }) }
|
||||
|
||||
@@ -62,6 +62,7 @@ const getCachedUserSettings = unstable_cache(
|
||||
return {
|
||||
theme: 'light' as const satisfies ThemeId,
|
||||
cardSizeMode: 'variable' as const,
|
||||
accentColor: '#A47148',
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,8 @@
|
||||
--color-memento-ink: #1C1C1C;
|
||||
--color-primary: #ACB995;
|
||||
--color-memento-accent: #D4A373;
|
||||
--color-memento-blue: #A47148;
|
||||
--color-brand-accent: #A47148;
|
||||
--color-memento-blue: var(--color-brand-accent);
|
||||
--color-memento-paper-elevated: #faf9f5;
|
||||
--color-background-light: var(--color-memento-paper);
|
||||
--color-background-dark: #202020;
|
||||
@@ -30,7 +30,7 @@
|
||||
--color-paper: var(--paper);
|
||||
--color-muted-ink: var(--muted-ink);
|
||||
--color-concrete: var(--concrete);
|
||||
--color-blueprint: #A47148;
|
||||
--color-blueprint: var(--color-brand-accent);
|
||||
--color-ochre: #D4A373;
|
||||
--color-sage: #A3B18A;
|
||||
--color-rust: #9B2226;
|
||||
@@ -1257,6 +1257,36 @@ html.font-system * {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Liens internes vers une autre note (openNote=) */
|
||||
.notion-editor-wrapper .ProseMirror a[href*="openNote="],
|
||||
.fullpage-editor .ProseMirror a[href*="openNote="] {
|
||||
color: oklch(0.48 0.12 75);
|
||||
background: oklch(0.94 0.04 85);
|
||||
border-radius: 4px;
|
||||
padding: 0 5px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
border: 1px solid oklch(0.88 0.06 85);
|
||||
}
|
||||
|
||||
.notion-editor-wrapper .ProseMirror a[href*="openNote="]:hover,
|
||||
.fullpage-editor .ProseMirror a[href*="openNote="]:hover {
|
||||
background: oklch(0.9 0.06 85);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dark .notion-editor-wrapper .ProseMirror a[href*="openNote="],
|
||||
.dark .fullpage-editor .ProseMirror a[href*="openNote="] {
|
||||
color: oklch(0.88 0.08 85);
|
||||
background: oklch(0.32 0.05 75);
|
||||
border-color: oklch(0.4 0.06 75);
|
||||
}
|
||||
|
||||
.dark .notion-editor-wrapper .ProseMirror a[href*="openNote="]:hover,
|
||||
.dark .fullpage-editor .ProseMirror a[href*="openNote="]:hover {
|
||||
background: oklch(0.38 0.06 75);
|
||||
}
|
||||
|
||||
/* --- Highlight --- */
|
||||
.notion-editor-wrapper .ProseMirror mark {
|
||||
background: oklch(0.85 0.12 90);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { auth } from "@/auth";
|
||||
import { CookieConsentRoot } from "@/components/legal/cookie-consent-root";
|
||||
import { LanguageProvider } from "@/lib/i18n/LanguageProvider";
|
||||
import Script from "next/script";
|
||||
import { getThemeScript } from "@/lib/theme-script";
|
||||
import type { CSSProperties } from "react";
|
||||
import { normalizeThemeId } from "@/lib/apply-document-theme";
|
||||
|
||||
import { Inter, Manrope, Playfair_Display, JetBrains_Mono } from "next/font/google";
|
||||
@@ -68,27 +68,6 @@ function serverHtmlThemeState(theme?: string | null): { className?: string; data
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline script that runs BEFORE React hydrates.
|
||||
* Reads the user's saved language from localStorage and sets
|
||||
* `dir` on <html> immediately — prevents RTL/LTR flash.
|
||||
*/
|
||||
const directionScript = `
|
||||
(function(){
|
||||
try {
|
||||
var lang = localStorage.getItem('user-language');
|
||||
if (!lang) {
|
||||
var c = document.cookie.split(';').map(function(s){return s.trim()}).find(function(s){return s.startsWith('user-language=')});
|
||||
if (c) lang = c.split('=')[1];
|
||||
}
|
||||
if (lang === 'fa' || lang === 'ar') {
|
||||
document.documentElement.dir = 'rtl';
|
||||
document.documentElement.lang = lang;
|
||||
}
|
||||
} catch(e) {}
|
||||
})();
|
||||
`;
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
@@ -103,25 +82,25 @@ export default async function RootLayout({
|
||||
])
|
||||
|
||||
const htmlTheme = serverHtmlThemeState(userSettings.theme)
|
||||
const serverAccent = userSettings.accentColor ?? '#A47148'
|
||||
const serverTheme = normalizeThemeId(userSettings.theme || 'light')
|
||||
const htmlStyle = {
|
||||
'--color-brand-accent': serverAccent,
|
||||
} as CSSProperties
|
||||
|
||||
return (
|
||||
<html
|
||||
suppressHydrationWarning
|
||||
className={htmlTheme.className}
|
||||
data-theme={htmlTheme.dataTheme}
|
||||
data-server-theme={serverTheme}
|
||||
data-server-accent={serverAccent}
|
||||
style={htmlStyle}
|
||||
>
|
||||
<head />
|
||||
<body className={`${inter.className} ${inter.variable} ${manrope.variable} ${playfair.variable} ${jetbrainsMono.variable}`}>
|
||||
<Script
|
||||
id="theme-init"
|
||||
strategy="beforeInteractive"
|
||||
dangerouslySetInnerHTML={{ __html: getThemeScript(userSettings.theme, userSettings.accentColor) }}
|
||||
/>
|
||||
<Script
|
||||
id="sw-cleanup"
|
||||
strategy="afterInteractive"
|
||||
dangerouslySetInnerHTML={{ __html: `if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then(function(rs){rs.forEach(function(r){r.unregister()})})}` }}
|
||||
/>
|
||||
<Script id="theme-init" src="/scripts/theme-init.js" strategy="beforeInteractive" />
|
||||
<Script id="direction-init" src="/scripts/direction-init.js" strategy="beforeInteractive" />
|
||||
<Script id="sw-cleanup" src="/scripts/sw-cleanup.js" strategy="afterInteractive" />
|
||||
<SessionProviderWrapper>
|
||||
<ErrorReporter />
|
||||
<DirectionInitializer />
|
||||
|
||||
Reference in New Issue
Block a user