diff --git a/migrations/1781200000000_resource-theme.js b/migrations/1781200000000_resource-theme.js new file mode 100644 index 00000000..af30bf44 --- /dev/null +++ b/migrations/1781200000000_resource-theme.js @@ -0,0 +1,16 @@ +// A presentation's theme, whether or not the model produced a structured deck. +// +// The theme lived only inside the deck JSON, so a presentation that fell back +// to markdown slides — 28 of the 41 in production — had nowhere to keep one, +// and "change template" answered that it had no layout to change. The column +// holds the author's choice for every presentation; a deck's own theme field +// is kept in step with it. + +exports.up = pgm => pgm.sql(` + ALTER TABLE user_resources ADD COLUMN IF NOT EXISTS theme TEXT; + UPDATE user_resources SET theme = deck->>'theme' WHERE theme IS NULL AND deck IS NOT NULL AND deck->>'theme' IS NOT NULL; +`); + +exports.down = pgm => pgm.sql(` + ALTER TABLE user_resources DROP COLUMN IF EXISTS theme; +`); diff --git a/src/routes/myResources.js b/src/routes/myResources.js index e8f11b3c..92594d0c 100644 --- a/src/routes/myResources.js +++ b/src/routes/myResources.js @@ -416,7 +416,7 @@ router.post('/my-resources/generate', async function (req, res) { // after a single unlucky reply. var firstFailure = deckFailure(ai && ai.content); logger.warn('[my-resources] deck reply was not usable (' + firstFailure + '); asking once more', - { topic: topic, attempt: 1, model: ai && ai.model }); + { topic: topic, attempt: 1, model: ai && ai.model, reply: String((ai && ai.content) || '').slice(0, 240) }); var retryGaps = []; ai = await callAI([{ role: 'user', content: prompt }], options); deck = deckBuild.parse(ai && ai.content, retryGaps); @@ -429,7 +429,7 @@ router.post('/my-resources/generate', async function (req, res) { // beats saving the model's apology. deckFallback = deckFailure(ai && ai.content); logger.warn('[my-resources] deck reply was not usable twice (' + deckFallback + - '); retrying as markdown', { topic: topic, attempt: 2, model: ai && ai.model }); + '); retrying as markdown', { topic: topic, attempt: 2, model: ai && ai.model, reply: String((ai && ai.content) || '').slice(0, 240) }); var plain = buildPrompt({ topic: topic, kind: kind, refinement: refinement, corpusContext: corpus.context, literature: sources.literature, webFindings: sources.webFindings, @@ -487,11 +487,12 @@ router.post('/my-resources/generate', async function (req, res) { if (!markdown) return res.status(502).json({ error: 'The model returned nothing. Try again.' }); var row = await db.get( - 'INSERT INTO user_resources (user_id, title, kind, markdown, topic, grounded_count, image_ids, deck) ' + - 'VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, title, kind, topic, grounded_count, created_at', + 'INSERT INTO user_resources (user_id, title, kind, markdown, topic, grounded_count, image_ids, deck, theme) ' + + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, title, kind, topic, grounded_count, created_at', [req.user.id, deck && deck.title ? deck.title : firstHeading(markdown, topic), kind, markdown, topic.slice(0, 500), corpus.sources.length, JSON.stringify(savedFigureIds), - deck ? JSON.stringify(deck) : null] + deck ? JSON.stringify(deck) : null, + kind === 'presentation' ? (deckSchema.themeId(req.body.theme) || null) : null] ); res.json({ @@ -655,18 +656,21 @@ router.put('/my-resources/:id/theme', async function (req, res) { return res.status(400).json({ error: 'That is not one of the available themes.' }); } var row = await db.get( - 'SELECT id, deck FROM user_resources WHERE id = ? AND user_id = ?', + 'SELECT id, kind, deck FROM user_resources WHERE id = ? AND user_id = ?', [parseInt(req.params.id, 10), req.user.id] ); if (!row) return res.status(404).json({ error: 'Not found' }); - if (!row.deck) { - return res.status(400).json({ error: 'This resource has no slide layout, so it has no theme to change.' }); + if (row.kind !== 'presentation') { + return res.status(400).json({ error: 'An article has no slides, so it has no theme to change.' }); } - var deck = typeof row.deck === 'string' ? JSON.parse(row.deck) : row.deck; - deck.theme = theme; + // Every presentation has a theme, structured deck or markdown slides: the + // renderer applies it to both. It used to be refused for the markdown + // ones — most of them — because the theme lived only inside the deck. + var deck = row.deck ? (typeof row.deck === 'string' ? JSON.parse(row.deck) : row.deck) : null; + if (deck) deck.theme = theme; await db.run( - 'UPDATE user_resources SET deck = ?::jsonb, updated_at = NOW() WHERE id = ? AND user_id = ?', - [JSON.stringify(deck), row.id, req.user.id] + 'UPDATE user_resources SET theme = ?, deck = COALESCE(?::jsonb, deck), updated_at = NOW() WHERE id = ? AND user_id = ?', + [theme || null, deck ? JSON.stringify(deck) : null, row.id, req.user.id] ); res.json({ success: true, theme: theme }); } catch (err) { @@ -1014,7 +1018,7 @@ router.get('/my-resources/:id/export', async function (req, res) { if (!documentExport.isSupported(format)) return res.status(400).json({ error: 'Unsupported format' }); var row = await db.get( - 'SELECT title, kind, markdown, image_ids, deck FROM user_resources WHERE id = ? AND user_id = ?', + 'SELECT title, kind, markdown, image_ids, deck, theme FROM user_resources WHERE id = ? AND user_id = ?', [parseInt(req.params.id, 10), req.user.id] ); if (!row) return res.status(404).json({ error: 'Not found' }); @@ -1037,7 +1041,7 @@ router.get('/my-resources/:id/export', async function (req, res) { var bytes; try { bytes = await documentExport.render(row.markdown, row.kind, format, - { images: figures, deck: row.deck, figureIds: figureIdList(row.image_ids) }); + { images: figures, deck: row.deck, theme: row.theme, figureIds: figureIdList(row.image_ids) }); } finally { if (scratch) { await require('fs/promises').rm(scratch, { recursive: true, force: true }) diff --git a/src/utils/documentExport.js b/src/utils/documentExport.js index 2ac50847..bcdfc1bb 100644 --- a/src/utils/documentExport.js +++ b/src/utils/documentExport.js @@ -191,7 +191,10 @@ async function buildDeck(markdown, workdir, images, options) { // to inferring a layout from markdown. var spec = options.deck ? attachFigures(options.deck, images, options.figureIds) - : slideSpec.build(markdown, { images: images }); + : slideSpec.build(markdown, { images: images, theme: options.theme }); + // The author's theme applies to markdown slides exactly as to a designed + // deck; a deck's own field, when present, is the same value kept in step. + if (options.theme && !spec.theme) spec.theme = options.theme; await runRenderer(DECK_RENDERER, out, spec, workdir); var built = await fsp.readFile(out); if (built.length) return; diff --git a/src/utils/slideSpec.js b/src/utils/slideSpec.js index 63adf26a..93855cbc 100644 --- a/src/utils/slideSpec.js +++ b/src/utils/slideSpec.js @@ -165,7 +165,7 @@ function build(markdown, options) { } return { title: meta.title, subtitle: meta.subtitle, date: meta.date, - slides: slides.concat(content) }; + theme: options.theme || undefined, slides: slides.concat(content) }; } module.exports = { build, TWO_COLUMN_AT }; diff --git a/test/deck-theme-applied.test.js b/test/deck-theme-applied.test.js index f444e8be..f5172edd 100644 --- a/test/deck-theme-applied.test.js +++ b/test/deck-theme-applied.test.js @@ -45,3 +45,21 @@ test('an unknown theme leaves the default rather than failing the render', () => const fn = renderer.slice(renderer.indexOf('def apply_theme')); assert.match(fn.slice(0, 400), /a deck rendering in the wrong colours beats a deck not rendering/); }); + +test('a presentation that fell back to markdown slides still takes a theme', () => { + // 28 of the 41 presentations in production had no structured deck, and for + // every one of them "change template" answered that there was no layout to + // change. The theme is a column now, and the markdown slide builder carries + // it to the same renderer field a designed deck uses. + const slideSpec = require('../src/utils/slideSpec'); + const spec = slideSpec.build('% Title\n% Author\n% 2026\n\n# One\n\n- a\n- b\n', { theme: 'ward-teal' }); + assert.equal(spec.theme, 'ward-teal'); + assert.equal(slideSpec.build('# One\n- a').theme, undefined, 'no theme asked for, none invented'); + const route = fs.readFileSync(path.join(__dirname, '..', 'src/routes/myResources.js'), 'utf8'); + assert.doesNotMatch(route, /has no slide layout, so it has no theme to change/); + assert.match(route, /UPDATE user_resources SET theme = \?, deck = COALESCE\(\?::jsonb, deck\)/); + assert.match(route, /deck: row\.deck, theme: row\.theme, figureIds/); + const exporter = fs.readFileSync(path.join(__dirname, '..', 'src/utils/documentExport.js'), 'utf8'); + assert.match(exporter, /slideSpec\.build\(markdown, \{ images: images, theme: options\.theme \}\)/); + assert.match(fs.readFileSync(path.join(__dirname, '..', 'migrations/1781200000000_resource-theme.js'), 'utf8'), /ADD COLUMN IF NOT EXISTS theme TEXT/); +}); diff --git a/test/deck-themes.test.js b/test/deck-themes.test.js index cbb8830a..f99a31b4 100644 --- a/test/deck-themes.test.js +++ b/test/deck-themes.test.js @@ -55,10 +55,12 @@ test('the renderer rebinds its palette instead of hardcoding one', () => { test('re-skinning is a column write, never a regeneration', () => { const route = read('src/routes/myResources.js'); const handler = route.slice(route.indexOf("router.put('/my-resources/:id/theme'")); - assert.match(handler.slice(0, 1400), /UPDATE user_resources SET deck = \?::jsonb/); - assert.doesNotMatch(handler.slice(0, 1400), /callAI/, 'no model call: nothing can reword a slide'); - assert.match(handler.slice(0, 1400), /AND user_id = \?/, 'scoped to the owner'); - assert.match(handler.slice(0, 1400), /has no slide layout/, 'flat markdown has no theme'); + assert.match(handler.slice(0, 1600), /UPDATE user_resources SET theme = \?, deck = COALESCE\(\?::jsonb, deck\)/); + assert.doesNotMatch(handler.slice(0, 1600), /callAI/, 'no model call: nothing can reword a slide'); + assert.match(handler.slice(0, 1600), /AND user_id = \?/, 'scoped to the owner'); + // Markdown slides take a theme too — the renderer applies it to both — so + // the only thing refused is an article, which has no slides at all. + assert.match(handler.slice(0, 1600), /An article has no slides/, 'articles have no theme'); }); test('the theme is shown by a deck the renderer built, not a mocked-up swatch', () => {