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:
32
memento-note/lib/ai/semantic-proximity.ts
Normal file
32
memento-note/lib/ai/semantic-proximity.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/** Seuil Memory Echo (prod) — connexions sous ce score ne sont pas proposées. */
|
||||
export const SEMANTIC_SIMILARITY_FLOOR = 0.75
|
||||
export const SEMANTIC_SIMILARITY_FLOOR_DEMO = 0.5
|
||||
|
||||
/** Ratio 0 (seuil) → 1 (identique), pour étaler l’affichage au-dessus du seuil. */
|
||||
export function semanticProximityRatio(
|
||||
similarity: number,
|
||||
floor = SEMANTIC_SIMILARITY_FLOOR,
|
||||
): number {
|
||||
const clamped = Math.max(floor, Math.min(1, similarity))
|
||||
if (floor >= 1) return 1
|
||||
return (clamped - floor) / (1 - floor)
|
||||
}
|
||||
|
||||
/** Pourcentage « proximité » affiché (0–100), étalé entre le seuil et 100 %. */
|
||||
export function semanticProximityPercent(
|
||||
similarity: number,
|
||||
floor = SEMANTIC_SIMILARITY_FLOOR,
|
||||
): number {
|
||||
return Math.round(semanticProximityRatio(similarity, floor) * 100)
|
||||
}
|
||||
|
||||
/** Rayon orbite : plus la proximité est forte, plus le nœud est proche du centre. */
|
||||
export function semanticOrbitRadius(
|
||||
similarity: number,
|
||||
floor = SEMANTIC_SIMILARITY_FLOOR,
|
||||
): number {
|
||||
const MIN_R = 34
|
||||
const MAX_R = 94
|
||||
const t = semanticProximityRatio(similarity, floor)
|
||||
return MAX_R - t * (MAX_R - MIN_R)
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
/**
|
||||
* Chart Suggestion Service
|
||||
* Frontend service for calling the AI chart suggestions API
|
||||
* NOW WITH FAST PARSER - skips AI when possible (< 50ms vs 2 minutes)
|
||||
*/
|
||||
|
||||
import { parseChartData, generateChartSuggestions } from '@/lib/chart/parser'
|
||||
|
||||
export interface ChartSuggestion {
|
||||
type: 'bar' | 'horizontal-bar' | 'line' | 'area' | 'pie' | 'radar' | 'funnel' | 'gauge'
|
||||
title: string
|
||||
@@ -64,6 +67,7 @@ function setCached(key: string, data: SuggestChartsResponse): void {
|
||||
|
||||
/**
|
||||
* Call the AI chart suggestions API
|
||||
* FAST PATH: Try regex parser first (< 50ms), only call AI if no data found
|
||||
* @param request - The request parameters
|
||||
* @returns Promise with the chart suggestions
|
||||
*/
|
||||
@@ -78,7 +82,29 @@ export async function suggestCharts(request: SuggestChartsRequest): Promise<Sugg
|
||||
return cached
|
||||
}
|
||||
|
||||
console.log('[suggestCharts] CACHE MISS - calling API')
|
||||
console.log('[suggestCharts] CACHE MISS - trying fast parser first')
|
||||
|
||||
// FAST PATH: Try regex parser first - NO AI call needed!
|
||||
const textToParse = request.selection || request.content || ''
|
||||
const parsed = parseChartData(textToParse)
|
||||
|
||||
if (parsed.hasData && parsed.confidence > 0.3) {
|
||||
console.log('[suggestCharts] FAST PATH - regex parser found data, skipping AI!')
|
||||
const suggestions = generateChartSuggestions(parsed.data)
|
||||
|
||||
const response: SuggestChartsResponse = {
|
||||
suggestions,
|
||||
analyzedText: textToParse.substring(0, 200),
|
||||
detectedData: `${parsed.data.length} data points found`,
|
||||
hasData: true,
|
||||
}
|
||||
|
||||
// Cache the fast result
|
||||
setCached(cacheKey, response)
|
||||
return response
|
||||
}
|
||||
|
||||
console.log('[suggestCharts] Parser found no good data, calling AI API (slow...)')
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ai/suggest-charts', {
|
||||
|
||||
@@ -96,6 +96,16 @@ export class MemoryEchoService {
|
||||
* Find meaningful connections between user's notes
|
||||
*/
|
||||
async findConnections(userId: string, demoMode: boolean = false): Promise<NoteConnection[]> {
|
||||
// GDPR AI Consent check — compliance skip if not granted (AC6)
|
||||
const userSettings = await prisma.userAISettings.findUnique({
|
||||
where: { userId },
|
||||
select: { aiProcessingConsent: true },
|
||||
})
|
||||
if (!userSettings?.aiProcessingConsent) {
|
||||
console.log(`[MemoryEchoService] User ${userId} has not given AI consent. Skipping connection generation for compliance.`)
|
||||
return []
|
||||
}
|
||||
|
||||
// Ensure all notes have embeddings before searching for connections
|
||||
await this.ensureEmbeddings(userId)
|
||||
|
||||
|
||||
@@ -31,7 +31,8 @@ const RECIPES: Record<string, Recipe> = {
|
||||
|
||||
function resolveRecipe(name?: string): Recipe {
|
||||
if (!name || name === 'auto') return RECIPES['architectural-saas']
|
||||
const key = name.toLowerCase().replace(/[^a-z]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
|
||||
// Normalize: underscores to dashes for consistency
|
||||
const key = name.toLowerCase().replace(/[\s_]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
|
||||
return RECIPES[key] ?? RECIPES['architectural-saas']
|
||||
}
|
||||
|
||||
@@ -345,30 +346,45 @@ function renderRadarChart(data: { label: string; value: number }[], r: Recipe):
|
||||
const cx = 150, cy = 150, radius = 110
|
||||
const max = Math.max(...data.map(d => d.value), 1)
|
||||
const angleStep = (2 * Math.PI) / n
|
||||
|
||||
// Choose grid color based on theme darkness
|
||||
const gridColor = r.isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)'
|
||||
const labelColor = r.isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)'
|
||||
|
||||
// Grid
|
||||
const gridLevels = [0.25, 0.5, 0.75, 1].map(f => {
|
||||
const pts = Array.from({ length: n }, (_, i) => {
|
||||
const a = i * angleStep - Math.PI / 2
|
||||
return `${cx + Math.cos(a) * radius * f},${cy + Math.sin(a) * radius * f}`
|
||||
}).join(' ')
|
||||
return `<polygon points="${pts}" fill="none" stroke="${r.svgGrid}" stroke-width="1"/>`
|
||||
return `<polygon points="${pts}" fill="none" stroke="${gridColor}" stroke-width="1"/>`
|
||||
}).join('')
|
||||
|
||||
// Axis lines from center
|
||||
const axisLines = Array.from({ length: n }, (_, i) => {
|
||||
const a = i * angleStep - Math.PI / 2
|
||||
return `<line x1="${cx}" y1="${cy}" x2="${cx + Math.cos(a) * radius}" y2="${cy + Math.sin(a) * radius}" stroke="${gridColor}" stroke-width="1"/>`
|
||||
}).join('')
|
||||
|
||||
// Data polygon
|
||||
const dataPts = data.map((d, i) => {
|
||||
const a = i * angleStep - Math.PI / 2
|
||||
const r2 = (d.value / max) * radius
|
||||
return `${cx + Math.cos(a) * r2},${cy + Math.sin(a) * r2}`
|
||||
}).join(' ')
|
||||
// Labels
|
||||
|
||||
// Labels with better contrast
|
||||
const labels = data.map((d, i) => {
|
||||
const a = i * angleStep - Math.PI / 2
|
||||
const lx = cx + Math.cos(a) * (radius + 20)
|
||||
const ly = cy + Math.sin(a) * (radius + 20)
|
||||
return `<text x="${lx}" y="${ly}" text-anchor="middle" font-size="10" fill="${r.textMuted}">${esc(d.label)}</text>`
|
||||
return `<text x="${lx}" y="${ly}" text-anchor="middle" font-size="11" font-weight="600" fill="${labelColor}">${esc(d.label)}</text>`
|
||||
}).join('')
|
||||
|
||||
return `<svg viewBox="0 0 300 300" style="width:100%;max-width:320px;height:auto;margin:0 auto;display:block;">
|
||||
${axisLines}
|
||||
${gridLevels}
|
||||
<polygon points="${dataPts}" fill="${r.accent1}" fill-opacity="0.15" stroke="${r.accent1}" stroke-width="2"/>
|
||||
<polygon points="${dataPts}" fill="${r.accent1}" fill-opacity="0.2" stroke="${r.accent1}" stroke-width="2.5"/>
|
||||
${labels}
|
||||
</svg>`
|
||||
}
|
||||
|
||||
@@ -43,21 +43,21 @@ export const PALETTE_ALIASES: Record<string, string> = {
|
||||
premium: 'platinum_white_gold', clean: 'vibrant_tech', stage: 'stage_dark',
|
||||
architectural: 'architectural_mono', silk: 'minimal_silk',
|
||||
black: 'keynote', white: 'platinum_white_gold', nuit: 'galaxy', sombre: 'stage_dark',
|
||||
|
||||
// Recipe explicit theme mappings
|
||||
architectural_saas: 'architectural_mono',
|
||||
midnight_cathedral: 'keynote',
|
||||
aurora_borealis: 'galaxy',
|
||||
tokyo_neon: 'vibrant_tech',
|
||||
sunlit_gallery: 'bohemian',
|
||||
clinical_precision: 'modern_wellness',
|
||||
venture_pitch: 'vibrant_orange_mint',
|
||||
forest_floor: 'forest_eco',
|
||||
steel_glass: 'luxury_mystery',
|
||||
cyberpunk_terminal: 'tech_night',
|
||||
editorial_ink: 'vintage_academic',
|
||||
coastal_morning: 'coastal_coral',
|
||||
paper_studio: 'craft_artisan',
|
||||
|
||||
// Recipe explicit theme mappings (both underscore and dash variants)
|
||||
architectural_saas: 'architectural_mono', 'architectural-saas': 'architectural_mono',
|
||||
midnight_cathedral: 'keynote', 'midnight-cathedral': 'keynote',
|
||||
aurora_borealis: 'galaxy', 'aurora-borealis': 'galaxy',
|
||||
tokyo_neon: 'vibrant_tech', 'tokyo-neon': 'vibrant_tech',
|
||||
sunlit_gallery: 'bohemian', 'sunlit-gallery': 'bohemian',
|
||||
clinical_precision: 'modern_wellness', 'clinical-precision': 'modern_wellness',
|
||||
venture_pitch: 'vibrant_orange_mint', 'venture-pitch': 'vibrant_orange_mint',
|
||||
forest_floor: 'forest_eco', 'forest-floor': 'forest_eco',
|
||||
steel_glass: 'luxury_mystery', 'steel-glass': 'luxury_mystery',
|
||||
cyberpunk_terminal: 'tech_night', 'cyberpunk-terminal': 'tech_night',
|
||||
editorial_ink: 'vintage_academic', 'editorial-ink': 'vintage_academic',
|
||||
coastal_morning: 'coastal_coral', 'coastal-morning': 'coastal_coral',
|
||||
paper_studio: 'craft_artisan', 'paper-studio': 'craft_artisan',
|
||||
}
|
||||
|
||||
export const THEME_NAMES: Record<string, string> = {
|
||||
@@ -74,7 +74,8 @@ export const THEME_NAMES: Record<string, string> = {
|
||||
}
|
||||
|
||||
export function resolvePalette(spec: Pick<PresentationSpec, 'theme'>): { palette: Palette; key: string } {
|
||||
const name = (spec.theme || '').toLowerCase().replace(/[\s-]/g, '_')
|
||||
// Normalize theme name: handle both dashes and underscores
|
||||
const name = (spec.theme || '').toLowerCase().replace(/[\s-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')
|
||||
const key = PALETTE_ALIASES[name] || (PALETTES[name] ? name : 'keynote')
|
||||
return { palette: PALETTES[key]!, key }
|
||||
}
|
||||
|
||||
73
memento-note/lib/blocks/extract-blocks.ts
Normal file
73
memento-note/lib/blocks/extract-blocks.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export interface ExtractedBlock {
|
||||
blockId: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export function extractBlocksFromHtml(html: string): ExtractedBlock[] {
|
||||
const blocks: ExtractedBlock[] = []
|
||||
const regex = /<(?:p|h[1-6]|blockquote|li)[^>]*data-id="([^"]+)"[^>]*>([\s\S]*?)<\/(?:p|h[1-6]|blockquote|li)>/gi
|
||||
let match
|
||||
while ((match = regex.exec(html)) !== null) {
|
||||
const blockId = match[1]
|
||||
const content = match[2].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
if (content.length >= 10) {
|
||||
blocks.push({ blockId, content })
|
||||
}
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
export 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)
|
||||
}
|
||||
|
||||
function extractPlainBlocksFromHtml(html: string): ExtractedBlock[] {
|
||||
const blocks: ExtractedBlock[] = []
|
||||
const regex = /<(?:p|h[1-6]|blockquote|li|td|th|div)[^>]*>([\s\S]*?)<\/(?:p|h[1-6]|blockquote|li|td|th|div)>/gi
|
||||
let match
|
||||
while ((match = regex.exec(html)) !== null) {
|
||||
const content = match[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
if (content.length >= 10) {
|
||||
blocks.push({ blockId: '', content })
|
||||
}
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
function pickBestFromBlocks(blocks: ExtractedBlock[], hint: string): ExtractedBlock | null {
|
||||
if (blocks.length === 0) return null
|
||||
if (!hint.trim()) return blocks[0]
|
||||
|
||||
let best = blocks[0]
|
||||
let bestScore = jaccardSimilarity(hint, best.content)
|
||||
for (const block of blocks.slice(1)) {
|
||||
const score = jaccardSimilarity(hint, block.content)
|
||||
if (score > bestScore) {
|
||||
best = block
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
export function pickBestBlockForHint(html: string, hint: string): ExtractedBlock | null {
|
||||
return pickBestFromBlocks(extractBlocksFromHtml(html), hint)
|
||||
}
|
||||
|
||||
/** Fallback when notes have no data-id yet (citation statique, pas de bloc vivant). */
|
||||
export function pickBestPlainPassageForHint(html: string, hint: string): ExtractedBlock | null {
|
||||
return pickBestFromBlocks(extractPlainBlocksFromHtml(html), hint)
|
||||
}
|
||||
196
memento-note/lib/chart/parser.ts
Normal file
196
memento-note/lib/chart/parser.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Fast chart data parser - NO AI needed
|
||||
* Extracts structured data from text using regex patterns
|
||||
*/
|
||||
|
||||
export interface ChartDataPoint {
|
||||
label: string
|
||||
value: number
|
||||
}
|
||||
|
||||
export interface ParsedChartData {
|
||||
hasData: boolean
|
||||
data: ChartDataPoint[]
|
||||
suggestedType: 'bar' | 'horizontal-bar' | 'line' | 'area' | 'pie'
|
||||
confidence: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse text for chart data using multiple regex patterns
|
||||
* Runs in < 50ms - NO AI calls
|
||||
*/
|
||||
export function parseChartData(text: string): ParsedChartData {
|
||||
const patterns = [
|
||||
// Pattern 1: "Label : Value | Label2 : Value2"
|
||||
{
|
||||
regex: /([^|:|\n]+?)\s*[:|=]\s*(\d+(?:[.,]\d+)?)(?:\s*\||\n|,|$)/gi,
|
||||
priority: 1,
|
||||
},
|
||||
// Pattern 2: "Label: Value, Label2: Value2"
|
||||
{
|
||||
regex: /([^,:;\n]+?)\s*[:=]\s*(\d+(?:[.,]\d+)?)(?:\s*,|;|\n|$)/gi,
|
||||
priority: 2,
|
||||
},
|
||||
// Pattern 3: "Label - Value" or "Label — Value"
|
||||
{
|
||||
regex: /([^-—\n]+?)\s*[—-]\s*(\d+(?:[.,]\d+)?)(?:\s*,|\n|$)/gi,
|
||||
priority: 3,
|
||||
},
|
||||
// Pattern 4: Lists with percentages "Label • 45%"
|
||||
{
|
||||
regex: /([^•%\n]+?)\s*[•·]\s*(\d+)(?:\s*%)?(?:\s*,|\n|$)/gi,
|
||||
priority: 4,
|
||||
},
|
||||
]
|
||||
|
||||
const seen = new Set<string>()
|
||||
let bestMatch: { data: ChartDataPoint[], score: number, type: ParsedChartData['suggestedType'] } | null = null
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const matches: ChartDataPoint[] = []
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
// Reset regex state
|
||||
pattern.regex.lastIndex = 0
|
||||
|
||||
while ((match = pattern.regex.exec(text)) !== null) {
|
||||
const label = match[1].trim()
|
||||
const valueStr = match[2].trim().replace(',', '.')
|
||||
const value = parseFloat(valueStr)
|
||||
|
||||
if (!isNaN(value) && value > 0 && label && !seen.has(label)) {
|
||||
matches.push({ label, value })
|
||||
seen.add(label)
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length >= 2) {
|
||||
const score = matches.length * pattern.priority
|
||||
if (!bestMatch || score > bestMatch.score) {
|
||||
bestMatch = {
|
||||
data: matches,
|
||||
score,
|
||||
type: suggestChartType(matches, text),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestMatch) {
|
||||
return { hasData: false, data: [], suggestedType: 'bar', confidence: 0 }
|
||||
}
|
||||
|
||||
return {
|
||||
hasData: true,
|
||||
data: bestMatch.data,
|
||||
suggestedType: bestMatch.type,
|
||||
confidence: Math.min(bestMatch.score / 10, 1),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest chart type based on data and text context
|
||||
*/
|
||||
function suggestChartType(
|
||||
data: ChartDataPoint[],
|
||||
text: string
|
||||
): ParsedChartData['suggestedType'] {
|
||||
const textLower = text.toLowerCase()
|
||||
|
||||
// Time series indicators
|
||||
const timeKeywords = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec',
|
||||
'janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
|
||||
'q1', 'q2', 'q3', 'q4', 'month', 'mois', 'week', 'semaine', 'year', 'année', 'trend', 'tendance', 'growth', 'croissance']
|
||||
|
||||
const hasTimeLabels = data.some(d => timeKeywords.some(k => d.label.toLowerCase().includes(k)))
|
||||
const hasTimeInText = timeKeywords.some(k => textLower.includes(k))
|
||||
|
||||
// Proportion indicators
|
||||
const proportionKeywords = ['%', 'percent', 'part', 'share', 'répartition', 'distribution', 'segment', 'total']
|
||||
const hasProportion = proportionKeywords.some(k => textLower.includes(k))
|
||||
|
||||
// Check if values sum to ~100 (percentage data)
|
||||
const sum = data.reduce((a, b) => a + b.value, 0)
|
||||
const isPercentage = Math.abs(sum - 100) < 10 || data.some(d => d.value > 100)
|
||||
|
||||
// Decision tree
|
||||
if (hasProportion || isPercentage) {
|
||||
return data.length <= 5 ? 'pie' : 'bar'
|
||||
}
|
||||
|
||||
if (hasTimeLabels || hasTimeInText) {
|
||||
return 'line'
|
||||
}
|
||||
|
||||
// Long labels favor horizontal
|
||||
if (data.some(d => d.label.length > 15)) {
|
||||
return 'horizontal-bar'
|
||||
}
|
||||
|
||||
return 'bar'
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate chart suggestions from parsed data
|
||||
* NO AI - instant generation
|
||||
*/
|
||||
export function generateChartSuggestions(data: ChartDataPoint[]): Array<{
|
||||
type: 'bar' | 'horizontal-bar' | 'line' | 'area' | 'pie'
|
||||
title: string
|
||||
data: ChartDataPoint[]
|
||||
description: string
|
||||
}> {
|
||||
const suggestions: ReturnType<typeof generateChartSuggestions> = []
|
||||
|
||||
// Always include bar chart as fallback
|
||||
suggestions.push({
|
||||
type: 'bar',
|
||||
title: 'Comparaison',
|
||||
data,
|
||||
description: 'Graphique à barres pour comparer les valeurs',
|
||||
})
|
||||
|
||||
// Add line if we have enough data points
|
||||
if (data.length >= 3) {
|
||||
suggestions.push({
|
||||
type: 'line',
|
||||
title: 'Tendance',
|
||||
data,
|
||||
description: 'Graphique linéaire pour visualiser la progression',
|
||||
})
|
||||
}
|
||||
|
||||
// Add area for magnitude
|
||||
if (data.length >= 3) {
|
||||
suggestions.push({
|
||||
type: 'area',
|
||||
title: 'Évolution',
|
||||
data,
|
||||
description: 'Graphique en aires pour souligner le volume',
|
||||
})
|
||||
}
|
||||
|
||||
// Add pie if data looks proportional
|
||||
const sum = data.reduce((a, b) => a + b.value, 0)
|
||||
if (data.length >= 2 && data.length <= 6 && (sum > 90 || data.some(d => d.label.includes('%')))) {
|
||||
suggestions.push({
|
||||
type: 'pie',
|
||||
title: 'Répartition',
|
||||
data,
|
||||
description: 'Graphique circulaire pour montrer les proportions',
|
||||
})
|
||||
}
|
||||
|
||||
// Add horizontal-bar for long labels
|
||||
if (data.some(d => d.label.length > 12)) {
|
||||
suggestions.push({
|
||||
type: 'horizontal-bar',
|
||||
title: 'Comparaison',
|
||||
data,
|
||||
description: 'Barres horizontales pour les labels longs',
|
||||
})
|
||||
}
|
||||
|
||||
// Return max 3 suggestions
|
||||
return suggestions.slice(0, 3)
|
||||
}
|
||||
39
memento-note/lib/consent/ai-consent-client.ts
Normal file
39
memento-note/lib/consent/ai-consent-client.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
const LOCAL_STORAGE_KEY = 'memento-ai-consent-v1'
|
||||
|
||||
/**
|
||||
* Reads the persistent AI processing consent value from local storage.
|
||||
* Returns true if consented, false if rejected or not set.
|
||||
*/
|
||||
export function getLocalStorageAiConsent(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
try {
|
||||
const val = localStorage.getItem(LOCAL_STORAGE_KEY)
|
||||
return val === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the persistent AI processing consent value to local storage.
|
||||
*/
|
||||
export function setLocalStorageAiConsent(value: boolean): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, value ? 'true' : 'false')
|
||||
} catch (e) {
|
||||
console.error('[setLocalStorageAiConsent] Failed to write to localStorage:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the persistent AI processing consent value from local storage.
|
||||
*/
|
||||
export function removeLocalStorageAiConsent(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEY)
|
||||
} catch (e) {
|
||||
console.error('[removeLocalStorageAiConsent] Failed to remove from localStorage:', e)
|
||||
}
|
||||
}
|
||||
30
memento-note/lib/consent/server-consent.ts
Normal file
30
memento-note/lib/consent/server-consent.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* Checks if the authenticated user has explicit GDPR AI processing consent.
|
||||
* Persistent consent: UserAISettings.aiProcessingConsent
|
||||
* Session-only consent: signed JWT claim (not client headers — GDPR-safe)
|
||||
*/
|
||||
export async function hasUserAiConsent(): Promise<boolean> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (session.aiSessionConsent === true) {
|
||||
return true
|
||||
}
|
||||
|
||||
const settings = await prisma.userAISettings.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
select: { aiProcessingConsent: true },
|
||||
})
|
||||
|
||||
return settings?.aiProcessingConsent ?? false
|
||||
}
|
||||
|
||||
export function aiConsentForbiddenResponse() {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
||||
}
|
||||
@@ -73,8 +73,8 @@ export class QuotaExceededError extends Error {
|
||||
const TIER_LIMITS: Record<SubscriptionTier, Record<string, number | 'unlimited'>> = {
|
||||
BASIC: {
|
||||
semantic_search: 30,
|
||||
auto_tag: 20,
|
||||
auto_title: 10,
|
||||
auto_tag: 15,
|
||||
auto_title: 5,
|
||||
brainstorm_create: 1,
|
||||
brainstorm_expand: 10,
|
||||
brainstorm_enrich: 20,
|
||||
@@ -85,8 +85,8 @@ const TIER_LIMITS: Record<SubscriptionTier, Record<string, number | 'unlimited'>
|
||||
auto_tag: 200,
|
||||
auto_title: 200,
|
||||
reformulate: 50,
|
||||
chat: 100,
|
||||
brainstorm_create: 30,
|
||||
chat: 50,
|
||||
brainstorm_create: 5,
|
||||
brainstorm_expand: 100,
|
||||
brainstorm_enrich: 200,
|
||||
suggest_charts: 50,
|
||||
@@ -96,8 +96,8 @@ const TIER_LIMITS: Record<SubscriptionTier, Record<string, number | 'unlimited'>
|
||||
auto_tag: 1000,
|
||||
auto_title: 1000,
|
||||
reformulate: 500,
|
||||
chat: 1000,
|
||||
brainstorm_create: 200,
|
||||
chat: 500,
|
||||
brainstorm_create: 'unlimited',
|
||||
brainstorm_expand: 500,
|
||||
brainstorm_enrich: 1000,
|
||||
suggest_charts: 200,
|
||||
|
||||
@@ -58,25 +58,24 @@ export function LanguageProvider({ children, initialLanguage = 'en', initialTran
|
||||
}
|
||||
}, [initialLanguage])
|
||||
|
||||
// Load translations when language changes (with caching)
|
||||
// On first render, skip updateDocumentDirection since the inline script already set it.
|
||||
// Always reload locale JSON on the client so new i18n keys are picked up
|
||||
// (SSR initialTranslations may be stale until the next deploy).
|
||||
useEffect(() => {
|
||||
const cached = cacheRef.current.get(language)
|
||||
if (cached) {
|
||||
setTranslations(cached)
|
||||
if (!isFirstRender.current) updateDocumentDirection(language)
|
||||
isFirstRender.current = false
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
|
||||
const loadLang = async () => {
|
||||
const loaded = await loadTranslations(language)
|
||||
if (cancelled) return
|
||||
cacheRef.current.set(language, loaded)
|
||||
setTranslations(loaded)
|
||||
if (!isFirstRender.current) updateDocumentDirection(language)
|
||||
isFirstRender.current = false
|
||||
}
|
||||
|
||||
loadLang()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [language])
|
||||
|
||||
const setLanguage = useCallback((lang: SupportedLanguage) => {
|
||||
@@ -90,8 +89,10 @@ export function LanguageProvider({ children, initialLanguage = 'en', initialTran
|
||||
if (!translations) return key
|
||||
|
||||
let value: any = getTranslationValue(translations, key)
|
||||
if (value === key && language !== 'en') {
|
||||
value = getTranslationValue(enTranslations as unknown as Translations, key)
|
||||
}
|
||||
|
||||
// Replace parameters like {count}, {percentage}, etc.
|
||||
if (params && typeof value === 'string') {
|
||||
Object.entries(params).forEach(([param, paramValue]) => {
|
||||
value = value.replace(`{${param}}`, String(paramValue))
|
||||
@@ -99,7 +100,7 @@ export function LanguageProvider({ children, initialLanguage = 'en', initialTran
|
||||
}
|
||||
|
||||
return typeof value === 'string' ? value : key
|
||||
}, [translations])
|
||||
}, [translations, language])
|
||||
|
||||
const value = useMemo(() => ({ language, setLanguage, t, translations }), [language, setLanguage, t, translations])
|
||||
|
||||
|
||||
8
memento-note/lib/navigation/open-note.ts
Normal file
8
memento-note/lib/navigation/open-note.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/** Ouvre une note dans l'éditeur (nouvel onglet). Route réelle : /home?openNote=… */
|
||||
export function openNoteInNewTab(noteId: string) {
|
||||
window.open(`/home?openNote=${encodeURIComponent(noteId)}`, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
export function openNotePath(noteId: string) {
|
||||
return `/home?openNote=${encodeURIComponent(noteId)}`
|
||||
}
|
||||
23
memento-note/lib/note-change-sync.ts
Normal file
23
memento-note/lib/note-change-sync.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Note } from '@/lib/types'
|
||||
|
||||
export const NOTE_CHANGE_EVENT = 'memento-note-changed'
|
||||
export const NOTE_REQUEST_SAVE_EVENT = 'memento-request-note-save'
|
||||
|
||||
export type NoteChangeEvent =
|
||||
| { type: 'updated'; note: Note }
|
||||
| { type: 'deleted'; noteId: string; notebookId?: string | null }
|
||||
| { type: 'created'; note: Note }
|
||||
|
||||
export type NoteCollectionActions = {
|
||||
onTogglePin?: (note: Note) => void | Promise<void>
|
||||
onDeleteNote?: (note: Note) => void | Promise<void>
|
||||
onArchiveNote?: (note: Note) => void | Promise<void>
|
||||
onMoveToNotebook?: (note: Note, notebookId: string | null) => void | Promise<void>
|
||||
onNotePatch?: (noteId: string, patch: Partial<Note>) => void
|
||||
onNoteIllustrationGenerated?: (noteId: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
export function emitNoteChange(detail: NoteChangeEvent) {
|
||||
if (typeof window === 'undefined') return
|
||||
window.dispatchEvent(new CustomEvent(NOTE_CHANGE_EVENT, { detail }))
|
||||
}
|
||||
@@ -60,3 +60,16 @@ export function getNotePlainExcerpt(note: Note, maxLen = 240): string {
|
||||
raw = raw.replace(/<[^>]+>/g, ' ')
|
||||
return stripMarkdownPreview(raw, maxLen)
|
||||
}
|
||||
|
||||
/** Adapte un SVG miniature (viewBox 4:3 liste) pour remplir une vignette carte 16:10. */
|
||||
export function prepareNoteIllustrationForGrid(svg: string): string {
|
||||
return svg.replace(/<svg([^>]*)>/i, (_, attrs: string) => {
|
||||
let next = attrs.replace(/\s(width|height)=["'][^"']*["']/gi, '')
|
||||
if (/preserveAspectRatio=/i.test(next)) {
|
||||
next = next.replace(/preserveAspectRatio=["'][^"']*["']/i, 'preserveAspectRatio="xMidYMid slice"')
|
||||
} else {
|
||||
next += ' preserveAspectRatio="xMidYMid slice"'
|
||||
}
|
||||
return `<svg${next} width="100%" height="100%">`
|
||||
})
|
||||
}
|
||||
|
||||
31
memento-note/lib/notes-view-preference.ts
Normal file
31
memento-note/lib/notes-view-preference.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { NotesLayoutMode, NotesViewType } from '@/components/notes-list-views'
|
||||
|
||||
export const NOTES_LAYOUT_COOKIE = 'memento-notes-layout'
|
||||
export const NOTES_VIEW_TYPE_COOKIE = 'memento-notes-view-type'
|
||||
export const NOTES_LAYOUT_STORAGE_KEY = 'memento-notes-layout'
|
||||
export const NOTES_VIEW_TYPE_STORAGE_KEY = 'memento-notes-view-type'
|
||||
|
||||
const LAYOUT_VALUES: NotesLayoutMode[] = ['grid', 'list', 'table']
|
||||
const VIEW_TYPE_VALUES: NotesViewType[] = ['notes', 'tasks']
|
||||
|
||||
export function parseNotesLayoutMode(value: string | undefined | null): NotesLayoutMode {
|
||||
if (value && (LAYOUT_VALUES as string[]).includes(value)) return value as NotesLayoutMode
|
||||
return 'list'
|
||||
}
|
||||
|
||||
export function parseNotesViewType(value: string | undefined | null): NotesViewType {
|
||||
if (value && (VIEW_TYPE_VALUES as string[]).includes(value)) return value as NotesViewType
|
||||
return 'notes'
|
||||
}
|
||||
|
||||
export function setNotesLayoutPreference(layout: NotesLayoutMode) {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.setItem(NOTES_LAYOUT_STORAGE_KEY, layout)
|
||||
document.cookie = `${NOTES_LAYOUT_COOKIE}=${layout}; path=/; max-age=31536000; SameSite=Lax`
|
||||
}
|
||||
|
||||
export function setNotesViewTypePreference(viewType: NotesViewType) {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.setItem(NOTES_VIEW_TYPE_STORAGE_KEY, viewType)
|
||||
document.cookie = `${NOTES_VIEW_TYPE_COOKIE}=${viewType}; path=/; max-age=31536000; SameSite=Lax`
|
||||
}
|
||||
138
memento-note/lib/notes/sync-note-links.ts
Normal file
138
memento-note/lib/notes/sync-note-links.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
/** [[Titre]] ou [[Titre|noteId]] */
|
||||
const WIKILINK_RE = /\[\[([^\]|#]+?)(?:\|([^\]|#]+?))?(?:[#][^\]]+)?\]\]/g
|
||||
|
||||
const OPEN_NOTE_IN_HREF_RE = /href\s*=\s*["']([^"']*openNote=([^"'&#]+)[^"']*)["']/gi
|
||||
|
||||
export interface ParsedNoteLinkTarget {
|
||||
title: string
|
||||
noteId?: string
|
||||
snippet: string
|
||||
}
|
||||
|
||||
export function extractNoteLinkTargets(content: string): ParsedNoteLinkTarget[] {
|
||||
const results: ParsedNoteLinkTarget[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const push = (title: string, noteId: string | undefined, snippet: string) => {
|
||||
const key = noteId ? `id:${noteId}` : `title:${title.toLowerCase()}`
|
||||
if (!title && !noteId) return
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
results.push({ title: title || 'Sans titre', noteId, snippet })
|
||||
}
|
||||
|
||||
const plain = content.replace(/<[^>]+>/g, ' ')
|
||||
let match: RegExpExecArray | null
|
||||
WIKILINK_RE.lastIndex = 0
|
||||
while ((match = WIKILINK_RE.exec(plain)) !== null) {
|
||||
const title = match[1].trim()
|
||||
const noteId = match[2]?.trim()
|
||||
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()
|
||||
push(title, noteId, snippet)
|
||||
}
|
||||
|
||||
OPEN_NOTE_IN_HREF_RE.lastIndex = 0
|
||||
while ((match = OPEN_NOTE_IN_HREF_RE.exec(content)) !== null) {
|
||||
const href = match[1]
|
||||
const noteId = decodeURIComponent(match[2].trim())
|
||||
const idx = content.indexOf(href)
|
||||
const start = Math.max(0, idx - 50)
|
||||
const end = Math.min(content.length, idx + href.length + 50)
|
||||
const snippet = content.slice(start, end).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
push('', noteId, snippet)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/** Parse [[wikilinks]] + liens internes openNote, synchronise la table NoteLink. */
|
||||
export async function syncNoteLinksForNote(
|
||||
noteId: string,
|
||||
userId: string,
|
||||
content: string,
|
||||
): Promise<number> {
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id: noteId },
|
||||
select: { id: true, userId: true },
|
||||
})
|
||||
if (!note || note.userId !== userId) return 0
|
||||
|
||||
const targets = extractNoteLinkTargets(content)
|
||||
const upsertedIds: string[] = []
|
||||
|
||||
for (const target of targets) {
|
||||
let targetNoteId = target.noteId
|
||||
|
||||
if (targetNoteId) {
|
||||
const byId = await prisma.note.findFirst({
|
||||
where: { id: targetNoteId, userId, trashedAt: null },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!byId) {
|
||||
if (!target.title) continue
|
||||
targetNoteId = undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetNoteId && target.title) {
|
||||
let targetNote = await prisma.note.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
title: { equals: target.title, mode: 'insensitive' },
|
||||
trashedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
if (!targetNote) {
|
||||
targetNote = await prisma.note.create({
|
||||
data: {
|
||||
title: target.title,
|
||||
content: '',
|
||||
userId,
|
||||
type: 'richtext',
|
||||
color: 'default',
|
||||
isMarkdown: false,
|
||||
order: 0,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
}
|
||||
targetNoteId = targetNote.id
|
||||
}
|
||||
|
||||
if (!targetNoteId || targetNoteId === noteId) continue
|
||||
|
||||
await prisma.noteLink.upsert({
|
||||
where: {
|
||||
sourceNoteId_targetNoteId: {
|
||||
sourceNoteId: noteId,
|
||||
targetNoteId,
|
||||
},
|
||||
},
|
||||
update: { contextSnippet: target.snippet.slice(0, 200) },
|
||||
create: {
|
||||
sourceNoteId: noteId,
|
||||
targetNoteId,
|
||||
contextSnippet: target.snippet.slice(0, 200),
|
||||
},
|
||||
})
|
||||
upsertedIds.push(targetNoteId)
|
||||
}
|
||||
|
||||
if (upsertedIds.length > 0) {
|
||||
await prisma.noteLink.deleteMany({
|
||||
where: {
|
||||
sourceNoteId: noteId,
|
||||
targetNoteId: { notIn: upsertedIds },
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await prisma.noteLink.deleteMany({ where: { sourceNoteId: noteId } })
|
||||
}
|
||||
|
||||
return upsertedIds.length
|
||||
}
|
||||
@@ -7,9 +7,13 @@ export function cn(...inputs: ClassValue[]) {
|
||||
}
|
||||
|
||||
export function extractImagesFromHTML(html: string): string[] {
|
||||
if (!html) return [];
|
||||
const matches = html.matchAll(/<img[^>]+src="([^">]+)"/gi);
|
||||
return Array.from(matches).map(m => m[1]);
|
||||
if (!html) return []
|
||||
const urls = new Set<string>()
|
||||
for (const match of html.matchAll(/<img[^>]+src=["']([^"'>]+)["']/gi)) {
|
||||
const src = match[1]?.trim()
|
||||
if (src) urls.add(src)
|
||||
}
|
||||
return Array.from(urls)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user