feat: publication IA (magazine/brief/essay) + fixes critique
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m22s
CI / Deploy production (on server) (push) Has been skipped

Publication IA:
- 4 templates (magazine, brief, essay, simple) avec CSS riche
- Rewrite IA (article/exercises/tutorial/reference/mixed)
- Modération avec timeout 12s + fallback safe
- Quotas publish_enhance par tier (basic=2, pro=15, business=100)
- Détection contenu stale (hash)
- Migration DB publishedContent/publishedTemplate/publishedSourceHash

Fixes:
- cheerio v1.2: Element -> AnyNode (domhandler), decodeEntities cast
- _isShared ajouté au type Note (champ virtuel serveur)
- callout colors PDF export: extraction fonction pure testable
- admin/published: guard note.userId null
- Cmd+S fonctionne en mode dialog (pas seulement fullPage)

i18n:
- 23 clés publish* traduites dans les 15 locales
- Extension Web Clipper: 13 locales mise à jour

Tests:
- callout-colors.test.ts (6 tests)
- note-visible-in-view.test.ts (5 tests)
- entitlements.test.ts + byok-entitlements.test.ts: mock usageLog + unstubAllEnvs
- 199/199 tests passent

Tracker: user-stories.md sync avec sprint-status.yaml
This commit is contained in:
Antigravity
2026-06-28 07:32:57 +00:00
parent 902fe95a69
commit 96e7902f01
169 changed files with 5382 additions and 1527 deletions

View File

@@ -0,0 +1,102 @@
import { getChatProvider } from '../factory'
import { getSystemConfig } from '@/lib/config'
export interface NoteAnalysisResult {
noteId: string
include: boolean
reason: string
}
export interface NotebookAnalysis {
notes: NoteAnalysisResult[]
description: string
}
export class NotebookPublishAnalyzerService {
async analyze(
notebookName: string,
notes: Array<{ id: string; title: string; wordCount: number; preview: string }>,
language: string,
): Promise<NotebookAnalysis> {
const lang = language || 'fr'
const langName = lang === 'fr' ? 'français' : lang === 'en' ? 'English' : lang === 'fa' ? 'فارسی' : lang === 'ar' ? 'العربية' : lang === 'de' ? 'Deutsch' : lang === 'es' ? 'Español' : lang
const notesList = notes.map((n, i) =>
`${i + 1}. [${n.id}] "${n.title || 'Sans titre'}" — ${n.wordCount} mots — "${n.preview}"`
).join('\n')
const prompt = `Tu es un éditeur web expert. Analyse ce carnet de notes "${notebookName}" et décide quelles notes méritent d'être publiées sur un site web professionnel.
NOTES DU CARNET :
${notesList}
CRITÈRES D'INCLUSION :
- Note complète (>150 mots), structurée, sans fautes évidentes
- Contenu utile pour un lecteur externe (pas juste des notes personnelles)
- Titre clair et représentatif du contenu
CRITÈRES D'EXCLUSION :
- Brouillon ou note vide (<50 mots)
- Contenu incomplet ("à compléter", "TODO", "...")
- Note purement personnelle ou de travail interne
Réponds en JSON strict :
{
"description": "Résumé du carnet en 1-2 phrases percutantes en ${langName}",
"notes": [
{ "noteId": "id_ici", "include": true, "reason": "Courte justification en ${langName}" }
]
}
Inclus toutes les notes dans le tableau "notes", même celles exclues.`
const config = await getSystemConfig()
const provider = getChatProvider(config)
const raw = await provider.generateText(prompt)
return this.parseResponse(raw, notes)
}
private parseResponse(
raw: string,
notes: Array<{ id: string }>,
): NotebookAnalysis {
const jsonMatch = raw.match(/```json\s*([\s\S]+?)\s*```/)
let jsonStr = jsonMatch ? jsonMatch[1] : raw
const s = jsonStr.indexOf('{')
const e = jsonStr.lastIndexOf('}')
if (s >= 0 && e > s) jsonStr = jsonStr.slice(s, e + 1)
try {
const parsed = JSON.parse(jsonStr)
const noteIds = new Set(notes.map(n => n.id))
const analysisNotes: NoteAnalysisResult[] = (parsed.notes || [])
.filter((n: any) => noteIds.has(n.noteId))
.map((n: any) => ({
noteId: String(n.noteId),
include: Boolean(n.include),
reason: String(n.reason || ''),
}))
// Include any notes missing from AI response with include=true as default
const analyzedIds = new Set(analysisNotes.map(n => n.noteId))
for (const note of notes) {
if (!analyzedIds.has(note.id)) {
analysisNotes.push({ noteId: note.id, include: true, reason: '' })
}
}
return {
notes: analysisNotes,
description: String(parsed.description || ''),
}
} catch {
return {
notes: notes.map(n => ({ noteId: n.id, include: true, reason: '' })),
description: '',
}
}
}
}
export const notebookPublishAnalyzerService = new NotebookPublishAnalyzerService()

View File

