From d6bcca2a25300f2962357ab3a190a5122fdd96d0 Mon Sep 17 00:00:00 2001 From: Igor Balos Date: Fri, 10 Oct 2025 11:49:24 +0200 Subject: [PATCH 1/4] described more intentionally what reading time function does --- ghost/core/core/frontend/helpers/reading_time.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ghost/core/core/frontend/helpers/reading_time.js b/ghost/core/core/frontend/helpers/reading_time.js index dc3743c414a..6de413724b1 100644 --- a/ghost/core/core/frontend/helpers/reading_time.js +++ b/ghost/core/core/frontend/helpers/reading_time.js @@ -13,18 +13,19 @@ const {checks} = require('../services/data'); const {SafeString} = require('../services/handlebars'); -const {readingTime: calculateReadingTime} = require('@tryghost/helpers'); +const {readingTime: calculateAndFormatReadingTime} = require('@tryghost/helpers'); module.exports = function reading_time(options) {// eslint-disable-line camelcase options = options || {}; options.hash = options.hash || {}; + const possiblyPost = this; // only calculate reading time for posts - if (!checks.isPost(this)) { + if (!checks.isPost(possiblyPost)) { return null; } - let readingTime = calculateReadingTime(this, options.hash); + const readingTime = calculateAndFormatReadingTime(possiblyPost, options.hash); return new SafeString(readingTime); }; From 9ba2ed7ec6276a7daa13a88c48709bd65a5c0bfc Mon Sep 17 00:00:00 2001 From: Igor Balos Date: Fri, 10 Oct 2025 13:49:10 +0200 Subject: [PATCH 2/4] added migration for adding reading time to the posts table revert tests --- .../utils/serializers/output/utils/extra-attrs.js | 8 ++++++-- ...025-10-10-10-57-59-add-reading-time-column-to-posts.js | 7 +++++++ ghost/core/core/server/data/schema/schema.js | 3 ++- .../admin/__snapshots__/activity-feed.test.js.snap | 4 ++-- .../test/e2e-api/admin/__snapshots__/members.test.js.snap | 2 +- ghost/core/test/unit/server/data/schema/integrity.test.js | 2 +- 6 files changed, 19 insertions(+), 7 deletions(-) create mode 100644 ghost/core/core/server/data/migrations/versions/6.4/2025-10-10-10-57-59-add-reading-time-column-to-posts.js diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/extra-attrs.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/extra-attrs.js index f1dc5b17501..2dab4a6fec3 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/extra-attrs.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/extra-attrs.js @@ -13,6 +13,7 @@ module.exports.forPost = (options, model, attrs) => { const columnsIncludesPlaintext = options.columns?.includes('plaintext'); const columnsIncludesReadingTime = options.columns?.includes('reading_time'); const formatsIncludesPlaintext = options.formats?.includes('plaintext'); + const noColumnsRequested = !Object.prototype.hasOwnProperty.call(options, 'columns'); // 1. Gets excerpt from post's plaintext. If custom_excerpt exists, it overrides the excerpt but the key remains excerpt. if (columnsIncludesExcerpt) { @@ -43,7 +44,7 @@ module.exports.forPost = (options, model, attrs) => { } // 3. Displays excerpt if no columns was requested - specifically needed for the Admin Posts API - if (!Object.prototype.hasOwnProperty.call(options, 'columns')) { + if (noColumnsRequested) { let customExcerpt = model.get('custom_excerpt'); if (customExcerpt !== null) { @@ -59,7 +60,7 @@ module.exports.forPost = (options, model, attrs) => { } // 4. Add `reading_time` if no columns were requested, or if `reading_time` was requested via `columns` - if (!Object.prototype.hasOwnProperty.call(options, 'columns') || columnsIncludesReadingTime) { + if (noColumnsRequested || columnsIncludesReadingTime) { if (attrs.html) { let additionalImages = 0; @@ -67,6 +68,9 @@ module.exports.forPost = (options, model, attrs) => { additionalImages += 1; } attrs.reading_time = readingMinutes(attrs.html, additionalImages); + } else if (attrs.reading_time === null) { + // Remove null reading_time from response + delete attrs.reading_time; } } }; diff --git a/ghost/core/core/server/data/migrations/versions/6.4/2025-10-10-10-57-59-add-reading-time-column-to-posts.js b/ghost/core/core/server/data/migrations/versions/6.4/2025-10-10-10-57-59-add-reading-time-column-to-posts.js new file mode 100644 index 00000000000..fe4745c8d10 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.4/2025-10-10-10-57-59-add-reading-time-column-to-posts.js @@ -0,0 +1,7 @@ +const {createAddColumnMigration} = require('../../utils'); + +module.exports = createAddColumnMigration('posts', 'reading_time', { + type: 'integer', + nullable: true, + unsigned: true +}); diff --git a/ghost/core/core/server/data/schema/schema.js b/ghost/core/core/server/data/schema/schema.js index 6b335beeade..5189bb4a53d 100644 --- a/ghost/core/core/server/data/schema/schema.js +++ b/ghost/core/core/server/data/schema/schema.js @@ -1,7 +1,7 @@ /* String Column Sizes Information * (From: https://github.com/TryGhost/Ghost/pull/7932) * New/Updated column maxlengths should meet these guidlines - * + * * Small strings = length 50 * Medium strings = length 191 * Large strings = length 2000 (use soft limits via validation for 191-2000) @@ -96,6 +96,7 @@ module.exports = { canonical_url: {type: 'text', maxlength: 2000, nullable: true}, newsletter_id: {type: 'string', maxlength: 24, nullable: true, references: 'newsletters.id'}, show_title_and_feature_image: {type: 'boolean', nullable: false, defaultTo: true}, + reading_time: {type: 'integer', nullable: true, unsigned: true}, '@@INDEXES@@': [ ['type','status','updated_at'] ], diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap index 61eb698d9ff..3865ae7b013 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap @@ -22699,7 +22699,7 @@ exports[`Activity Feed API Can filter events by post id 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "17979", + "content-length": "18039", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -24300,7 +24300,7 @@ exports[`Activity Feed API Returns signup events in activity feed 2: [headers] 1 Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "21855", + "content-length": "22015", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap index fa84d5c213b..22a7d6985ac 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap @@ -386,7 +386,7 @@ exports[`Members API - member attribution Returns sign up attributions of all ty Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "8685", + "content-length": "8725", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, diff --git a/ghost/core/test/unit/server/data/schema/integrity.test.js b/ghost/core/test/unit/server/data/schema/integrity.test.js index 4ff359942f7..e2ef4bcceef 100644 --- a/ghost/core/test/unit/server/data/schema/integrity.test.js +++ b/ghost/core/test/unit/server/data/schema/integrity.test.js @@ -35,7 +35,7 @@ const validateRouteSettings = require('../../../../../core/server/services/route */ describe('DB version integrity', function () { // Only these variables should need updating - const currentSchemaHash = '3071f336e59e2ff87f1336caf5dfaed6'; + const currentSchemaHash = '97d31db45c4817ed5e7760cf5d60de90'; const currentFixturesHash = '0877727032b8beddbaedc086a8acf1a2'; const currentSettingsHash = 'bb8be7d83407f2b4fa2ad68c19610579'; const currentRoutesHash = '3d180d52c663d173a6be791ef411ed01'; From 190697f70fb3f61fab44316e48f645358bd6293b Mon Sep 17 00:00:00 2001 From: Igor Balos Date: Fri, 10 Oct 2025 16:01:34 +0200 Subject: [PATCH 3/4] aded backfil migration --- ...25-10-10-13-23-52-backfill-reading-times.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 ghost/core/core/server/data/migrations/versions/6.4/2025-10-10-13-23-52-backfill-reading-times.js diff --git a/ghost/core/core/server/data/migrations/versions/6.4/2025-10-10-13-23-52-backfill-reading-times.js b/ghost/core/core/server/data/migrations/versions/6.4/2025-10-10-13-23-52-backfill-reading-times.js new file mode 100644 index 00000000000..7ccf4213c0b --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.4/2025-10-10-13-23-52-backfill-reading-times.js @@ -0,0 +1,18 @@ +const logging = require('@tryghost/logging'); +const {createTransactionalMigration} = require('../../utils'); +const {calculatePostReadingTime} = require('../../../../../shared/post-reading-times'); + +module.exports = createTransactionalMigration( + async function up(knex) { + const posts = await knex.select('id', 'html', 'feature_image').from('posts').whereNotNull('html'); + + logging.info(`Adding reading_time field value to ${posts.length} posts.`); + + // eslint-disable-next-line no-restricted-syntax + for (const post of posts) { + const readingTime = calculatePostReadingTime(post.html, post.feature_image); + await knex('posts').update('reading_time', readingTime).where('id', post.id); + } + }, + async function down() {} +); From 7295342824961e1eeb7a51939a7d7ab8a66ffa70 Mon Sep 17 00:00:00 2001 From: Igor Balos Date: Fri, 10 Oct 2025 16:02:09 +0200 Subject: [PATCH 4/4] moved to using real database reading_time, instead of using it on-the-fly --- .../serializers/output/utils/extra-attrs.js | 12 +----------- ghost/core/core/server/models/post.js | 10 ++++++++++ ghost/core/core/shared/post-reading-times.js | 16 ++++++++++++++++ .../__snapshots__/activity-feed.test.js.snap | 4 ++-- .../admin/__snapshots__/members.test.js.snap | 2 +- 5 files changed, 30 insertions(+), 14 deletions(-) create mode 100644 ghost/core/core/shared/post-reading-times.js diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/extra-attrs.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/extra-attrs.js index 2dab4a6fec3..5a32026749d 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/extra-attrs.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/extra-attrs.js @@ -1,5 +1,3 @@ -const readingMinutes = require('@tryghost/helpers').utils.readingMinutes; - /** * * @param {Object} options - frame options @@ -61,15 +59,7 @@ module.exports.forPost = (options, model, attrs) => { // 4. Add `reading_time` if no columns were requested, or if `reading_time` was requested via `columns` if (noColumnsRequested || columnsIncludesReadingTime) { - if (attrs.html) { - let additionalImages = 0; - - if (attrs.feature_image) { - additionalImages += 1; - } - attrs.reading_time = readingMinutes(attrs.html, additionalImages); - } else if (attrs.reading_time === null) { - // Remove null reading_time from response + if (attrs.reading_time === null) { delete attrs.reading_time; } } diff --git a/ghost/core/core/server/models/post.js b/ghost/core/core/server/models/post.js index 4b18c028750..a611ba7e5d5 100644 --- a/ghost/core/core/server/models/post.js +++ b/ghost/core/core/server/models/post.js @@ -20,6 +20,7 @@ const {Newsletter} = require('./newsletter'); const {BadRequestError} = require('@tryghost/errors'); const {mobiledocToLexical} = require('@tryghost/kg-converters'); const {setIsRoles} = require('./role-utils'); +const {calculatePostReadingTime} = require('../../shared/post-reading-times'); const messages = { isAlreadyPublished: 'Your post is already published, please reload your page.', @@ -750,6 +751,15 @@ Post = ghostBookshelf.Model.extend({ } } + // Calculate and store reading_time whenever html or feature_image changes + if (this.hasChanged('html') || this.hasChanged('feature_image')) { + if (this.get('html')) { + this.set('reading_time', calculatePostReadingTime(this.get('html'), this.get('feature_image'))); + } else { + this.set('reading_time', null); + } + } + // disabling sanitization until we can implement a better version if (!options.importing) { title = this.get('title') || tpl(messages.untitled); diff --git a/ghost/core/core/shared/post-reading-times.js b/ghost/core/core/shared/post-reading-times.js new file mode 100644 index 00000000000..aba383499d2 --- /dev/null +++ b/ghost/core/core/shared/post-reading-times.js @@ -0,0 +1,16 @@ +const {readingMinutes} = require('@tryghost/helpers').utils; + +/** + * Calculate reading time for a post + * @param {string} html - Post HTML content + * @param {string|null} featureImage - Feature image URL (or null) + * @returns {number} Reading time in minutes + */ +function calculatePostReadingTime(html, featureImage) { + const additionalImages = featureImage ? 1 : 0; + return readingMinutes(html, additionalImages); +} + +module.exports = { + calculatePostReadingTime +}; diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap index 3865ae7b013..f5249e14ee0 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap @@ -22699,7 +22699,7 @@ exports[`Activity Feed API Can filter events by post id 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "18039", + "content-length": "18030", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -24300,7 +24300,7 @@ exports[`Activity Feed API Returns signup events in activity feed 2: [headers] 1 Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "22015", + "content-length": "21991", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap index 22a7d6985ac..8ef19dfe683 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap @@ -386,7 +386,7 @@ exports[`Members API - member attribution Returns sign up attributions of all ty Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "8725", + "content-length": "8719", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,