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 4665105bef..3166c11493 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
@@ -29,6 +29,8 @@
SmallintDatabaseTableColumn::create('attachments')
->notNull()
->defaultValue(0),
+ NotNullVarchar255DatabaseTableColumn::create('slug')
+ ->defaultValue(''),
]),
PartialDatabaseTable::create('wcf1_label_group')
->columns([
diff --git a/wcfsetup/install/files/lib/acp/form/ArticleAddForm.class.php b/wcfsetup/install/files/lib/acp/form/ArticleAddForm.class.php
index f0750f7e75..9590b3ac2c 100644
--- a/wcfsetup/install/files/lib/acp/form/ArticleAddForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/ArticleAddForm.class.php
@@ -6,6 +6,7 @@
use wcf\data\article\Article;
use wcf\data\article\ArticleAction;
use wcf\data\article\category\ArticleCategory;
+use wcf\data\article\content\ArticleContentEditor;
use wcf\data\category\CategoryNodeTree;
use wcf\data\user\User;
use wcf\form\AbstractFormBuilderForm;
@@ -40,6 +41,7 @@
use wcf\system\WCF;
use wcf\util\HeaderUtil;
use wcf\util\HtmlString;
+use wcf\util\StringUtil;
/**
* Shows the article add form.
@@ -280,6 +282,11 @@ protected function createMonolingualForm(): void
TitleFormField::create('title')
->required()
->maximumLength(255),
+ TextFormField::create('slug')
+ ->label('wcf.acp.article.slug')
+ ->description('wcf.acp.article.slug.description')
+ ->maximumLength(255)
+ ->addValidator($this->getSlugValidator(null)),
MultilineTextFormField::create('teaser')
->label('wcf.acp.article.teaser'),
TagFormField::create('tags')
@@ -326,6 +333,11 @@ protected function createMultilingualForm(): void
TitleFormField::create("title_{$lc}")
->required()
->maximumLength(255),
+ TextFormField::create("slug_{$lc}")
+ ->label('wcf.acp.article.slug')
+ ->description('wcf.acp.article.slug.description')
+ ->maximumLength(255)
+ ->addValidator($this->getSlugValidator($language->languageID)),
MultilineTextFormField::create("teaser_{$lc}")
->label('wcf.acp.article.teaser'),
TagFormField::create("tags_{$lc}")
@@ -358,6 +370,36 @@ protected function createMultilingualForm(): void
}
}
+ /**
+ * Returns a validator ensuring that the article slug is properly formatted
+ * and unique within the given language.
+ */
+ protected function getSlugValidator(?int $languageID): FormFieldValidator
+ {
+ return new FormFieldValidator('slug', function (IFormField $field) use ($languageID) {
+ $slug = \mb_strtolower(StringUtil::trim((string)$field->getSaveValue()));
+ if ($slug === '') {
+ return;
+ }
+
+ if (\preg_match('~^[a-z0-9\-_]+$~', $slug) === 0) {
+ $field->addValidationError(new FormFieldValidationError(
+ 'invalid',
+ 'wcf.acp.article.slug.error.invalid'
+ ));
+ return;
+ }
+
+ $excludedArticleID = $this->formObject !== null ? $this->formObject->articleID : null;
+ if (!ArticleContentEditor::isUniqueSlug($slug, $languageID, $excludedArticleID)) {
+ $field->addValidationError(new FormFieldValidationError(
+ 'notUnique',
+ 'wcf.acp.article.slug.error.notUnique'
+ ));
+ }
+ });
+ }
+
#[\Override]
public function finalizeForm(): void
{
@@ -403,6 +445,7 @@ function (IFormDocument $document, array $parameters) {
$parameters['content'][$lid] = [
'title' => $parameters['data']["title_{$lc}"] ?? '',
+ 'slug' => \mb_strtolower(StringUtil::trim($parameters['data']["slug_{$lc}"] ?? '')),
'tags' => $parameters["tags_{$lc}"] ?? [],
'teaser' => $parameters['data']["teaser_{$lc}"] ?? '',
'content' => $parameters['data']["content_{$lc}"] ?? '',
@@ -418,6 +461,7 @@ function (IFormDocument $document, array $parameters) {
unset(
$parameters['data']["title_{$lc}"],
+ $parameters['data']["slug_{$lc}"],
$parameters['data']["teaser_{$lc}"],
$parameters['data']["content_{$lc}"],
$parameters['data']["imageID_{$lc}"],
@@ -432,6 +476,7 @@ function (IFormDocument $document, array $parameters) {
} else {
$parameters['content'][0] = [
'title' => $parameters['data']['title'] ?? '',
+ 'slug' => \mb_strtolower(StringUtil::trim($parameters['data']['slug'] ?? '')),
'tags' => $parameters['tags'] ?? [],
'teaser' => $parameters['data']['teaser'] ?? '',
'content' => $parameters['data']['content'] ?? '',
@@ -448,6 +493,7 @@ function (IFormDocument $document, array $parameters) {
unset(
$parameters['data']['title'],
+ $parameters['data']['slug'],
$parameters['data']['teaser'],
$parameters['data']['content'],
$parameters['data']['imageID'],
diff --git a/wcfsetup/install/files/lib/acp/form/ArticleEditForm.class.php b/wcfsetup/install/files/lib/acp/form/ArticleEditForm.class.php
index 3d1b4d29e3..80c4237274 100644
--- a/wcfsetup/install/files/lib/acp/form/ArticleEditForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/ArticleEditForm.class.php
@@ -118,6 +118,7 @@ function (IFormDocument $document, array $data, IStorableObject $object) {
$lc = $language->languageCode;
$data["title_{$lc}"] = $content->title;
+ $data["slug_{$lc}"] = $content->slug;
$data["teaser_{$lc}"] = $content->teaser;
$data["content_{$lc}"] = $content->content;
$data["imageID_{$lc}"] = $content->imageID;
@@ -134,6 +135,7 @@ function (IFormDocument $document, array $data, IStorableObject $object) {
}
} else {
$data['title'] = $content->title;
+ $data['slug'] = $content->slug;
$data['teaser'] = $content->teaser;
$data['content'] = $content->content;
$data['imageID'] = $content->imageID;
diff --git a/wcfsetup/install/files/lib/command/article/EnableI18n.class.php b/wcfsetup/install/files/lib/command/article/EnableI18n.class.php
index e445b8a712..d53c7cec4c 100644
--- a/wcfsetup/install/files/lib/command/article/EnableI18n.class.php
+++ b/wcfsetup/install/files/lib/command/article/EnableI18n.class.php
@@ -34,6 +34,7 @@ public function __invoke(): void
foreach (LanguageFactory::getInstance()->getLanguages() as $language) {
$data[$language->languageID] = [
'title' => $articleContent->title,
+ 'slug' => $articleContent->slug,
'teaser' => $articleContent->teaser,
'content' => $articleContent->content,
'imageID' => $articleContent->imageID ?: null,
diff --git a/wcfsetup/install/files/lib/data/article/ArticleAction.class.php b/wcfsetup/install/files/lib/data/article/ArticleAction.class.php
index e4c2aa7a5f..03887c6238 100644
--- a/wcfsetup/install/files/lib/data/article/ArticleAction.class.php
+++ b/wcfsetup/install/files/lib/data/article/ArticleAction.class.php
@@ -106,6 +106,7 @@ public function create()
'articleID' => $article->articleID,
'languageID' => $languageID ?: null,
'title' => $content['title'],
+ 'slug' => $content['slug'] ?? '',
'teaser' => $content['teaser'],
'content' => $content['content'],
'imageID' => $content['imageID'],
@@ -204,6 +205,7 @@ public function update()
if ($articleContent !== null) {
$updateData = [
'title' => $content['title'],
+ 'slug' => $isRevert ? $articleContent->slug : ($content['slug'] ?? ''),
'teaser' => $content['teaser'],
'content' => $content['content'],
'imageID' => ($isRevert) ? $articleContent->imageID : $content['imageID'],
@@ -237,6 +239,7 @@ public function update()
'articleID' => $article->articleID,
'languageID' => $languageID ?: null,
'title' => $content['title'],
+ 'slug' => $isRevert ? '' : ($content['slug'] ?? ''),
'teaser' => $content['teaser'],
'content' => $content['content'],
'imageID' => ($isRevert) ? null : $content['imageID'],
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 1a2e718507..504a31e813 100644
--- a/wcfsetup/install/files/lib/data/article/content/ArticleContent.class.php
+++ b/wcfsetup/install/files/lib/data/article/content/ArticleContent.class.php
@@ -28,6 +28,7 @@
* @property-read int $articleID id of the article the article content belongs to
* @property-read ?int $languageID id of the article content's language
* @property-read string $title title of the article in the associated language
+ * @property-read string $slug custom URL slug of the article in the associated language or empty to derive it from the title
* @property-read ?string $content actual content of the article in the associated language
* @property-read ?string $teaser teaser of the article in the associated language or empty if no teaser exists
* @property-read ?int $imageID id of the (image) media object used as article image for the associated language or `null` if no image is used
@@ -50,6 +51,13 @@ class ArticleContent extends CollectionDatabaseObject implements ILinkableObject
#[\Override]
public function getLink(): string
{
+ if ($this->slug !== '') {
+ return LinkHandler::getInstance()->getControllerLink(ArticlePage::class, [
+ 'id' => $this->articleContentID,
+ 'title' => $this->slug,
+ ]);
+ }
+
return LinkHandler::getInstance()->getControllerLink(ArticlePage::class, [
'object' => $this,
]);
diff --git a/wcfsetup/install/files/lib/data/article/content/ArticleContentEditor.class.php b/wcfsetup/install/files/lib/data/article/content/ArticleContentEditor.class.php
index 70db28f93b..e3e5cc22ad 100644
--- a/wcfsetup/install/files/lib/data/article/content/ArticleContentEditor.class.php
+++ b/wcfsetup/install/files/lib/data/article/content/ArticleContentEditor.class.php
@@ -3,6 +3,8 @@
namespace wcf\data\article\content;
use wcf\data\DatabaseObjectEditor;
+use wcf\system\database\util\PreparedStatementConditionBuilder;
+use wcf\system\WCF;
/**
* Provides functions to edit article content.
@@ -20,4 +22,38 @@ class ArticleContentEditor extends DatabaseObjectEditor
* @inheritDoc
*/
protected static $baseClass = ArticleContent::class;
+
+ /**
+ * Returns whether the given slug is unique within the given language scope.
+ * The `$excludedArticleID` is excluded from the lookup to allow updates of
+ * an existing article.
+ *
+ * @since 6.3
+ */
+ public static function isUniqueSlug(string $slug, ?int $languageID, ?int $excludedArticleID = null): bool
+ {
+ if ($slug === '') {
+ return true;
+ }
+
+ $conditionBuilder = new PreparedStatementConditionBuilder();
+ $conditionBuilder->add('slug = ?', [$slug]);
+
+ if ($languageID === null) {
+ $conditionBuilder->add('languageID IS NULL');
+ } else {
+ $conditionBuilder->add('(languageID = ? OR languageID IS NULL)', [$languageID]);
+ }
+ if ($excludedArticleID !== null) {
+ $conditionBuilder->add('articleID <> ?', [$excludedArticleID]);
+ }
+
+ $sql = "SELECT COUNT(*)
+ FROM wcf1_article_content
+ " . $conditionBuilder;
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute($conditionBuilder->getParameters());
+
+ return $statement->fetchSingleColumn() === 0;
+ }
}
diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml
index 03b69b201d..7d40f1a7b6 100644
--- a/wcfsetup/install/lang/de.xml
+++ b/wcfsetup/install/lang/de.xml
@@ -109,6 +109,10 @@
+
+ - a-z,
0-9, -, _.]]>
+
+
diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml
index 7ff1d70d6a..3cfd262900 100644
--- a/wcfsetup/install/lang/en.xml
+++ b/wcfsetup/install/lang/en.xml
@@ -109,6 +109,10 @@
+
+ - a-z,
0-9, -, _.]]>
+
+
diff --git a/wcfsetup/setup/db/install_com.woltlab.wcf.php b/wcfsetup/setup/db/install_com.woltlab.wcf.php
index 3f8939f1e5..f1ad25700a 100644
--- a/wcfsetup/setup/db/install_com.woltlab.wcf.php
+++ b/wcfsetup/setup/db/install_com.woltlab.wcf.php
@@ -464,6 +464,8 @@
NotNullInt10DatabaseTableColumn::create('articleID'),
IntDatabaseTableColumn::create('languageID'),
NotNullVarchar255DatabaseTableColumn::create('title'),
+ NotNullVarchar255DatabaseTableColumn::create('slug')
+ ->defaultValue(''),
TextDatabaseTableColumn::create('teaser'),
MediumtextDatabaseTableColumn::create('content'),
IntDatabaseTableColumn::create('imageID'),