feat: dashboard Second Brain, essai 7 jours et vérification e-mail
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m14s
CI / Deploy production (on server) (push) Successful in 1m25s

Rendre le dashboard actionnable (inbox, peek, carte mentale), aligner la facturation sur l’essai 7 jours, et bloquer le login e-mail tant que l’adresse n’est pas confirmée.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-08-30 07:19:36 +00:00
parent 69c99e4f4f
commit 80ccc1f6de
95 changed files with 4158 additions and 618 deletions

View File

@@ -4,24 +4,55 @@
* Critical for STEM notes: formulas must be harvested, not hoped for from the model.
*/
import { extractPublishImageUrls } from '@/lib/publish/process-note-html'
export interface SourceAssets {
formulas: string[]
numbers: Array<{ label: string; value: number; raw: string }>
keySentences: string[]
hasMath: boolean
hasNumbers: boolean
hasImages: boolean
wordCount: number
images: string[]
}
/** Extract LaTeX / equation-like fragments from note plain text or HTML. */
export function extractFormulas(raw: string): string[] {
if (!raw) return []
const found: string[] = []
// 3+ consecutive plain words at brace depth 0 = prose tail captured after a
// formula (KaTeX eats spaces in math mode → must never reach $…$).
const PROSE_TAIL_RE =
/(?:[.!?]?\s+)[A-ZÀ-ÖØ-Þ]?[a-zA-ZÀ-ÿ']{2,}\s+[a-zA-ZÀ-ÿ']{2,}\s+[a-zA-ZÀ-ÿ']{2,}[\s\S]*$/g
const braceDepth = (s: string): number => {
let d = 0
for (const ch of s) {
if (ch === '{') d++
else if (ch === '}') d = Math.max(0, d - 1)
}
return d
}
const push = (s: string) => {
const t = s.replace(/\s+/g, ' ').trim()
let t = s.replace(/\s+/g, ' ').trim()
t = t.replace(/^\$+|\$+$/g, '').trim()
PROSE_TAIL_RE.lastIndex = 0
let m: RegExpExecArray | null
while ((m = PROSE_TAIL_RE.exec(t)) !== null) {
if (braceDepth(t.slice(0, m.index)) === 0) {
t = t.slice(0, m.index).trim()
break
}
}
t = t.replace(/^\$+|\$+$/g, '').trim()
if (t.length >= 2 && t.length <= 280 && !found.includes(t)) found.push(t)
}
// TipTap / published HTML: data-latex="..."
for (const m of raw.matchAll(/data-latex=["']([^"']+)["']/gi)) {
push(decodeHtmlEntities(m[1] || ''))
}
// $$ ... $$
for (const m of raw.matchAll(/\$\$([\s\S]+?)\$\$/g)) push(m[1] || '')
// \[ ... \]
@@ -47,20 +78,34 @@ export function extractFormulas(raw: string): string[] {
return found.slice(0, 24)
}
function decodeHtmlEntities(s: string): string {
return s
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
}
export function extractNumbers(raw: string): Array<{ label: string; value: number; raw: string }> {
if (!raw) return []
// Normalize Persian (۰-۹) and Arabic (٠-٩) numerals to Western (0-9)
const text = raw
.replace(/[\u06F0-\u06F9]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0x06f0 + 0x30))
.replace(/[\u0660-\u0669]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0x0660 + 0x30))
const out: Array<{ label: string; value: number; raw: string }> = []
const re =
/(?:^|[^\d])((?:≈|~)?\s*-?\d+(?:[.,]\d+)?)\s*(%|€|\$|k|m|bn|ms|s|°C)?\b/gi
let m: RegExpExecArray | null
while ((m = re.exec(raw)) !== null && out.length < 20) {
while ((m = re.exec(text)) !== null && out.length < 20) {
const numStr = (m[1] || '').replace(/[≈~\s]/g, '').replace(',', '.')
const value = parseFloat(numStr)
if (!Number.isFinite(value)) continue
const unit = m[2] || ''
// crude label: 40 chars before
const start = Math.max(0, m.index - 40)
const ctx = raw.slice(start, m.index).replace(/\s+/g, ' ').trim()
const ctx = text.slice(start, m.index).replace(/\s+/g, ' ').trim()
const label = ctx.split(/[.;:!?\n]/).pop()?.trim().slice(-32) || `n${out.length + 1}`
out.push({ label, value: unit === '%' ? value : value, raw: `${numStr}${unit}` })
}
@@ -88,16 +133,20 @@ export function extractKeySentences(raw: string, max = 16): string[] {
}
export function extractSourceAssets(raw: string): SourceAssets {
const formulas = extractFormulas(raw)
const numbers = extractNumbers(raw)
const plain = raw.replace(/<[^>]+>/g, ' ')
const formulas = extractFormulas(plain)
const numbers = extractNumbers(plain)
const keySentences = extractKeySentences(raw)
const wordCount = raw.replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length
const images = extractPublishImageUrls(raw).slice(0, 8)
const wordCount = plain.split(/\s+/).filter(Boolean).length
return {
formulas,
numbers,
keySentences,
hasMath: formulas.length > 0 || /équat|equat|différen|differen|dériv|deriv|intégr|integr|EDO|ODE|PDE|latex/i.test(raw),
hasMath: formulas.length > 0 || /équat|equat|différen|differen|dériv|deriv|intégr|integr|EDO|ODE|PDE|latex/i.test(plain),
hasNumbers: numbers.length >= 2,
hasImages: images.length > 0,
wordCount,
images,
}
}