diff --git a/com.woltlab.wcf/templates/articleContentHeaderTitle.tpl b/com.woltlab.wcf/templates/articleContentHeaderTitle.tpl index e6bd0663017..bd949329562 100644 --- a/com.woltlab.wcf/templates/articleContentHeaderTitle.tpl +++ b/com.woltlab.wcf/templates/articleContentHeaderTitle.tpl @@ -18,6 +18,15 @@ + {if $articleContent->getReadingTime()} +
  • + {icon name='hourglass'} + {lang minutes=$articleContent->getReadingTime()}wcf.page.readingTime{/lang} + + +
  • + {/if} + {if $article->hasLabels}
  • {icon name='tags'} diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php index 247492f61eb..b36489ecd63 100644 --- a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php +++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php @@ -42,6 +42,9 @@ ->defaultValue(0), NotNullVarchar255DatabaseTableColumn::create('slug') ->defaultValue(''), + IntDatabaseTableColumn::create('numberOfWords') + ->notNull() + ->defaultValue(0), ]), PartialDatabaseTable::create('wcf1_label_group') ->columns([ diff --git a/wcfsetup/install/files/lib/data/article/ArticleAction.class.php b/wcfsetup/install/files/lib/data/article/ArticleAction.class.php index 9b0a49eb153..81c5558cbfd 100644 --- a/wcfsetup/install/files/lib/data/article/ArticleAction.class.php +++ b/wcfsetup/install/files/lib/data/article/ArticleAction.class.php @@ -35,6 +35,7 @@ use wcf\system\user\object\watch\UserObjectWatchHandler; use wcf\system\version\VersionTracker; use wcf\system\WCF; +use wcf\util\StringUtil; /** * Executes article related actions. @@ -110,6 +111,7 @@ public function create() 'slug' => $content['slug'] ?? '', 'teaser' => $content['teaser'], 'content' => $content['content'], + 'numberOfWords' => StringUtil::countWords($content['content']), 'imageID' => $content['imageID'], 'teaserImageID' => $content['teaserImageID'], 'metaTitle' => $content['metaTitle'] ?? '', @@ -210,6 +212,7 @@ public function update() 'slug' => $isRevert ? $articleContent->slug : ($content['slug'] ?? ''), 'teaser' => $content['teaser'], 'content' => $content['content'], + 'numberOfWords' => StringUtil::countWords($content['content']), 'imageID' => ($isRevert) ? $articleContent->imageID : $content['imageID'], 'teaserImageID' => ($isRevert) ? $articleContent->teaserImageID : $content['teaserImageID'], 'metaTitle' => $content['metaTitle'] ?? '', @@ -244,6 +247,7 @@ public function update() 'slug' => $isRevert ? '' : ($content['slug'] ?? ''), 'teaser' => $content['teaser'], 'content' => $content['content'], + 'numberOfWords' => StringUtil::countWords($content['content']), 'imageID' => ($isRevert) ? null : $content['imageID'], 'teaserImageID' => ($isRevert) ? null : $content['teaserImageID'], 'metaTitle' => $content['metaTitle'] ?? '', diff --git a/wcfsetup/install/files/lib/data/article/content/ArticleContent.class.php b/wcfsetup/install/files/lib/data/article/content/ArticleContent.class.php index d249d7fbeff..e1419db69b6 100644 --- a/wcfsetup/install/files/lib/data/article/content/ArticleContent.class.php +++ b/wcfsetup/install/files/lib/data/article/content/ArticleContent.class.php @@ -40,6 +40,7 @@ * @property-read string $metaDescription meta description of the article * @property-read int $comments number of comments * @property-read int $attachments number of attachments + * @property-read int $numberOfWords number of words in the content * * @extends CollectionDatabaseObject */ @@ -281,6 +282,16 @@ public static function getArticleContent(int $articleID, ?int $languageID): ?Art return null; } + /** + * Returns the estimated reading time in minutes. + * + * @since 6.3 + */ + public function getReadingTime(): int + { + return (int)\ceil($this->numberOfWords / 200); + } + /** * @since 6.3 */ diff --git a/wcfsetup/install/files/lib/data/article/content/ArticleContentBuilder.class.php b/wcfsetup/install/files/lib/data/article/content/ArticleContentBuilder.class.php index 14b87c7cea4..819c59a37ba 100644 --- a/wcfsetup/install/files/lib/data/article/content/ArticleContentBuilder.class.php +++ b/wcfsetup/install/files/lib/data/article/content/ArticleContentBuilder.class.php @@ -9,6 +9,7 @@ use wcf\system\language\LanguageFactory; use wcf\system\message\embedded\object\MessageEmbeddedObjectManager; use wcf\system\tagging\TagEngine; +use wcf\util\StringUtil; /** * Builder for creating and updating article contents. @@ -69,6 +70,14 @@ public function setTeaser(string $teaser): static public function setContent(string $content): static { $this->properties['content'] = $content; + $this->setNumberOfWords(StringUtil::countWords($content)); + + return $this; + } + + public function setNumberOfWords(int $numberOfWords): static + { + $this->properties['numberOfWords'] = $numberOfWords; return $this; } diff --git a/wcfsetup/install/files/lib/system/importer/ArticleImporter.class.php b/wcfsetup/install/files/lib/system/importer/ArticleImporter.class.php index 2789fc18931..798b1307e71 100644 --- a/wcfsetup/install/files/lib/system/importer/ArticleImporter.class.php +++ b/wcfsetup/install/files/lib/system/importer/ArticleImporter.class.php @@ -9,6 +9,7 @@ use wcf\system\language\LanguageFactory; use wcf\system\tagging\TagEngine; use wcf\system\WCF; +use wcf\util\StringUtil; /** * Imports cms articles. @@ -110,6 +111,7 @@ public function import(mixed $oldID, array $data, array $additionalData = []) 'title' => $contentData['title'], 'teaser' => $contentData['teaser'], 'content' => $contentData['content'], + 'numberOfWords' => StringUtil::countWords($contentData['content']), 'imageID' => $contentData['imageID'], 'teaserImageID' => $contentData['teaserImageID'], 'metaTitle' => $contentData['metaTitle'], diff --git a/wcfsetup/install/files/lib/system/worker/ArticleRebuildDataWorker.class.php b/wcfsetup/install/files/lib/system/worker/ArticleRebuildDataWorker.class.php index 90b87874634..6afe4615db5 100644 --- a/wcfsetup/install/files/lib/system/worker/ArticleRebuildDataWorker.class.php +++ b/wcfsetup/install/files/lib/system/worker/ArticleRebuildDataWorker.class.php @@ -13,6 +13,7 @@ use wcf\system\message\embedded\object\MessageEmbeddedObjectManager; use wcf\system\search\SearchIndexManager; use wcf\system\WCF; +use wcf\util\StringUtil; /** * Worker implementation for updating articles. @@ -128,6 +129,9 @@ public function execute() $attachmentStatement->execute([$attachmentObjectType->objectTypeID, $articleContent->articleContentID]); $builder->setAttachments($attachmentStatement->fetchSingleColumn()); + // count words + $builder->setNumberOfWords(StringUtil::countWords($articleContent->content ?? '')); + $builder->update(); } diff --git a/wcfsetup/install/files/lib/util/StringUtil.class.php b/wcfsetup/install/files/lib/util/StringUtil.class.php index ad52d243270..328eea0d2d4 100644 --- a/wcfsetup/install/files/lib/util/StringUtil.class.php +++ b/wcfsetup/install/files/lib/util/StringUtil.class.php @@ -484,6 +484,21 @@ public static function stripHTML(string $string): string ); } + /** + * Returns the number of words in the given HTML. Tags are treated as word + * boundaries and words may contain letters of any script. + * + * @since 6.3 + */ + public static function countWords(string $html): int + { + $text = \preg_replace('~~s', '', $html); + $text = \preg_replace('/<\/?[a-zA-Z](?:[^>"\']+|"[^"]*"|\'[^\']*\')*+>/', ' ', $text); + $text = \html_entity_decode($text, \ENT_QUOTES | \ENT_HTML5, 'UTF-8'); + + return \preg_match_all('~[\p{L}\p{N}\p{M}]+(?:[\'\x{2019}-][\p{L}\p{N}\p{M}]+)*~u', $text); + } + /** * Returns false if the given word is forbidden by given word filter. */ diff --git a/wcfsetup/setup/db/install_com.woltlab.wcf.php b/wcfsetup/setup/db/install_com.woltlab.wcf.php index 1d507357155..257fef0c77e 100644 --- a/wcfsetup/setup/db/install_com.woltlab.wcf.php +++ b/wcfsetup/setup/db/install_com.woltlab.wcf.php @@ -479,6 +479,9 @@ SmallintDatabaseTableColumn::create('attachments') ->notNull() ->defaultValue(0), + IntDatabaseTableColumn::create('numberOfWords') + ->notNull() + ->defaultValue(0), ]) ->indices([ DatabaseTablePrimaryIndex::create()