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()