feat(notes): liens internes, onglet Réseau, living blocks et consentement IA
Some checks failed
CI / Lint, Test & Build (push) Failing after 1m19s
CI / Deploy production (on server) (push) Has been skipped

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:
Antigravity
2026-05-24 14:27:29 +00:00
parent 077e665dfc
commit e2672cd2c2
323 changed files with 20670 additions and 42431 deletions

View 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 laffichage 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é (0100), é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)
}

View File

@@ -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', {

View File

@@ -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)

View File

@@ -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>`
}

View File

@@ -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 }
}

View 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)
}

View 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)
}

View 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)
}
}

View 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 })
}

View File

@@ -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,

View File

@@ -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])

View 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)}`
}

View 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 }))
}

View File

@@ -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%">`
})
}

View 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`
}

View 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
}

View File

@@ -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)
}
/**