Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
SmallintDatabaseTableColumn::create('attachments')
->notNull()
->defaultValue(0),
NotNullVarchar255DatabaseTableColumn::create('slug')
->defaultValue(''),
]),
PartialDatabaseTable::create('wcf1_label_group')
->columns([
Expand Down
46 changes: 46 additions & 0 deletions wcfsetup/install/files/lib/acp/form/ArticleAddForm.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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}"] ?? '',
Expand All @@ -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}"],
Expand All @@ -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'] ?? '',
Expand All @@ -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'],
Expand Down
2 changes: 2 additions & 0 deletions wcfsetup/install/files/lib/acp/form/ArticleEditForm.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
}
}
4 changes: 4 additions & 0 deletions wcfsetup/install/lang/de.xml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@
<item name="wcf.acp.article.isDeleted"><![CDATA[Gelöscht]]></item>
<item name="wcf.acp.article.metaTitle"><![CDATA[Meta Titel]]></item>
<item name="wcf.acp.article.metaDescription"><![CDATA[Meta Description]]></item>
<item name="wcf.acp.article.slug"><![CDATA[Eigener URL-Slug]]></item>
<item name="wcf.acp.article.slug.description"><![CDATA[Leer lassen, um den URL-Slug aus dem Titel abzuleiten. Erlaubte Zeichen: <code>a-z</code>, <code>0-9</code>, <code>-</code>, <code>_</code>.]]></item>
<item name="wcf.acp.article.slug.error.invalid"><![CDATA[Der Slug darf nur aus Kleinbuchstaben, Ziffern, Binde- und Unterstrichen bestehen.]]></item>
<item name="wcf.acp.article.slug.error.notUnique"><![CDATA[Der Slug wird bereits durch einen anderen Artikel verwendet.]]></item>
</category>
<category name="wcf.acp.attachment">
<item name="wcf.acp.attachment.list"><![CDATA[Dateianhänge]]></item>
Expand Down
4 changes: 4 additions & 0 deletions wcfsetup/install/lang/en.xml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@
<item name="wcf.acp.article.isDeleted"><![CDATA[Deleted]]></item>
<item name="wcf.acp.article.metaTitle"><![CDATA[Meta Title]]></item>
<item name="wcf.acp.article.metaDescription"><![CDATA[Meta Description]]></item>
<item name="wcf.acp.article.slug"><![CDATA[Custom URL Slug]]></item>
<item name="wcf.acp.article.slug.description"><![CDATA[Leave empty to derive the URL slug from the title. Allowed characters: <code>a-z</code>, <code>0-9</code>, <code>-</code>, <code>_</code>.]]></item>
<item name="wcf.acp.article.slug.error.invalid"><![CDATA[The slug must only contain lowercase letters, digits, hyphens, and underscores.]]></item>
<item name="wcf.acp.article.slug.error.notUnique"><![CDATA[The slug is already in use by another article.]]></item>
</category>
<category name="wcf.acp.attachment">
<item name="wcf.acp.attachment.list"><![CDATA[Attachments]]></item>
Expand Down
2 changes: 2 additions & 0 deletions wcfsetup/setup/db/install_com.woltlab.wcf.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Loading