@@ -29,7 +29,7 @@ export class NotebookWizardService {
const provider = getChatProvider(config)
const raw = await provider.generateText(prompts)
return this.parseResponse(raw, profile)
return this.parseResponse(raw, profile, lang)
}
private buildPrompt(
@@ -39,8 +39,9 @@ export class NotebookWizardService {
count: number,
level?: string
): string {
const langName = lang === 'fr' ? 'français' : lang === 'en' ? 'English' : lang === 'fa' ? 'فارسی' : lang
const langName = lang === 'fr' ? 'français' : lang === 'en' ? 'English' : lang === 'fa' ? 'فارسی' : lang === 'ar' ? 'العربية' : lang === 'de' ? 'Deutsch' : lang === 'es' ? 'Español' : lang === 'it' ? 'Italiano' : lang === 'pt' ? 'Português' : lang === 'ru' ? 'Русский' : lang === 'zh' ? '中文' : lang === 'ja' ? '日本語' : lang === 'ko' ? '한국어' : lang === 'nl' ? 'Nederlands' : lang === 'pl' ? 'Polski' : lang === 'hi' ? 'हिन्दी' : lang
const schemaLabels = this.getSchemaLabels(lang)
const profileContext = {
student: `Tu crées des notes de cours pour un étudiant. Le contenu doit être pédagogique, clair, avec des exemples.`,
teacher: `Tu crées la structure d'un cours pour un professeur. Chaque note est un chapitre avec des sections à remplir, des objectifs, et des exercices.`,
@@ -77,8 +78,8 @@ FORMAT DE SORTIE — JSON UNIQUEMENT :
}
],
"schemaProperties": [
{ "name": "Statut", "type": "select", "options": ["À réviser", "En cours", "Maîtrisé"] },
{ "name": "Difficulté", "type": "select", "options": ["Facile", "Moyen", "Difficile"] }
{ "name": "${schemaLabels.status}", "type": "select", "options": ["${schemaLabels.statusTodo}", "${schemaLabels.statusInProgress}", "${schemaLabels.statusDone}"] },
{ "name": "${schemaLabels.difficulty}", "type": "select", "options": ["${schemaLabels.easy}", "${schemaLabels.medium}", "${schemaLabels.hard}"] }
]
}
\`\`\`
@@ -110,7 +111,28 @@ IMPORTANT : Chaque note DOIT faire entre 800 et 1500 mots. Sois exhaustif. Déve
Les "difficulty" doivent varier : mélange facile/moyen/difficile.`
}
private parseResponse(raw: string, profile: WizardProfile): GeneratedCarnet {
private getSchemaLabels(lang: string) {
const map: Record<string, { status: string; statusTodo: string; statusInProgress: string; statusDone: string; difficulty: string; easy: string; medium: string; hard: string }> = {
fr: { status: 'Statut', statusTodo: 'À réviser', statusInProgress: 'En cours', statusDone: 'Maîtrisé', difficulty: 'Difficulté', easy: 'Facile', medium: 'Moyen', hard: 'Difficile' },
en: { status: 'Status', statusTodo: 'To review', statusInProgress: 'In progress', statusDone: 'Mastered', difficulty: 'Difficulty', easy: 'Easy', medium: 'Medium', hard: 'Hard' },
fa: { status: 'وضعیت', statusTodo: 'باید مرور شود', statusInProgress: 'در حال یادگیری', statusDone: 'تسلط یافته', difficulty: 'سختی', easy: 'آسان', medium: 'متوسط', hard: 'دشوار' },
ar: { status: 'الحالة', statusTodo: 'للمراجعة', statusInProgress: 'قيد الدراسة', statusDone: 'متقن', difficulty: 'الصعوبة', easy: 'سهل', medium: 'متوسط', hard: 'صعب' },
de: { status: 'Status', statusTodo: 'Zu wiederholen', statusInProgress: 'In Bearbeitung', statusDone: 'Beherrscht', difficulty: 'Schwierigkeit', easy: 'Einfach', medium: 'Mittel', hard: 'Schwer' },
es: { status: 'Estado', statusTodo: 'Por repasar', statusInProgress: 'En progreso', statusDone: 'Dominado', difficulty: 'Dificultad', easy: 'Fácil', medium: 'Medio', hard: 'Difícil' },
it: { status: 'Stato', statusTodo: 'Da ripassare', statusInProgress: 'In corso', statusDone: 'Padroneggiato', difficulty: 'Difficoltà', easy: 'Facile', medium: 'Medio', hard: 'Difficile' },
pt: { status: 'Estado', statusTodo: 'A rever', statusInProgress: 'Em progresso', statusDone: 'Dominado', difficulty: 'Dificuldade', easy: 'Fácil', medium: 'Médio', hard: 'Difícil' },
ru: { status: 'Статус', statusTodo: 'На повторение', statusInProgress: 'В процессе', statusDone: 'Усвоено', difficulty: 'Сложность', easy: 'Лёгкий', medium: 'Средний', hard: 'Сложный' },
zh: { status: '状态', statusTodo: '待复习', statusInProgress: '学习中', statusDone: '已掌握', difficulty: '难度', easy: '简单', medium: '中等', hard: '困难' },
ja: { status: 'ステータス', statusTodo: '要復習', statusInProgress: '学習中', statusDone: '習得済み', difficulty: '難易度', easy: '易しい', medium: '普通', hard: '難しい' },
ko: { status: '상태', statusTodo: '복습 필요', statusInProgress: '학습 중', statusDone: '마스터', difficulty: '난이도', easy: '쉬움', medium: '보통', hard: '어려움' },
nl: { status: 'Status', statusTodo: 'Te herhalen', statusInProgress: 'Bezig', statusDone: 'Beheerst', difficulty: 'Moeilijkheid', easy: 'Makkelijk', medium: 'Gemiddeld', hard: 'Moeilijk' },
pl: { status: 'Status', statusTodo: 'Do powtórki', statusInProgress: 'W trakcie', statusDone: 'Opanowane', difficulty: 'Trudność', easy: 'Łatwy', medium: 'Średni', hard: 'Trudny' },
hi: { status: 'स्थिति', statusTodo: 'दोहराने के लिए', statusInProgress: 'प्रगति में', statusDone: 'महारत हासिल', difficulty: 'कठिनाई', easy: 'आसान', medium: 'मध्यम', hard: 'कठिन' },
}
return map[lang] || map.en
}
private parseResponse(raw: string, profile: WizardProfile, lang?: string): GeneratedCarnet {
const jsonMatch = raw.match(/```json\s*([\s\S]+?)\s*```/)
let jsonStr = jsonMatch ? jsonMatch[1] : raw
@@ -145,11 +167,12 @@ Les "difficulty" doivent varier : mélange facile/moyen/difficile.`
})
}
if (notes.length === 0) throw new Error('No notes found in response')
const fallbackLabels = this.getSchemaLabels(lang || 'fr')
return {
notes,
schemaProperties: [
{ name: 'Statut', type: 'select', options: ['À réviser', 'En cours', 'Maîtrisé'] },
{ name: 'Difficulté', type: 'select', options: ['Facile', 'Moyen', 'Difficile'] },
{ name: fallbackLabels.status, type: 'select', options: [fallbackLabels.statusTodo, fallbackLabels.statusInProgress, fallbackLabels.statusDone] },
{ name: fallbackLabels.difficulty, type: 'select', options: [fallbackLabels.easy, fallbackLabels.medium, fallbackLabels.hard] },
],
}
} catch {
@@ -164,9 +187,10 @@ Les "difficulty" doivent varier : mélange facile/moyen/difficile.`
difficulty: n.difficulty || undefined,
}))
const defaultLabels = this.getSchemaLabels(lang || 'fr')
const schemaProperties = parsed.schemaProperties || [
{ name: 'Statut', type: 'select', options: ['À réviser', 'En cours', 'Maîtrisé'] },
{ name: 'Difficulté', type: 'select', options: ['Facile', 'Moyen', 'Difficile'] },
{ name: defaultLabels.status, type: 'select', options: [defaultLabels.statusTodo, defaultLabels.statusInProgress, defaultLabels.statusDone] },
{ name: defaultLabels.difficulty, type: 'select', options: [defaultLabels.easy, defaultLabels.medium, defaultLabels.hard] },
]
return { notes, schemaProperties }

View File

