Files
Momento/memento-note/lib/brainstorm/export-pptx.ts
Antigravity 96e7902f01
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m22s
CI / Deploy production (on server) (push) Has been skipped
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
2026-06-28 07:32:57 +00:00

350 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type PptxGenJSModule from 'pptxgenjs'
let _PptxGenJS: (new () => PptxGenJSModule) | null = null
async function getPptxGenClass(): Promise<new () => PptxGenJSModule> {
if (!_PptxGenJS) {
const mod = await import('pptxgenjs')
_PptxGenJS = (mod.default ?? mod) as unknown as new () => PptxGenJSModule
}
return _PptxGenJS
}
// ── Theme — cohérent avec l'identité visuelle Memento ───────────────────────
const T = {
bg: 'F2F0E9',
primary: '1C1C1C',
accent: 'A47148',
secondary: 'D4A373',
muted: '9A8C87',
wave1: 'fb923c',
wave2: '60a5fa',
wave3: 'a78bfa',
green: '10b981',
}
const WAVE_LABELS: Record<number, string> = {
1: '🔄 Variations',
2: '🔗 Analogies',
3: '💥 Disruptions',
}
const WAVE_COLORS: Record<number, string> = {
1: T.wave1,
2: T.wave2,
3: T.wave3,
}
// ── Types (minimal subset) ──────────────────────────────────────────────────
interface IdeaLike {
id: string
title: string
description: string
waveNumber: number
status: string
isStarred: boolean
convertedToNoteId: string | null
}
interface SessionLike {
seedIdea: string
createdAt: Date
ideas: IdeaLike[]
}
// ── Helpers ─────────────────────────────────────────────────────────────────
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.slice(0, 40)
}
function truncate(text: string, max: number): string {
return text.length > max ? text.slice(0, max - 1) + '…' : text
}
/**
* PLG viral watermark — added to every slide automatically via addSlide monkey-patch.
* LAYOUT_WIDE = 13.33" × 7.5" → watermark anchored near bottom-right at y ≈ 7.1
*/
function addWatermark(slide: any) {
slide.addText('memento-note.com', {
x: 10.0, y: 7.1, w: 3.0, h: 0.25,
fontSize: 7, fontFace: 'Arial', color: 'B8B0A8',
align: 'right', italic: true,
})
}
/** 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[]) => {
const slide = original(...args)
addWatermark(slide)
return slide
}
return pres
}
// Add a consistent slide background
function addBg(slide: any) {
slide.background = { color: T.bg }
}
// Accent bar at the top of a slide
function addTopBar(slide: any, color: string = T.accent) {
slide.addShape('rect', { x: 0, y: 0, w: '100%', h: 0.12, fill: { color } })
}
// ── Slide builders ───────────────────────────────────────────────────────────
function buildCoverSlide(pres: PptxGenJSModule, session: SessionLike, stats: { total: number; converted: number; starred: number }) {
const slide = pres.addSlide()
addBg(slide)
// Left accent panel
slide.addShape('rect', { x: 0, y: 0, w: 3.6, h: '100%', fill: { color: T.primary } })
// Brand label on accent panel
slide.addText('MOMENTO', {
x: 0.3, y: 0.4, w: 3.0, h: 0.4,
fontSize: 9, fontFace: 'Arial', color: T.accent, bold: true,
charSpacing: 4, align: 'left',
})
// Seed idea (big) on right
slide.addText(truncate(session.seedIdea, 80), {
x: 4.0, y: 1.2, w: 5.6, h: 2.4,
fontSize: 26, fontFace: 'Georgia', color: T.primary,
bold: false, align: 'left', valign: 'middle', wrap: true,
})
// Date
slide.addText(session.createdAt.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' }), {
x: 4.0, y: 4.0, w: 5.6, h: 0.4,
fontSize: 10, fontFace: 'Arial', color: T.muted, align: 'left',
})
// Stats row
const statItems = [
{ label: 'idées', value: String(stats.total) },
{ label: 'converties', value: String(stats.converted) },
{ label: 'favorites', value: String(stats.starred) },
]
statItems.forEach((s, i) => {
const x = 4.0 + i * 2.0
slide.addText(s.value, { x, y: 4.8, w: 1.8, h: 0.55, fontSize: 22, fontFace: 'Georgia', color: T.accent, bold: true, align: 'left' })
slide.addText(s.label, { x, y: 5.35, w: 1.8, h: 0.3, fontSize: 9, fontFace: 'Arial', color: T.muted, align: 'left', charSpacing: 1 })
})
// Vertical label "BRAINSTORM" on left panel
slide.addText('BRAINSTORM', {
x: 0.1, y: 2.0, w: 3.2, h: 0.5,
fontSize: 11, fontFace: 'Arial', color: 'FFFFFF', bold: true,
charSpacing: 6, align: 'center',
rotate: 270,
})
}
function buildWaveSlide(pres: PptxGenJSModule, wave: number, ideas: IdeaLike[]) {
const slide = pres.addSlide()
addBg(slide)
addTopBar(slide, WAVE_COLORS[wave] || T.accent)
// Wave label
slide.addText(WAVE_LABELS[wave] || `Wave ${wave}`, {
x: 0.5, y: 0.25, w: 9.0, h: 0.5,
fontSize: 14, fontFace: 'Arial', color: WAVE_COLORS[wave] || T.accent, bold: true, charSpacing: 2,
})
// Count badge
slide.addShape('roundRect', { x: 9.0, y: 0.28, w: 0.6, h: 0.4, fill: { color: WAVE_COLORS[wave] || T.accent }, rectRadius: 0.1 })
slide.addText(String(ideas.length), { x: 9.0, y: 0.28, w: 0.6, h: 0.4, fontSize: 11, fontFace: 'Arial', color: 'FFFFFF', bold: true, align: 'center', valign: 'middle' })
const maxPerSlide = 6
const shown = ideas.slice(0, maxPerSlide)
const colW = 4.5
const rowH = 1.3
const startY = 1.0
shown.forEach((idea, i) => {
const col = i % 2
const row = Math.floor(i / 2)
const x = 0.4 + col * (colW + 0.3)
const y = startY + row * (rowH + 0.15)
// Card background
slide.addShape('roundRect', { x, y, w: colW, h: rowH, fill: { color: 'FFFFFF' }, line: { color: 'E8E6E0', width: 0.75 }, rectRadius: 0.08 })
// Star / converted badge
if (idea.isStarred || idea.convertedToNoteId) {
const badge = idea.convertedToNoteId ? '✓' : '⭐'
const badgeColor = idea.convertedToNoteId ? T.green : T.wave1
slide.addText(badge, { x: x + colW - 0.45, y: y + 0.08, w: 0.35, h: 0.35, fontSize: 10, fontFace: 'Arial', color: badgeColor, align: 'center' })
}
// Title
slide.addText(truncate(idea.title, 55), {
x: x + 0.18, y: y + 0.12, w: colW - 0.55, h: 0.4,
fontSize: 11, fontFace: 'Arial', color: T.primary, bold: true, wrap: true,
})
// Description
slide.addText(truncate(idea.description, 120), {
x: x + 0.18, y: y + 0.52, w: colW - 0.36, h: 0.65,
fontSize: 9, fontFace: 'Arial', color: T.muted, wrap: true, valign: 'top',
})
})
if (ideas.length > maxPerSlide) {
slide.addText(`+ ${ideas.length - maxPerSlide} autres idées`, {
x: 0.4, y: 5.5, w: 9.2, h: 0.3,
fontSize: 9, fontFace: 'Arial', color: T.muted, align: 'center', italic: true,
})
}
}
function buildTopIdeasSlide(pres: PptxGenJSModule, starred: IdeaLike[], converted: IdeaLike[]) {
const slide = pres.addSlide()
addBg(slide)
addTopBar(slide, T.accent)
slide.addText('Top Idées', {
x: 0.5, y: 0.25, w: 9.0, h: 0.5,
fontSize: 14, fontFace: 'Arial', color: T.accent, bold: true, charSpacing: 2,
})
const all = [
...starred.map(i => ({ ...i, badge: '⭐', badgeColor: T.wave1 })),
...converted.filter(i => !i.isStarred).map(i => ({ ...i, badge: '✓', badgeColor: T.green })),
].slice(0, 6)
if (all.length === 0) {
slide.addText('Aucune idée favorite ou convertie.', {
x: 0.5, y: 3.0, w: 9.0, h: 0.5,
fontSize: 12, fontFace: 'Georgia', color: T.muted, align: 'center', italic: true,
})
return
}
const colW = 4.5
const rowH = 1.25
all.forEach((idea, i) => {
const col = i % 2
const row = Math.floor(i / 2)
const x = 0.4 + col * (colW + 0.3)
const y = 1.1 + row * (rowH + 0.15)
const waveColor = WAVE_COLORS[idea.waveNumber] || T.muted
slide.addShape('roundRect', { x, y, w: colW, h: rowH, fill: { color: 'FFFFFF' }, line: { color: waveColor, width: 1.5 }, rectRadius: 0.08 })
slide.addText(idea.badge, { x: x + 0.1, y: y + 0.1, w: 0.4, h: 0.4, fontSize: 14, fontFace: 'Arial', color: idea.badgeColor, align: 'center' })
slide.addText(truncate(idea.title, 55), {
x: x + 0.55, y: y + 0.1, w: colW - 0.7, h: 0.4,
fontSize: 11, fontFace: 'Arial', color: T.primary, bold: true, wrap: true,
})
slide.addText(truncate(idea.description, 110), {
x: x + 0.18, y: y + 0.55, w: colW - 0.36, h: 0.6,
fontSize: 9, fontFace: 'Arial', color: T.muted, wrap: true, valign: 'top',
})
})
}
function buildSummarySlide(pres: PptxGenJSModule, session: SessionLike, stats: { total: number; converted: number; starred: number; dismissed: number }) {
const slide = pres.addSlide()
addBg(slide)
addTopBar(slide, T.accent)
slide.addText('BILAN DE SESSION', {
x: 0.8, y: 0.25, w: 11.5, h: 0.5,
fontSize: 10, fontFace: 'Arial', color: T.accent, bold: true, charSpacing: 5, align: 'left',
})
slide.addText(truncate(session.seedIdea, 80), {
x: 0.8, y: 0.9, w: 11.5, h: 1.0,
fontSize: 20, fontFace: 'Georgia', color: T.primary, align: 'left', wrap: true,
})
// Divider
slide.addShape('rect', { x: 0.8, y: 2.1, w: 11.5, h: 0.02, fill: { color: T.accent } })
// Stats cards
const statCols = [
{ label: 'IDÉES GÉNÉRÉES', value: String(stats.total), color: T.wave2, bg: 'EEF6FF' },
{ label: 'CONVERTIES EN NOTES', value: String(stats.converted), color: T.green, bg: 'EDFAF4' },
{ label: 'FAVORITES', value: String(stats.starred), color: T.wave1, bg: 'FFF4EE' },
{ label: 'REJETÉES', value: String(stats.dismissed), color: T.muted, bg: 'F5F4F2' },
]
const cardW = 2.7
const cardH = 2.8
const startX = 0.8
const startY = 2.3
statCols.forEach((s, i) => {
const x = startX + i * (cardW + 0.2)
// Card bg
slide.addShape('roundRect', { x, y: startY, w: cardW, h: cardH, fill: { color: s.bg }, rectRadius: 0.1 })
// Top accent strip
slide.addShape('roundRect', { x, y: startY, w: cardW, h: 0.1, fill: { color: s.color }, rectRadius: 0.05 })
// Big number
slide.addText(s.value, { x: x + 0.2, y: startY + 0.35, w: cardW - 0.4, h: 1.2, fontSize: 48, fontFace: 'Georgia', color: s.color, bold: true, align: 'left', valign: 'middle' })
// Label
slide.addText(s.label, { x: x + 0.2, y: startY + 1.7, w: cardW - 0.4, h: 0.9, fontSize: 8, fontFace: 'Arial', color: T.muted, align: 'left', charSpacing: 1.5, wrap: true })
})
}
// ── Main export function ─────────────────────────────────────────────────────
export async function generateBrainstormPptx(session: SessionLike): Promise<{ buffer: Buffer; filename: string }> {
const PptxGenJS = await getPptxGenClass()
const pres = withWatermark(new PptxGenJS())
pres.layout = 'LAYOUT_WIDE'
pres.author = 'Memento'
pres.subject = `Brainstorm: ${session.seedIdea}`
const activeIdeas = session.ideas.filter(i => i.status !== 'dismissed')
const dismissedCount = session.ideas.filter(i => i.status === 'dismissed').length
const converted = activeIdeas.filter(i => i.convertedToNoteId !== null)
const starred = activeIdeas.filter(i => i.isStarred)
const stats = {
total: activeIdeas.length,
converted: converted.length,
starred: starred.length,
dismissed: dismissedCount,
}
// Slide 1 — Cover
buildCoverSlide(pres, session, stats)
// Slides 2-4 — One per active wave
for (const wave of [1, 2, 3]) {
const waveIdeas = activeIdeas.filter(i => i.waveNumber === wave)
if (waveIdeas.length === 0) continue
buildWaveSlide(pres, wave, waveIdeas)
}
// Slide N — Top ideas (starred + converted)
if (starred.length > 0 || converted.length > 0) {
buildTopIdeasSlide(pres, starred, converted)
}
// Last slide — Summary
buildSummarySlide(pres, session, stats)
const buffer = (await pres.write({ outputType: 'nodebuffer' })) as unknown as Buffer
const filename = `brainstorm-${slugify(session.seedIdea)}.pptx`
return { buffer, filename }
}