feat: publication IA (magazine/brief/essay) + fixes critique
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:
@@ -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()
|
||||
@@ -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 }
|
||||
|
||||
149
memento-note/lib/ai/services/publish-enhance.service.ts
Normal file
149
memento-note/lib/ai/services/publish-enhance.service.ts
Normal 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(/ /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(/ /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()
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
19
memento-note/lib/editor/callout-colors.ts
Normal file
19
memento-note/lib/editor/callout-colors.ts
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
111
memento-note/lib/publish/process-note-html.ts
Normal file
111
memento-note/lib/publish/process-note-html.ts
Normal 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> = { '"': '"', '&': '&', '<': '<', '>': '>', ''': "'" }
|
||||
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/')
|
||||
}
|
||||
286
memento-note/lib/publish/shared-css.ts
Normal file
286
memento-note/lib/publish/shared-css.ts
Normal 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;
|
||||
}
|
||||
`
|
||||
116
memento-note/lib/publish/template-render.ts
Normal file
116
memento-note/lib/publish/template-render.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
208
memento-note/lib/publish/transform-editor-blocks.ts
Normal file
208
memento-note/lib/publish/transform-editor-blocks.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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() || ''
|
||||
}
|
||||
21
memento-note/lib/publish/types.ts
Normal file
21
memento-note/lib/publish/types.ts
Normal 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)
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user