@@ -0,0 +1,149 @@
import { getChatProvider } from '../factory'
import { getSystemConfig } from '@/lib/config'
import { extractPublishImageUrls } from '@/lib/publish/process-note-html'
import { transformEditorBlocksForPublish } from '@/lib/publish/transform-editor-blocks'
import type { PublishEnhanceSpec, PublishRewriteSpec, PublishTemplateId } from '@/lib/publish/types'
const TEMPLATE_HINTS: Record<PublishTemplateId, string> = {
magazine: `Chapô accrocheur + une citation mise en avant (pullQuote). Style journalistique.`,
brief: `Résumé exécutif dense + 3 à 5 points clés (keyPoints). Ton professionnel actionnable.`,
essay: `Épigraphe inspirante + chapô réfléchi. Ton littéraire mais clair.`,
}
/* ─── MODE ÉDITORIAL (pas de réécriture) ─────────────────────────────────── */
export class PublishEnhanceService {
async enhance(
title: string,
noteContent: string,
template: PublishTemplateId,
language: string,
): Promise<PublishEnhanceSpec> {
const imageUrls = extractPublishImageUrls(noteContent)
const plainText = noteContent
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 6000)
const langName = language === 'fr' ? 'français' : language === 'fa' ? 'فارسی' : 'English'
const imageHint = imageUrls.length > 0
? `La note contient ${imageUrls.length} image(s) — elles sont affichées automatiquement.`
: ''
const prompt = `Tu es un éditeur web. Rédige UNIQUEMENT l'habillage éditorial d'une page publique.
TITRE : "${title || 'Sans titre'}"
LANGUE : ${langName}
STYLE : ${TEMPLATE_HINTS[template]}
${imageHint}
CONTENU (pour contexte) :
${plainText}
Réponds en JSON strict, sans markdown :
{
"summary": "chapô 2-3 phrases",
"pullQuote": "phrase marquante (magazine)",
"epigraph": "ouverture courte (essay)",
"keyPoints": ["point 1", "point 2", "point 3"]
}`
const config = await getSystemConfig()
const provider = getChatProvider(config)
const raw = await provider.generateText(prompt)
return this.parseEnhanceResponse(raw, plainText)
}
private parseEnhanceResponse(raw: string, fallback: string): PublishEnhanceSpec {
const jsonMatch = raw.match(/```json\s*([\s\S]+?)\s*```/)
let jsonStr = jsonMatch ? jsonMatch[1] : raw
const s = jsonStr.indexOf('{')
const e = jsonStr.lastIndexOf('}')
if (s >= 0 && e > s) jsonStr = jsonStr.slice(s, e + 1)
try {
const p = JSON.parse(jsonStr)
return {
summary: String(p.summary || '').trim() || fallback.slice(0, 280),
pullQuote: p.pullQuote ? String(p.pullQuote).trim() : undefined,
epigraph: p.epigraph ? String(p.epigraph).trim() : undefined,
keyPoints: Array.isArray(p.keyPoints)
? p.keyPoints.map((k: unknown) => String(k).trim()).filter(Boolean).slice(0, 6)
: undefined,
}
} catch {
return { summary: fallback.slice(0, 280) }
}
}
/* ─── MODE RÉÉCRITURE WEB ─────────────────────────────────────────────── */
async rewrite(
title: string,
noteContent: string,
template: PublishTemplateId,
language: string,
): Promise<PublishRewriteSpec> {
const langName = language === 'fr' ? 'français' : language === 'fa' ? 'فارسی' : 'English'
const plainText = noteContent
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 8000)
// Corps = transformation déterministe du HTML éditeur (exercices, toggles, callouts…)
const structuredBody = transformEditorBlocksForPublish(noteContent)
const prompt = `Tu es un éditeur web. Analyse ce contenu de note et rédige UNIQUEMENT l'habillage éditorial.
Langue : ${langName}
Titre : "${title || 'Sans titre'}"
Style : ${TEMPLATE_HINTS[template]}
Le corps HTML est déjà structuré (exercices, définitions, toggles) — tu ne réécris PAS le corps.
CONTENU (texte) :
${plainText}
Réponds en JSON strict, sans markdown :
{
"contentType": "article|exercises|tutorial|reference|mixed",
"summary": "chapô 1-2 phrases percutantes"
}`
const config = await getSystemConfig()
const provider = getChatProvider(config)
const raw = await provider.generateText(prompt)
return this.parseRewriteResponse(raw, title, plainText, structuredBody)
}
private parseRewriteResponse(
raw: string,
title: string,
fallback: string,
structuredBody: string,
): PublishRewriteSpec {
const jsonMatch = raw.match(/```json\s*([\s\S]+?)\s*```/)
let jsonStr = jsonMatch ? jsonMatch[1] : raw
const s = jsonStr.indexOf('{')
const e = jsonStr.lastIndexOf('}')
if (s >= 0 && e > s) jsonStr = jsonStr.slice(s, e + 1)
try {
const p = JSON.parse(jsonStr)
return {
contentType: ['article', 'exercises', 'tutorial', 'reference', 'mixed'].includes(p.contentType)
? p.contentType
: 'article',
summary: String(p.summary || '').trim() || '',
body: structuredBody || `<p>${fallback.slice(0, 500)}</p>`,
}
} catch {
return {
contentType: 'article',
summary: '',
body: structuredBody || `<h2>${title}</h2><p>${fallback.slice(0, 600)}</p>`,
}
}
}
}
export const publishEnhanceService = new PublishEnhanceService()

View File

@@ -66,13 +66,14 @@ Chaque jour doit avoir 1-4 notes à réviser avec une activité descriptive.`
const provider = getChatProvider(config)
const raw = await provider.generateText(prompt)
return this.parseResponse(raw, notes, daysUntilExam)
return this.parseResponse(raw, notes, daysUntilExam, language)
}
private parseResponse(
raw: string,
notes: Array<{ id: string; title: string }>,
totalDays: number
totalDays: number,
language?: string
): StudyPlan {
const jsonMatch = raw.match(/```json\s*([\s\S]+?)\s*```/)
let jsonStr = jsonMatch ? jsonMatch[1] : raw
@@ -101,6 +102,9 @@ Chaque jour doit avoir 1-4 notes à réviser avec une activité descriptive.`
}
} catch {
// Fallback: simple distribution
const lang = language || 'fr'
const firstReadLabel = lang === 'fa' ? 'اولین مطالعه' : lang === 'ar' ? 'القراءة الأولى' : lang === 'de' ? 'Erste Lektüre' : lang === 'es' ? 'Primera lectura' : lang === 'it' ? 'Prima lettura' : lang === 'pt' ? 'Primeira leitura' : lang === 'ru' ? 'Первое чтение' : lang === 'zh' ? '第一次阅读' : lang === 'ja' ? '最初の読み' : lang === 'ko' ? '첫 번째 읽기' : lang === 'nl' ? 'Eerste lezing' : lang === 'pl' ? 'Pierwsze czytanie' : lang === 'hi' ? 'पहली पढ़ाई' : lang === 'fr' ? 'Première lecture' : 'First reading'
const reviewLabel = (n: number) => lang === 'fa' ? `مرور ${n} یادداشت` : lang === 'ar' ? `مراجعة ${n} ملاحظات` : lang === 'de' ? `${n} Notizen wiederholen` : lang === 'es' ? `Repasar ${n} notas` : lang === 'it' ? `Ripassa ${n} note` : lang === 'pt' ? `Rever ${n} notas` : lang === 'ru' ? `Повторить ${n} заметок` : lang === 'zh' ? `复习 ${n} 条笔记` : lang === 'ja' ? `${n}件のノートを復習` : lang === 'ko' ? `${n}개 노트 복습` : lang === 'nl' ? `${n} notities herzien` : lang === 'pl' ? `Powtórz ${n} notatek` : lang === 'hi' ? `${n} नोट्स दोहराएं` : lang === 'fr' ? `Revoir ${n} notes` : `Review ${n} notes`
const days: StudyDay[] = []
const today = new Date()
const notesPerDay = Math.max(1, Math.ceil(notes.length / Math.min(totalDays, 14)))
@@ -113,7 +117,7 @@ Chaque jour doit avoir 1-4 notes à réviser avec une activité descriptive.`
date: date.toISOString().slice(0, 10),
noteIds: dayNotes.map(n => n.id),
noteTitles: dayNotes.map(n => n.title),
activity: i === 0 ? 'Première lecture' : `Revoir ${dayNotes.length} notes`,
activity: i === 0 ? firstReadLabel : reviewLabel(dayNotes.length),
})
}

View File

@@ -114,7 +114,7 @@ function addWatermark(slide: any) {
})
}
/** Wrap pres.addSlide so every new slide gets the Momento watermark automatically */
/** Wrap pres.addSlide so every new slide gets the Memento watermark automatically */
function withWatermark(pres: PptxGenJSModule): PptxGenJSModule {
const original = pres.addSlide.bind(pres)
;(pres as any).addSlide = (...args: any[]) => {
@@ -1004,7 +1004,7 @@ async function buildPresentation(spec: PresentationSpec): Promise<PptxGenJSModul
const PptxGenJS = await getPptxGenClass()
const pres = withWatermark(new PptxGenJS())
pres.title = spec.title
pres.author = 'Momento'
pres.author = 'Memento'
pres.subject = spec.title
pres.layout = 'LAYOUT_16x9'

View File

@@ -2,7 +2,7 @@
import { signOut } from 'next-auth/react';
/** Ends the Momento session server-side (JWT revoked) and redirects to login. */
/** Ends the Memento session server-side (JWT revoked) and redirects to login. */
export async function performSignOut(callbackUrl = '/login') {
await signOut({ redirectTo: callbackUrl });
}

View File

@@ -9,7 +9,7 @@ async function getPptxGenClass(): Promise<new () => PptxGenJSModule> {
return _PptxGenJS
}
// ── Theme — cohérent avec l'identité visuelle Momento ───────────────────────
// ── Theme — cohérent avec l'identité visuelle Memento ───────────────────────
const T = {
bg: 'F2F0E9',
primary: '1C1C1C',
@@ -78,7 +78,7 @@ function addWatermark(slide: any) {
})
}
/** Wrap pres.addSlide so every new slide gets the Momento watermark automatically */
/** Wrap pres.addSlide so every new slide gets the Memento watermark automatically */
function withWatermark(pres: PptxGenJSModule): PptxGenJSModule {
const original = pres.addSlide.bind(pres)
;(pres as any).addSlide = (...args: any[]) => {
@@ -309,7 +309,7 @@ export async function generateBrainstormPptx(session: SessionLike): Promise<{ bu
const pres = withWatermark(new PptxGenJS())
pres.layout = 'LAYOUT_WIDE'
pres.author = 'Momento'
pres.author = 'Memento'
pres.subject = `Brainstorm: ${session.seedIdea}`
const activeIdeas = session.ideas.filter(i => i.status !== 'dismissed')

View File

@@ -0,0 +1,19 @@
export interface CalloutColors {
bg: string
border: string
}
const CALLOUT_COLOR_MAP: Record<string, CalloutColors> = {
info: { bg: '#eff6ff', border: '#93c5fd' },
warning: { bg: '#fffbeb', border: '#fcd34d' },
tip: { bg: '#faf5ff', border: '#c4b5fd' },
success: { bg: '#f0fdf4', border: '#86efac' },
danger: { bg: '#fef2f2', border: '#fca5a5' },
}
export function getCalloutColors(type: string | null | undefined): CalloutColors {
if (type && type in CALLOUT_COLOR_MAP) {
return CALLOUT_COLOR_MAP[type]
}
return CALLOUT_COLOR_MAP.info
}

View File

@@ -85,6 +85,95 @@ export class QuotaExceededError extends Error {
const TTL_SECONDS = 90 * 24 * 60 * 60;
function getPeriodDates(): { period: string; periodStart: Date; periodEnd: Date } {
const period = getCurrentPeriodKey();
const periodStart = new Date(`${period}-01T00:00:00.000Z`);
const periodEnd = new Date(Date.UTC(periodStart.getUTCFullYear(), periodStart.getUTCMonth() + 1, 1));
return { period, periodStart, periodEnd };
}
async function getDatabaseUsageCounts(
userId: string,
features: string[],
): Promise<Record<string, number>> {
if (features.length === 0) return {};
const { periodStart } = getPeriodDates();
const rows = await prisma.usageLog.findMany({
where: { userId, periodStart, feature: { in: features } },
select: { feature: true, requestsCount: true },
});
return Object.fromEntries(rows.map((r) => [r.feature, r.requestsCount]));
}
async function mirrorUsageCountToDatabase(
userId: string,
feature: string,
requestsCount: number,
): Promise<void> {
const { periodStart, periodEnd } = getPeriodDates();
await prisma.usageLog.upsert({
where: {
userId_feature_periodStart: { userId, feature, periodStart },
},
create: {
userId,
feature,
periodStart,
periodEnd,
requestsCount,
tokensUsed: 0,
syncedAt: new Date(),
},
update: {
requestsCount,
syncedAt: new Date(),
},
}).catch((err) => {
console.error('[entitlements] Failed to mirror usage to DB:', err);
});
}
/** Fallback when Redis is unavailable — atomic increment in PostgreSQL. */
async function reserveUsageInDatabase(
userId: string,
feature: string,
limit: number,
): Promise<number> {
const { periodStart, periodEnd } = getPeriodDates();
return prisma.$transaction(async (tx) => {
const existing = await tx.usageLog.findUnique({
where: { userId_feature_periodStart: { userId, feature, periodStart } },
});
const current = existing?.requestsCount ?? 0;
if (current >= limit) return -1;
const updated = await tx.usageLog.upsert({
where: { userId_feature_periodStart: { userId, feature, periodStart } },
create: {
userId,
feature,
periodStart,
periodEnd,
requestsCount: 1,
tokensUsed: 0,
syncedAt: new Date(),
},
update: {
requestsCount: { increment: 1 },
syncedAt: new Date(),
},
});
return updated.requestsCount;
});
}
function parseReserveResult(raw: unknown): number {
const n = typeof raw === 'number' ? raw : Number(raw);
return Number.isFinite(n) ? n : NaN;
}
function shouldFailClosedOnRedisError(): boolean {
return process.env.NODE_ENV === 'production';
}
@@ -182,7 +271,9 @@ export async function canUseFeature(
try {
const key = getRedisKey(userId, feature);
const currentStr = await redis.get(key);
const current = parseRedisInt(currentStr);
const redisCurrent = parseRedisInt(currentStr);
const dbCounts = await getDatabaseUsageCounts(userId, [feature]);
const current = Math.max(redisCurrent, dbCounts[feature] ?? 0);
const allowed = current < limit;
if (!allowed) {
@@ -265,13 +356,14 @@ export async function reserveUsageOrThrow(
try {
const key = getRedisKey(userId, feature);
const newCount = await redis.eval(
const raw = await redis.eval(
RESERVE_LUA,
1,
key,
String(limit),
String(TTL_SECONDS),
) as number;
);
const newCount = parseReserveResult(raw);
if (newCount === -1) {
const byokConfigured = await hasAnyActiveByok(userId);
@@ -284,9 +376,36 @@ export async function reserveUsageOrThrow(
{ currentTier: tier },
);
}
if (!Number.isFinite(newCount) || newCount < 1) {
throw new Error(`Invalid Redis reserve result: ${String(raw)}`);
}
await mirrorUsageCountToDatabase(userId, feature, newCount);
} catch (err) {
if (err instanceof QuotaExceededError) throw err;
console.error('[entitlements] Redis unavailable:', err);
console.error('[entitlements] Redis reserve failed, trying DB fallback:', err);
try {
const dbCount = await reserveUsageInDatabase(userId, feature, limit);
if (dbCount === -1) {
const byokConfigured = await hasAnyActiveByok(userId);
throw new QuotaExceededError(
tier === 'BASIC' ? 'PRO' : 'BUSINESS',
feature,
limit,
limit,
byokConfigured,
{ currentTier: tier },
);
}
return;
} catch (dbErr) {
if (dbErr instanceof QuotaExceededError) throw dbErr;
console.error('[entitlements] DB reserve fallback failed:', dbErr);
}
if (shouldFailClosedOnRedisError()) {
throw new QuotaServiceUnavailableError();
}
@@ -334,12 +453,15 @@ export async function getUserQuotas(
try {
const values = await redis.mget(...keys);
const dbCounts = await getDatabaseUsageCounts(userId, features);
const result: Record<string, { remaining: number; limit: number; used: number }> = {};
for (let i = 0; i < features.length; i++) {
const feature = features[i];
const limit = (await getLimitAsync(tier, feature)) ?? 0;
const current = parseRedisInt(values[i]);
const redisCurrent = parseRedisInt(values[i]);
const dbCurrent = dbCounts[feature] ?? 0;
const current = Math.max(redisCurrent, dbCurrent);
result[feature] = {
remaining: limit === Infinity ? Infinity : Math.max(0, limit - current),
limit,

View File

@@ -18,6 +18,7 @@ export const FALLBACK_TIER_LIMITS: Record<
suggest_charts: 5,
ai_flashcard: 5,
voice_transcribe: 20,
publish_enhance: 2,
},
PRO: {
semantic_search: 200,
@@ -33,6 +34,7 @@ export const FALLBACK_TIER_LIMITS: Record<
excalidraw_generate: 20,
ai_flashcard: 100,
voice_transcribe: 500,
publish_enhance: 15,
},
BUSINESS: {
semantic_search: 1000,
@@ -48,6 +50,7 @@ export const FALLBACK_TIER_LIMITS: Record<
excalidraw_generate: 100,
ai_flashcard: 'unlimited',
voice_transcribe: 'unlimited',
publish_enhance: 100,
},
ENTERPRISE: {
semantic_search: 'unlimited',
@@ -63,6 +66,7 @@ export const FALLBACK_TIER_LIMITS: Record<
excalidraw_generate: 'unlimited',
ai_flashcard: 'unlimited',
voice_transcribe: 'unlimited',
publish_enhance: 'unlimited',
},
};
@@ -100,6 +104,19 @@ function buildLimitMapFromRows(
return map;
}
/** Merge DB entitlements with FALLBACK for features added after admin seeding. */
function mergeWithFallback(map: LimitMap): LimitMap {
const fallback = fallbackToLimitMap();
for (const tier of Object.keys(fallback) as SubscriptionTier[]) {
for (const [feature, limit] of Object.entries(fallback[tier])) {
if (map[tier][feature] === undefined) {
map[tier][feature] = limit;
}
}
}
return map;
}
export function invalidateEntitlementCache(): void {
cachedLimits = null;
cacheExpiresAt = 0;
@@ -119,11 +136,11 @@ async function loadLimitMap(): Promise<LimitMap> {
if (rows.length === 0) {
cachedLimits = fallbackToLimitMap();
} else {
cachedLimits = buildLimitMapFromRows(rows as Array<{
cachedLimits = mergeWithFallback(buildLimitMapFromRows(rows as Array<{
tier: SubscriptionTier;
feature: string;
limitValue: number | null;
}>);
}>));
}
} catch (err) {
console.error('[plan-entitlements] DB load failed, using fallback:', err);

View File

@@ -0,0 +1,111 @@
import katex from 'katex'
import { sanitizePublishedHtml } from '@/lib/sanitize-content'
import { preprocessMathInHtml } from '@/lib/text/math-preprocess'
import { transformEditorBlocksForPublish } from '@/lib/publish/transform-editor-blocks'
function decodeHtml(text: string): string {
const map: Record<string, string> = { '&quot;': '"', '&amp;': '&', '&lt;': '<', '&gt;': '>', '&#39;': "'" }
return text.replace(/&[a-z#0-9]+;/gi, (m) => map[m] || m)
}
function extractLatex(attrs: string, inner: string): string {
const fromAttr = attrs.match(/data-latex=["']([^"']*)["']/i)?.[1]
if (fromAttr) return decodeHtml(fromAttr)
const text = inner.replace(/<[^>]+>/g, '').trim()
return decodeHtml(text)
}
function isAlreadyKatex(html: string): boolean {
return /class=["'][^"']*\bkatex\b/i.test(html)
}
function renderKatexDisplay(latex: string): string {
const trimmed = latex.trim()
if (!trimmed) return ''
try {
const rendered = katex.renderToString(trimmed, { displayMode: true, throwOnError: false })
return `<div class="r-math-display">${rendered}</div>`
} catch {
return `<div class="r-math-display r-math-fallback">${trimmed}</div>`
}
}
function renderKatexInline(latex: string): string {
const trimmed = latex.trim()
if (!trimmed) return ''
try {
const rendered = katex.renderToString(trimmed, { displayMode: false, throwOnError: false })
return `<span class="r-math-inline">${rendered}</span>`
} catch {
return `<span class="r-math-inline r-math-fallback">${trimmed}</span>`
}
}
/** Convertit nœuds éditeur + délimiteurs LaTeX en HTML KaTeX. */
export function renderMathInHtml(html: string): string {
if (!html?.trim()) return ''
let result = preprocessMathInHtml(html)
// Blocs : data-type="math-equation" ou .math-equation-block
result = result.replace(
/<div\b([^>]*(?:data-type=["']math-equation["']|class=["'][^"']*math-equation-block)[^>]*)>([\s\S]*?)<\/div>/gi,
(full, attrs, inner) => {
if (isAlreadyKatex(full)) return full
const latex = extractLatex(attrs, inner)
return latex ? renderKatexDisplay(latex) : full
},
)
// Inline : data-type="inline-math" ou .inline-math
result = result.replace(
/<span\b([^>]*(?:data-type=["']inline-math["']|class=["'][^"']*inline-math)[^>]*)>([\s\S]*?)<\/span>/gi,
(full, attrs, inner) => {
if (isAlreadyKatex(full)) return full
const latex = extractLatex(attrs, inner)
return latex ? renderKatexInline(latex) : full
},
)
return result
}
/** Prépare le HTML de la note pour affichage public (KaTeX, callouts, nettoyage éditeur). */
export function processNoteHtmlForPublish(html: string): string {
if (!html?.trim()) return ''
let result = renderMathInHtml(html)
// Blocs éditeur TipTap → HTML publication (exercices, toggles, callouts…)
result = transformEditorBlocksForPublish(result)
result = result.replace(/<div[^>]*class="link-preview-searchable"[^>]*>[\s\S]*?<\/div>/g, '')
// Figures éditeur → sémantique publication (img déjà traités ignorés)
result = result.replace(/<img([^>]*?)>/gi, (match, attrs) => {
if (/class=["'][^"']*pub-/i.test(match)) return match
const altMatch = attrs.match(/\balt=["']([^"']*)["']/i)
const alt = altMatch?.[1] || ''
return `<figure class="pub-figure"><img${attrs} loading="lazy" class="pub-figure-img" />${alt ? `<figcaption class="pub-figure-caption">${alt}</figcaption>` : ''}</figure>`
})
return sanitizePublishedHtml(result)
}
export function extractPublishImageUrls(html: string): string[] {
if (!html) return []
const urls = new Set<string>()
for (const match of html.matchAll(/<img[^>]+src=["']([^"'>]+)["']/gi)) {
const src = match[1]?.trim()
if (src && isAllowedImageSrc(src)) urls.add(src)
}
return Array.from(urls)
}
export function isAllowedImageSrc(src: string): boolean {
const t = src.trim()
return t.startsWith('/uploads/')
|| t.startsWith('https://')
|| t.startsWith('http://')
|| t.startsWith('/api/')
}

View File

@@ -0,0 +1,286 @@
/** CSS KaTeX commun aux pages publiées */
export const KATEX_PUBLISH_CSS = `
.r-math-display {
margin: 1.75em 0;
text-align: center;
overflow-x: auto;
overflow-y: hidden;
padding: 0.25em 0;
}
.r-math-inline { display: inline; }
.r-math-inline .katex { font-size: 1.05em; }
.r-math-fallback {
font-family: 'SF Mono', Menlo, monospace;
font-size: 0.9em;
opacity: 0.85;
}
.pub-rewrite-body .katex-display { margin: 0; }
.katex { font-size: 1.1em; }
.katex-display { margin: 0; overflow-x: auto; overflow-y: hidden; }
`
export const REWRITE_SHARED_CSS = `
${KATEX_PUBLISH_CSS}
/* ─── Résumé introductif ─────────────────────────────── */
.pub-rewrite-summary {
font-size: 1.2em;
line-height: 1.7;
font-style: italic;
color: var(--pub-summary-color, #444);
margin-bottom: 2em;
padding-bottom: 1.5em;
border-bottom: 2px solid var(--pub-accent, #A47148);
}
/* ─── Exercices ──────────────────────────────────────── */
.pub-rewrite-body .pub-exercise {
border: 2px solid var(--pub-exercise-border, #e0d4c3);
border-radius: 12px;
margin: 1.75em 0;
overflow: hidden;
}
.pub-rewrite-body .pub-exercise-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 18px;
background: var(--pub-exercise-header-bg, #f6f1ea);
border-bottom: 1px solid var(--pub-exercise-border, #e0d4c3);
}
.pub-rewrite-body .pub-exercise-num {
font-weight: 700;
font-size: 0.82em;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--pub-accent, #A47148);
}
.pub-rewrite-body .pub-exercise-meta {
margin-left: auto;
font-size: 0.82em;
font-weight: 600;
color: #666;
}
.pub-rewrite-body .pub-exercise-body {
padding: 16px 20px;
color: #1a1a1a;
background: #fff;
}
.pub-rewrite-body .pub-exercise-body p:first-child { margin-top: 0; }
.pub-rewrite-body .pub-exercise-body p:last-child { margin-bottom: 0; }
/* Corps réécrit : texte toujours sombre sur fond clair */
.pub-rewrite-body {
color: #1a1a1a;
}
.pub-rewrite-body p,
.pub-rewrite-body li,
.pub-rewrite-body td,
.pub-rewrite-body th {
color: inherit;
}
/* Solution collapsible */
.pub-rewrite-body .pub-solution {
border-top: 1px dashed var(--pub-exercise-border, #e0d4c3);
}
.pub-rewrite-body .pub-solution > summary {
list-style: none;
cursor: pointer;
padding: 10px 20px;
font-size: 0.85em;
font-weight: 600;
color: var(--pub-accent, #A47148);
user-select: none;
display: flex;
align-items: center;
gap: 6px;
}
.pub-rewrite-body .pub-solution > summary::before { content: '▶'; font-size: 10px; transition: transform 0.2s; }
.pub-rewrite-body .pub-solution[open] > summary::before { transform: rotate(90deg); }
.pub-rewrite-body .pub-solution-body {
padding: 14px 20px 18px;
background: var(--pub-solution-bg, rgba(164,113,72,0.04));
border-top: 1px solid var(--pub-exercise-border, #e0d4c3);
color: #1a1a1a;
}
/* ─── Étapes tutoriel ────────────────────────────────── */
.pub-rewrite-body .pub-step {
display: grid;
grid-template-columns: 36px 1fr;
gap: 14px;
align-items: start;
margin: 1.5em 0;
}
.pub-rewrite-body .pub-step-num {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--pub-accent, #A47148);
color: #fff;
font-weight: 700;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 2px;
}
.pub-rewrite-body .pub-step-body p:first-child { margin-top: 0; }
/* ─── Définitions / Concepts ─────────────────────────── */
.pub-rewrite-body .pub-definition {
margin: 1.5em 0;
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--pub-definition-border, #ddd);
}
.pub-rewrite-body .pub-definition-term {
padding: 8px 16px;
background: var(--pub-accent, #A47148);
color: #fff;
font-weight: 700;
font-size: 0.88em;
letter-spacing: 0.04em;
}
.pub-rewrite-body .pub-definition-body {
padding: 12px 16px;
background: var(--pub-definition-bg, #fafaf8);
color: #1a1a1a;
}
.pub-rewrite-body .pub-definition-body p { margin: 0.4em 0; color: #1a1a1a; }
.pub-rewrite-body .pub-definition-body p:first-child { margin-top: 0; }
/* ─── Toggle / Collapsible ───────────────────────────── */
.pub-rewrite-body .pub-toggle {
border: 1px solid var(--pub-toggle-border, #e5e5e5);
border-radius: 10px;
margin: 1.2em 0;
overflow: hidden;
}
.pub-rewrite-body .pub-toggle > .pub-toggle-trigger {
list-style: none;
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
cursor: pointer;
font-weight: 600;
font-size: 0.92em;
background: var(--pub-toggle-header-bg, #f7f7f5);
user-select: none;
}
.pub-rewrite-body .pub-toggle > .pub-toggle-trigger::before {
content: '▶';
font-size: 10px;
color: var(--pub-accent, #A47148);
transition: transform 0.2s;
flex-shrink: 0;
}
.pub-rewrite-body .pub-toggle[open] > .pub-toggle-trigger::before { transform: rotate(90deg); }
.pub-rewrite-body .pub-toggle-body {
padding: 14px 18px;
border-top: 1px solid var(--pub-toggle-border, #e5e5e5);
color: #1a1a1a;
background: #fff;
}
.pub-rewrite-body .pub-toggle-body p:first-child { margin-top: 0; }
/* ─── Mise en avant ──────────────────────────────────── */
.pub-rewrite-body .pub-highlight {
margin: 1.5em 0;
padding: 16px 20px;
background: var(--pub-highlight-bg, #fffbf5);
border-left: 4px solid var(--pub-accent, #A47148);
border-radius: 0 10px 10px 0;
font-weight: 600;
color: #1a1a1a;
}
.pub-rewrite-body .pub-highlight p { margin: 0; color: #1a1a1a; }
/* ─── Callouts ───────────────────────────────────────── */
.pub-rewrite-body .pub-callout {
margin: 1.4em 0;
padding: 14px 18px 14px 52px;
border-radius: 10px;
position: relative;
font-size: 0.93em;
line-height: 1.65;
}
.pub-rewrite-body .pub-callout::before {
position: absolute;
left: 16px;
top: 14px;
font-size: 18px;
}
.pub-rewrite-body .pub-callout strong {
display: block;
font-weight: 700;
margin-bottom: 4px;
font-size: 0.9em;
letter-spacing: 0.04em;
text-transform: uppercase;
color: inherit;
}
.pub-rewrite-body .pub-callout p { margin: 0.3em 0; color: inherit; }
.pub-rewrite-body .pub-callout p:first-of-type { margin-top: 0; }
.pub-rewrite-body .pub-callout-info {
background: #eff6ff;
border: 1px solid #93c5fd;
color: #1e3a8a;
}
.pub-rewrite-body .pub-callout-info::before { content: ''; }
.pub-rewrite-body .pub-callout-tip {
background: #ecfdf5;
border: 1px solid #6ee7b7;
color: #065f46;
}
.pub-rewrite-body .pub-callout-tip::before { content: '💡'; }
.pub-rewrite-body .pub-callout-warning {
background: #fffbeb;
border: 1px solid #fbbf24;
color: #78350f;
}
.pub-rewrite-body .pub-callout-warning::before { content: '⚠️'; }
/* ─── Checklist ──────────────────────────────────────── */
.pub-rewrite-body .pub-checklist {
list-style: none;
padding: 0;
margin: 1em 0;
}
.pub-rewrite-body .pub-checklist li {
padding: 8px 12px 8px 36px;
position: relative;
border-radius: 6px;
margin: 4px 0;
background: var(--pub-checklist-bg, rgba(0,0,0,0.02));
color: #1a1a1a;
}
.pub-rewrite-body .pub-checklist li::before {
content: '✓';
position: absolute;
left: 10px;
top: 8px;
font-weight: 700;
color: var(--pub-accent, #A47148);
}
/* ─── Code ───────────────────────────────────────────── */
.pub-rewrite-body .pub-code {
margin: 1.5em 0;
border-radius: 10px;
overflow: hidden;
}
.pub-rewrite-body .pub-code code {
display: block;
padding: 20px 22px;
font-family: 'SF Mono', 'Fira Code', Menlo, Consolas, monospace;
font-size: 13.5px;
line-height: 1.65;
overflow-x: auto;
}
`

View File

@@ -0,0 +1,116 @@
import { createHash } from 'crypto'
import type { PublishEnhanceSpec, PublishRewriteSpec, PublishTemplateId } from './types'
import { extractPublishImageUrls, isAllowedImageSrc, processNoteHtmlForPublish } from './process-note-html'
function esc(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function heroFigure(src: string): string {
if (!isAllowedImageSrc(src)) return ''
return `<figure class="pub-hero"><img src="${esc(src)}" alt="" class="pub-hero-img" loading="eager" decoding="async" /></figure>`
}
function galleryFigures(urls: string[]): string {
const items = urls.map((src) => {
if (!isAllowedImageSrc(src)) return ''
return `<figure class="pub-gallery-item"><img src="${esc(src)}" alt="" class="pub-gallery-img" loading="lazy" decoding="async" /></figure>`
}).filter(Boolean).join('')
if (!items) return ''
return `<div class="pub-gallery" aria-label="Images">${items}</div>`
}
function removeFirstImageFromHtml(html: string): string {
return html.replace(/<figure[^>]*>[\s\S]*?<img[^>]*>[\s\S]*?<\/figure>|<img[^>]*>/i, '')
}
/* ─── Mode éditorial (texte original + habillage) ──────────────────────── */
export function renderPublishedTemplate(
spec: PublishEnhanceSpec,
template: PublishTemplateId,
sourceHtml: string,
): string {
const images = extractPublishImageUrls(sourceHtml)
const bodySource = images[0] ? removeFirstImageFromHtml(sourceHtml) : sourceHtml
const processedBody = processNoteHtmlForPublish(bodySource)
const hero = images[0] ? heroFigure(images[0]) : ''
const gallery = images.length > 1 ? galleryFigures(images.slice(1)) : ''
const summary = esc(spec.summary)
const pullQuote = spec.pullQuote
? `<blockquote class="pub-pull-quote"><p>${esc(spec.pullQuote)}</p></blockquote>`
: ''
const epigraph = spec.epigraph
? `<p class="pub-epigraph">${esc(spec.epigraph)}</p>`
: ''
const keyPoints = spec.keyPoints?.length
? `<div class="pub-key-points">
<p class="pub-key-points-label">Points clés</p>
<ul>${spec.keyPoints.map((k) => `<li>${esc(k)}</li>`).join('')}</ul>
</div>`
: ''
const bodyBlock = `<div class="pub-body-source">${processedBody}</div>`
if (template === 'brief') {
return `<div class="pub-tpl pub-tpl-brief">
${hero}
${summary ? `<div class="pub-brief-lead"><p>${summary}</p></div>` : ''}
${keyPoints}
${bodyBlock}
${gallery}
</div>`
}
if (template === 'essay') {
return `<div class="pub-tpl pub-tpl-essay">
${hero}
${epigraph}
${summary ? `<p class="pub-essay-summary">${summary}</p>` : ''}
${bodyBlock}
${gallery}
</div>`
}
return `<div class="pub-tpl pub-tpl-magazine">
${hero}
${summary ? `<p class="pub-magazine-dek">${summary}</p>` : ''}
${pullQuote}
${bodyBlock}
${gallery}
</div>`
}
/* ─── Mode réécriture (HTML sémantique IA) ─────────────────────────────── */
export function renderRewrittenTemplate(
spec: PublishRewriteSpec,
template: PublishTemplateId,
sourceHtml: string,
): string {
const images = extractPublishImageUrls(sourceHtml)
const hero = images[0] ? heroFigure(images[0]) : ''
const gallery = images.length > 1 ? galleryFigures(images.slice(1)) : ''
const summary = spec.summary
? `<p class="pub-rewrite-summary">${esc(spec.summary)}</p>`
: ''
const processedBody = processNoteHtmlForPublish(spec.body)
return `<div class="pub-tpl pub-tpl-${template} pub-tpl-rewrite" data-content-type="${spec.contentType}">
${hero}
${summary}
<div class="pub-rewrite-body">
${processedBody}
</div>
${gallery}
</div>`
}
export function computePublishedSourceHash(content: string): string {
return createHash('sha256').update(content || '').digest('hex').slice(0, 16)
}

View File

@@ -0,0 +1,208 @@
import { load, type Cheerio, type CheerioAPI } from 'cheerio'
import type { AnyNode } from 'domhandler'
const CALLOUT_PUB_CLASS: Record<string, string> = {
info: 'pub-callout-info',
warning: 'pub-callout-warning',
tip: 'pub-callout-tip',
success: 'pub-callout-tip',
danger: 'pub-callout-warning',
}
function stripTags(html: string): string {
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
}
function esc(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function firstParagraphText($: CheerioAPI, $el: Cheerio<AnyNode>): string {
const p = $el.find('p').first()
const text = p.length ? stripTags(p.html() || '') : stripTags($el.html() || '')
return text || 'Voir le contenu'
}
function isSolutionToggle(summary: string): boolean {
return /solution|réponse|answer|corrigé|corrige|reveal|révéler/i.test(summary)
}
function transformToggleBlocks($: CheerioAPI): void {
$('div[data-type="toggle-block"]').each((_, el) => {
const $el = $(el)
const opened = $el.attr('data-opened') !== 'false'
const innerHtml = $el.html() || ''
const $inner = load(`<div>${innerHtml}</div>`, { decodeEntities: false } as never)
const summaryText = firstParagraphText($, $inner.root())
const firstP = $inner('p').first()
if (firstP.length && /cliquer|révéler|reveal|click/i.test(stripTags(firstP.html() || ''))) {
firstP.remove()
}
$inner('h3').each((__, h) => {
if (/solution|réponse|answer/i.test(stripTags($(h).html() || ''))) $(h).remove()
})
const bodyHtml = $inner.root().html() || innerHtml
const isSolution = isSolutionToggle(summaryText)
const summary = isSolution ? 'Voir la solution' : esc(summaryText.slice(0, 120))
const cssClass = isSolution ? 'pub-solution' : 'pub-toggle'
const summaryClass = isSolution ? '' : ' class="pub-toggle-trigger"'
const bodyClass = isSolution ? 'pub-solution-body' : 'pub-toggle-body'
const openAttr = opened ? ' open' : ''
$el.replaceWith(
`<details class="${cssClass}"${openAttr}><summary${summaryClass}>${summary}</summary><div class="${bodyClass}">${bodyHtml}</div></details>`,
)
})
}
function transformCalloutBlocks($: CheerioAPI): void {
$('div[data-type="callout-block"]').each((_, el) => {
const $el = $(el)
if ($el.closest('.pub-exercise').length) return
const type = ($el.attr('data-callout-type') || 'info').toLowerCase()
const pubClass = CALLOUT_PUB_CLASS[type] || 'pub-callout-info'
const inner = $el.html() || ''
$el.replaceWith(`<div class="pub-callout ${pubClass}">${inner}</div>`)
})
}
function extractExerciseMeta(headerHtml: string): { label: string; meta: string } {
const text = stripTags(headerHtml)
const match = text.match(/(exercice|exercise|تمرین)\s*(\d+)?/i)
const label = match
? `${match[1].charAt(0).toUpperCase()}${match[1].slice(1).toLowerCase()}${match[2] ? ` ${match[2]}` : ''}`
: 'Exercice'
const meta = text.replace(new RegExp(label, 'i'), '').replace(/^[\s—-]+/, '').trim()
return { label, meta }
}
function isExerciseHeaderEl($: CheerioAPI, $el: Cheerio<AnyNode>): boolean {
return $el.hasClass('pub-callout-warning')
&& /exercice|exercise|تمرین/i.test(stripTags($el.html() || ''))
}
function isEnonceHeadingEl($: CheerioAPI, $el: Cheerio<AnyNode>): boolean {
if (!$el.is('h2, h3')) return false
const text = stripTags($el.html() || '').toLowerCase()
return /énoncé|enonce|problem|question|statement/.test(text)
}
function bundleExerciseSequences($: CheerioAPI, $root: Cheerio<AnyNode>): void {
const nodes = $root.children().toArray()
const out: string[] = []
let i = 0
while (i < nodes.length) {
const $node = $(nodes[i])
if (!isExerciseHeaderEl($, $node)) {
out.push($.html(nodes[i]) || '')
i++
continue
}
const { label, meta } = extractExerciseMeta($node.html() || '')
const bodyParts: string[] = []
let solutionHtml = ''
let j = i + 1
if (j < nodes.length && isEnonceHeadingEl($, $(nodes[j]))) {
bodyParts.push($.html(nodes[j]) || '')
j++
}
while (j < nodes.length) {
const $sib = $(nodes[j])
if (isExerciseHeaderEl($, $sib)) break
if ($sib.is('details')) {
const sum = stripTags($sib.find('summary').first().text())
$sib.removeClass('pub-toggle').addClass('pub-solution')
$sib.find('summary').first().text('Voir la solution')
$sib.find('.pub-toggle-body').removeClass('pub-toggle-body').addClass('pub-solution-body')
solutionHtml = $.html(nodes[j]) || ''
j++
break
}
if ($sib.is('h2') && bodyParts.length > 0) break
bodyParts.push($.html(nodes[j]) || '')
j++
}
const metaSpan = meta ? `<span class="pub-exercise-meta">${esc(meta)}</span>` : ''
out.push(`<div class="pub-exercise">
<div class="pub-exercise-header"><span class="pub-exercise-num">${esc(label)}</span>${metaSpan}</div>
<div class="pub-exercise-body">${bodyParts.join('')}</div>
${solutionHtml}
</div>`)
i = j
}
$root.html(out.join(''))
}
function promoteDefinitionBlocks($: CheerioAPI): void {
const headings = $('h2, h3').toArray()
for (const el of headings) {
const $h = $(el)
if ($h.closest('.pub-exercise, .pub-definition').length) continue
const title = stripTags($h.html() || '')
const $next = $h.next()
if (!title || title.length > 80 || !$next.length) continue
const isShortTitle = title.split(/\s+/).length <= 6
const looksLikeDefinition = /définition|definition|concept/i.test(title)
|| (isShortTitle && $next.is('p, ul, ol') && !/énoncé|exercice|chapitre|partie/i.test(title))
if (!looksLikeDefinition) continue
const chunks: Cheerio<AnyNode>[] = []
let $cursor: Cheerio<AnyNode> | null = $next
while ($cursor?.length) {
const tag = $cursor.prop('tagName')?.toLowerCase()
if (tag === 'h2' || tag === 'h3') break
if ($cursor.is('p, ul, ol, blockquote')) {
chunks.push($cursor)
$cursor = $cursor.next()
} else break
}
if (chunks.length === 0) continue
const bodyHtml = chunks.map((c) => $.html(c)).join('')
chunks.forEach((c) => c.remove())
$h.replaceWith(`<div class="pub-definition">
<div class="pub-definition-term">${esc(title)}</div>
<div class="pub-definition-body">${bodyHtml}</div>
</div>`)
}
}
function cleanEditorArtifacts($: CheerioAPI): void {
$('button').remove()
$('div[data-type="outline-block"]').remove()
$('.link-preview-searchable').remove()
$('[contenteditable]').removeAttr('contenteditable')
}
/** Convertit le HTML TipTap en blocs web (exercices, toggles, callouts…). */
export function transformEditorBlocksForPublish(html: string): string {
if (!html?.trim()) return ''
const $ = load(`<div id="pub-root">${html}</div>`, { decodeEntities: false } as never)
const $root = $('#pub-root')
cleanEditorArtifacts($)
transformToggleBlocks($)
transformCalloutBlocks($)
bundleExerciseSequences($, $root)
promoteDefinitionBlocks($)
cleanEditorArtifacts($)
return $root.html() || ''
}

View File

@@ -0,0 +1,21 @@
export const PUBLISH_TEMPLATES = ['magazine', 'brief', 'essay'] as const
export type PublishTemplateId = (typeof PUBLISH_TEMPLATES)[number]
/** Métadonnées éditoriales IA — corps = HTML source original. */
export interface PublishEnhanceSpec {
summary: string
pullQuote?: string
epigraph?: string
keyPoints?: string[]
}
/** Réécriture IA — corps = HTML sémantique généré par l'IA. */
export interface PublishRewriteSpec {
contentType: 'article' | 'exercises' | 'tutorial' | 'reference' | 'mixed'
summary: string
body: string
}
export function isPublishTemplateId(value: string): value is PublishTemplateId {
return (PUBLISH_TEMPLATES as readonly string[]).includes(value)
}

View File

@@ -12,6 +12,7 @@ export const VALID_FEATURES = [
'excalidraw_generate',
'ai_flashcard',
'voice_transcribe',
'publish_enhance',
] as const;
export type FeatureName = (typeof VALID_FEATURES)[number];

View File

@@ -27,3 +27,29 @@ export function sanitizeRichHtml(html: string): string {
if (!html) return ''
return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } })
}
/** Sanitisation pages publiées — préserve le HTML généré par KaTeX (MathML + spans). */
const KATEX_MATH_TAGS = [
'math', 'semantics', 'mrow', 'mi', 'mo', 'mn', 'msup', 'msub', 'mfrac', 'msqrt',
'mroot', 'mtext', 'mspace', 'mstyle', 'mpadded', 'mphantom', 'menclose',
'mover', 'munder', 'munderover', 'mtable', 'mtr', 'mtd', 'mlabeledtr',
'annotation', 'maligngroup', 'malignmark',
] as const
const KATEX_MATH_ATTR = [
'xmlns', 'display', 'mathvariant', 'mathsize', 'mathcolor', 'dir',
'columnalign', 'rowalign', 'columnspacing', 'rowspacing', 'stretchy',
'symmetric', 'maxsize', 'minsize', 'largeop', 'movablelimits', 'accent',
'accentunder', 'fence', 'separator', 'lspace', 'rspace', 'depth', 'height',
'width', 'displaystyle', 'scriptlevel', 'class', 'style', 'aria-hidden',
'encoding', 'data-latex',
] as const
export function sanitizePublishedHtml(html: string): string {
if (!html) return ''
return DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
ADD_TAGS: [...KATEX_MATH_TAGS],
ADD_ATTR: [...KATEX_MATH_ATTR],
})
}

View File

@@ -88,7 +88,12 @@ export interface Note {
searchScore?: number | null;
isPublic?: boolean;
publicSlug?: string | null;
publishedContent?: string | null;
publishedTemplate?: string | null;
publishedSourceHash?: string | null;
publishedAt?: Date | null;
/** Champ virtuel ajouté côté serveur — true si la note est partagée avec l'utilisateur courant */
_isShared?: boolean;
}
export interface NoteHistoryEntry {