From 6149284e21712c60780e2dd4ecd0a68df20d7844 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 16 Nov 2025 16:43:45 +0000
Subject: [PATCH 1/6] Initial plan
From 9dd1e89aa6ab328aeca88201485fdc46dfaa2703 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 16 Nov 2025 16:49:43 +0000
Subject: [PATCH 2/6] Add automatic redirect creation for URL changes
Co-authored-by: AWqxKAWERbXo <3855487+AWqxKAWERbXo@users.noreply.github.com>
---
boot.php | 20 ++++++
install.php | 9 +++
lang/de_de.lang | 5 ++
lang/en_gb.lang | 5 ++
lib/Url/Generator.php | 83 ++++++++++++++++++++++
lib/Url/RedirectManager.php | 133 ++++++++++++++++++++++++++++++++++++
lib/Url/UrlManagerSql.php | 16 +++++
7 files changed, 271 insertions(+)
create mode 100644 lib/Url/RedirectManager.php
diff --git a/boot.php b/boot.php
index 566ded5..2a77daa 100644
--- a/boot.php
+++ b/boot.php
@@ -95,6 +95,26 @@
if (rex::isBackend() && rex::getUser() !== null) {
rex_view::addCssFile($addon->getAssetsUrl('styles.css'));
+
+ // Extend YRewrite forward list to show redirect source
+ if (\rex_addon::get('yrewrite')->isAvailable()) {
+ rex_extension::register('YREWRITE_FORWARD_LIST', function (rex_extension_point $ep) {
+ /** @var rex_list $list */
+ $list = $ep->getSubject();
+
+ // Add column to show if redirect was created by URL addon
+ $list->addColumn('url_addon_source', '', count($list->getColumnNames()));
+ $list->setColumnLabel('url_addon_source', rex_i18n::msg('url_generator_redirect_source'));
+ $list->setColumnFormat('url_addon_source', 'custom', function ($params) {
+ $list = $params['list'];
+ $isUrlAddon = $list->getValue('is_url_addon');
+ if ($isUrlAddon == 1) {
+ return '' . rex_i18n::msg('url_generator_redirect_from_url_addon') . '';
+ }
+ return '' . rex_i18n::msg('url_generator_redirect_manual') . '';
+ });
+ });
+ }
}
if (null !== Url::getRewriter() && Url::getRewriter()->getSeoTagsExtensionPoint() !== '') {
diff --git a/install.php b/install.php
index 3751f0f..0ab3082 100644
--- a/install.php
+++ b/install.php
@@ -85,3 +85,12 @@
$updateSql->update();
}
}
+
+// Add is_url_addon column to yrewrite_redirect table if it doesn't exist
+if (\rex_addon::get('yrewrite')->isAvailable()) {
+ \rex_sql_table::get(
+ \rex::getTable('yrewrite_redirect')
+ )
+ ->ensureColumn(new \rex_sql_column('is_url_addon', 'TINYINT(1)', false, '0'))
+ ->ensure();
+}
diff --git a/lang/de_de.lang b/lang/de_de.lang
index 476ac47..d4a676c 100644
--- a/lang/de_de.lang
+++ b/lang/de_de.lang
@@ -135,3 +135,8 @@ url.profile.not_set = keine Auswahl
// Neue Update-Seite
url_generator_update = Neue Version verfügbar
+
+# Redirect management
+url_generator_redirect_source = Quelle
+url_generator_redirect_from_url_addon = URL-AddOn
+url_generator_redirect_manual = Manuell
diff --git a/lang/en_gb.lang b/lang/en_gb.lang
index d76ec96..f73aeff 100644
--- a/lang/en_gb.lang
+++ b/lang/en_gb.lang
@@ -100,3 +100,8 @@ url_yform_value_description = Adds a link to the frontend generated by the URL a
// Löschen
url_generate_notice_url_param_key = When using rex_getUrl you no longer have to add the article Id from the chosen article above. So instead of
rex_getUrl(4, '', ['id' => 5]) you can use rex_getUrl('', '', ['news-id' => 5])
+
+# Redirect management
+url_generator_redirect_source = Source
+url_generator_redirect_from_url_addon = URL Addon
+url_generator_redirect_manual = Manual
diff --git a/lib/Url/Generator.php b/lib/Url/Generator.php
index 001a128..9d31f23 100644
--- a/lib/Url/Generator.php
+++ b/lib/Url/Generator.php
@@ -49,8 +49,17 @@ public function execute(): void
$profiles = Profile::getByTableName($this->manager->getDatasetTableName());
if (count($profiles) > 0) {
foreach ($profiles as $profile) {
+ // Get old URLs before deletion to create redirects
+ $oldUrls = UrlManagerSql::getOriginUrls($profile->getId(), $this->manager->getDatasetPrimaryId());
+
$profile->deleteUrlsByDatasetId($this->manager->getDatasetPrimaryId());
$profile->buildUrlsByDatasetId($this->manager->getDatasetPrimaryId());
+
+ // Get new URLs after building
+ $newUrls = UrlManagerSql::getOriginUrls($profile->getId(), $this->manager->getDatasetPrimaryId());
+
+ // Create redirects from old to new URLs
+ self::createRedirectsForUrlChanges($oldUrls, $newUrls);
}
}
break;
@@ -71,4 +80,78 @@ public static function boot(): void
}
}
}
+
+ /**
+ * Creates redirects when URLs change
+ *
+ * @param array $oldUrls Old URL entries before change
+ * @param array $newUrls New URL entries after change
+ */
+ private static function createRedirectsForUrlChanges(array $oldUrls, array $newUrls): void
+ {
+ if (empty($oldUrls) || empty($newUrls)) {
+ return;
+ }
+
+ // Group by clang_id to match old and new URLs properly
+ $oldUrlsByClang = [];
+ foreach ($oldUrls as $oldUrl) {
+ $clangId = $oldUrl['clang_id'];
+ if (!isset($oldUrlsByClang[$clangId])) {
+ $oldUrlsByClang[$clangId] = [];
+ }
+ $oldUrlsByClang[$clangId][] = $oldUrl;
+ }
+
+ $newUrlsByClang = [];
+ foreach ($newUrls as $newUrl) {
+ $clangId = $newUrl['clang_id'];
+ if (!isset($newUrlsByClang[$clangId])) {
+ $newUrlsByClang[$clangId] = [];
+ }
+ $newUrlsByClang[$clangId][] = $newUrl;
+ }
+
+ // Create redirects for each language
+ foreach ($oldUrlsByClang as $clangId => $oldClangUrls) {
+ if (!isset($newUrlsByClang[$clangId])) {
+ continue;
+ }
+
+ $newClangUrls = $newUrlsByClang[$clangId];
+
+ // Match origin URLs (not user_path, not structure)
+ $oldOriginUrl = null;
+ $newOriginUrl = null;
+
+ foreach ($oldClangUrls as $url) {
+ if ($url['is_user_path'] == 0 && $url['is_structure'] == 0) {
+ $oldOriginUrl = $url['url'];
+ break;
+ }
+ }
+
+ foreach ($newClangUrls as $url) {
+ if ($url['is_user_path'] == 0 && $url['is_structure'] == 0) {
+ $newOriginUrl = $url['url'];
+ break;
+ }
+ }
+
+ if ($oldOriginUrl && $newOriginUrl && $oldOriginUrl !== $newOriginUrl) {
+ // Get domain ID for the article
+ $articleId = $newClangUrls[0]['article_id'] ?? null;
+ $domainId = 1; // default
+
+ if ($articleId && \rex_addon::get('yrewrite')->isAvailable()) {
+ $domain = \rex_yrewrite::getDomainByArticleId($articleId, $clangId);
+ if ($domain) {
+ $domainId = $domain->getId();
+ }
+ }
+
+ RedirectManager::createRedirect($oldOriginUrl, $newOriginUrl, $domainId);
+ }
+ }
+ }
}
diff --git a/lib/Url/RedirectManager.php b/lib/Url/RedirectManager.php
new file mode 100644
index 0000000..63258d6
--- /dev/null
+++ b/lib/Url/RedirectManager.php
@@ -0,0 +1,133 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Url;
+
+class RedirectManager
+{
+ /**
+ * Creates a 301 redirect from old URL to new URL in yrewrite_redirect table
+ *
+ * @param string $sourceUrl The old URL to redirect from
+ * @param string $targetUrl The new URL to redirect to
+ * @param int $domainId The yrewrite domain ID
+ * @return bool True if redirect was created successfully
+ */
+ public static function createRedirect(string $sourceUrl, string $targetUrl, int $domainId = 1): bool
+ {
+ if (!\rex_addon::get('yrewrite')->isAvailable()) {
+ return false;
+ }
+
+ // Don't create redirect if source and target are the same
+ if ($sourceUrl === $targetUrl) {
+ return false;
+ }
+
+ // Remove any existing redirect that would create a loop
+ // If the new target URL was previously a source URL, delete it
+ self::deleteRedirectBySource($targetUrl);
+
+ // Check if redirect already exists
+ $sql = \rex_sql::factory();
+ $existing = $sql->getArray(
+ 'SELECT id FROM ' . \rex::getTable('yrewrite_redirect') .
+ ' WHERE url_source = ? AND domain_id = ?',
+ [$sourceUrl, $domainId]
+ );
+
+ if (count($existing) > 0) {
+ // Update existing redirect
+ $sql->setTable(\rex::getTable('yrewrite_redirect'));
+ $sql->setWhere('id = ?', [$existing[0]['id']]);
+ $sql->setValue('url_target', $targetUrl);
+ $sql->setValue('status', 301);
+ $sql->setValue('is_url_addon', 1);
+ try {
+ $sql->update();
+ self::clearYrewriteCache();
+ return true;
+ } catch (\rex_sql_exception $e) {
+ return false;
+ }
+ }
+
+ // Create new redirect
+ $sql = \rex_sql::factory();
+ $sql->setTable(\rex::getTable('yrewrite_redirect'));
+ $sql->setValue('domain_id', $domainId);
+ $sql->setValue('url_source', $sourceUrl);
+ $sql->setValue('url_target', $targetUrl);
+ $sql->setValue('status', 301);
+ $sql->setValue('type', 'url');
+ $sql->setValue('is_url_addon', 1);
+
+ try {
+ $sql->insert();
+ self::clearYrewriteCache();
+ return true;
+ } catch (\rex_sql_exception $e) {
+ return false;
+ }
+ }
+
+ /**
+ * Deletes a redirect by its source URL to prevent loops
+ *
+ * @param string $sourceUrl The source URL of the redirect to delete
+ * @return bool True if redirect was deleted or didn't exist
+ */
+ public static function deleteRedirectBySource(string $sourceUrl): bool
+ {
+ if (!\rex_addon::get('yrewrite')->isAvailable()) {
+ return false;
+ }
+
+ $sql = \rex_sql::factory();
+ $sql->setTable(\rex::getTable('yrewrite_redirect'));
+ $sql->setWhere('url_source = ? AND is_url_addon = 1', [$sourceUrl]);
+
+ try {
+ $sql->delete();
+ self::clearYrewriteCache();
+ return true;
+ } catch (\rex_sql_exception $e) {
+ return false;
+ }
+ }
+
+ /**
+ * Clears the YRewrite redirect cache
+ */
+ private static function clearYrewriteCache(): void
+ {
+ if (class_exists('\rex_yrewrite_forward')) {
+ \rex_yrewrite_forward::clearCache();
+ }
+ }
+
+ /**
+ * Gets all redirects created by the URL addon
+ *
+ * @return array Array of redirects
+ */
+ public static function getUrlAddonRedirects(): array
+ {
+ if (!\rex_addon::get('yrewrite')->isAvailable()) {
+ return [];
+ }
+
+ $sql = \rex_sql::factory();
+ return $sql->getArray(
+ 'SELECT * FROM ' . \rex::getTable('yrewrite_redirect') . ' WHERE is_url_addon = 1'
+ );
+ }
+}
diff --git a/lib/Url/UrlManagerSql.php b/lib/Url/UrlManagerSql.php
index 0fcf35c..d2afe97 100644
--- a/lib/Url/UrlManagerSql.php
+++ b/lib/Url/UrlManagerSql.php
@@ -281,6 +281,22 @@ public static function getOriginAndExpanded(Profile $profile, int $datasetId, in
return $sql->sql->getArray('SELECT * FROM '.\rex::getTable(self::TABLE_NAME).' WHERE `profile_id` = ? AND `data_id` = ? AND `clang_id` = ?', [$profile->getId(), $datasetId, $clangId]);
}
+ /**
+ * Get all URLs for a specific profile and dataset (across all languages)
+ *
+ * @param int $profileId
+ * @param int $datasetId
+ *
+ * @throws \rex_sql_exception
+ *
+ * @return array
+ */
+ public static function getOriginUrls(int $profileId, int $datasetId): array
+ {
+ $sql = self::factory();
+ return $sql->sql->getArray('SELECT * FROM '.\rex::getTable(self::TABLE_NAME).' WHERE `profile_id` = ? AND `data_id` = ?', [$profileId, $datasetId]);
+ }
+
/**
* @param Url $url
*
From 2a9a51d9269095300d1b4776d1f7c499babe75ad Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 16 Nov 2025 16:51:34 +0000
Subject: [PATCH 3/6] Improve redirect creation to only work in edit mode
Co-authored-by: AWqxKAWERbXo <3855487+AWqxKAWERbXo@users.noreply.github.com>
---
lib/Url/ExtensionPointManager.php | 5 +++++
lib/Url/Generator.php | 17 ++++++++++-------
2 files changed, 15 insertions(+), 7 deletions(-)
diff --git a/lib/Url/ExtensionPointManager.php b/lib/Url/ExtensionPointManager.php
index c6e7f58..eaa41b6 100644
--- a/lib/Url/ExtensionPointManager.php
+++ b/lib/Url/ExtensionPointManager.php
@@ -89,6 +89,11 @@ public function getStructureClangId(): int|string|null
return $this->structureClangId;
}
+ public function isDatasetEditMode(): bool
+ {
+ return $this->dataEditMode ?? false;
+ }
+
protected function normalize(): void
{
switch ($this->extensionPoint->getName()) {
diff --git a/lib/Url/Generator.php b/lib/Url/Generator.php
index 9d31f23..91fbc8e 100644
--- a/lib/Url/Generator.php
+++ b/lib/Url/Generator.php
@@ -49,17 +49,20 @@ public function execute(): void
$profiles = Profile::getByTableName($this->manager->getDatasetTableName());
if (count($profiles) > 0) {
foreach ($profiles as $profile) {
- // Get old URLs before deletion to create redirects
- $oldUrls = UrlManagerSql::getOriginUrls($profile->getId(), $this->manager->getDatasetPrimaryId());
+ // Get old URLs before deletion to create redirects (only in edit mode)
+ $oldUrls = [];
+ if ($this->manager->isDatasetEditMode()) {
+ $oldUrls = UrlManagerSql::getOriginUrls($profile->getId(), $this->manager->getDatasetPrimaryId());
+ }
$profile->deleteUrlsByDatasetId($this->manager->getDatasetPrimaryId());
$profile->buildUrlsByDatasetId($this->manager->getDatasetPrimaryId());
- // Get new URLs after building
- $newUrls = UrlManagerSql::getOriginUrls($profile->getId(), $this->manager->getDatasetPrimaryId());
-
- // Create redirects from old to new URLs
- self::createRedirectsForUrlChanges($oldUrls, $newUrls);
+ // Create redirects from old to new URLs (only if we had old URLs)
+ if (!empty($oldUrls)) {
+ $newUrls = UrlManagerSql::getOriginUrls($profile->getId(), $this->manager->getDatasetPrimaryId());
+ self::createRedirectsForUrlChanges($oldUrls, $newUrls);
+ }
}
}
break;
From d052a533e1a11e798bdaa12e7e726401d6d07ade Mon Sep 17 00:00:00 2001
From: Alex <3855487+AWqxKAWERbXo@users.noreply.github.com>
Date: Sun, 16 Nov 2025 23:16:41 +0100
Subject: [PATCH 4/6] Update lib/Url/RedirectManager.php
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Alex <3855487+AWqxKAWERbXo@users.noreply.github.com>
---
lib/Url/RedirectManager.php | 1 +
1 file changed, 1 insertion(+)
diff --git a/lib/Url/RedirectManager.php b/lib/Url/RedirectManager.php
index 63258d6..f7146e0 100644
--- a/lib/Url/RedirectManager.php
+++ b/lib/Url/RedirectManager.php
@@ -100,6 +100,7 @@ public static function deleteRedirectBySource(string $sourceUrl): bool
self::clearYrewriteCache();
return true;
} catch (\rex_sql_exception $e) {
+ \rex_logger::logException($e);
return false;
}
}
From 58d5aa2097142309db25d0902abdb81f15b9515f Mon Sep 17 00:00:00 2001
From: Alex <3855487+AWqxKAWERbXo@users.noreply.github.com>
Date: Sun, 16 Nov 2025 23:17:13 +0100
Subject: [PATCH 5/6] Update lib/Url/RedirectManager.php
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Alex <3855487+AWqxKAWERbXo@users.noreply.github.com>
---
lib/Url/RedirectManager.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lib/Url/RedirectManager.php b/lib/Url/RedirectManager.php
index f7146e0..5361f7a 100644
--- a/lib/Url/RedirectManager.php
+++ b/lib/Url/RedirectManager.php
@@ -21,7 +21,7 @@ class RedirectManager
* @param int $domainId The yrewrite domain ID
* @return bool True if redirect was created successfully
*/
- public static function createRedirect(string $sourceUrl, string $targetUrl, int $domainId = 1): bool
+ public static function createRedirect(string $sourceUrl, string $targetUrl, int $domainId): bool
{
if (!\rex_addon::get('yrewrite')->isAvailable()) {
return false;
From bbbe9a6f39c907bd62d1e397b4d5a5a55bd718ce Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 16 Nov 2025 22:20:10 +0000
Subject: [PATCH 6/6] Address code review feedback: strict comparisons, error
logging, input validation
Co-authored-by: AWqxKAWERbXo <3855487+AWqxKAWERbXo@users.noreply.github.com>
---
boot.php | 2 +-
lib/Url/Generator.php | 9 ++++++---
lib/Url/RedirectManager.php | 9 ++++++++-
3 files changed, 15 insertions(+), 5 deletions(-)
diff --git a/boot.php b/boot.php
index 2a77daa..ed8660b 100644
--- a/boot.php
+++ b/boot.php
@@ -108,7 +108,7 @@
$list->setColumnFormat('url_addon_source', 'custom', function ($params) {
$list = $params['list'];
$isUrlAddon = $list->getValue('is_url_addon');
- if ($isUrlAddon == 1) {
+ if ($isUrlAddon === 1) {
return '' . rex_i18n::msg('url_generator_redirect_from_url_addon') . '';
}
return '' . rex_i18n::msg('url_generator_redirect_manual') . '';
diff --git a/lib/Url/Generator.php b/lib/Url/Generator.php
index 91fbc8e..47f10f0 100644
--- a/lib/Url/Generator.php
+++ b/lib/Url/Generator.php
@@ -128,14 +128,14 @@ private static function createRedirectsForUrlChanges(array $oldUrls, array $newU
$newOriginUrl = null;
foreach ($oldClangUrls as $url) {
- if ($url['is_user_path'] == 0 && $url['is_structure'] == 0) {
+ if ($url['is_user_path'] === 0 && $url['is_structure'] === 0) {
$oldOriginUrl = $url['url'];
break;
}
}
foreach ($newClangUrls as $url) {
- if ($url['is_user_path'] == 0 && $url['is_structure'] == 0) {
+ if ($url['is_user_path'] === 0 && $url['is_structure'] === 0) {
$newOriginUrl = $url['url'];
break;
}
@@ -143,7 +143,10 @@ private static function createRedirectsForUrlChanges(array $oldUrls, array $newU
if ($oldOriginUrl && $newOriginUrl && $oldOriginUrl !== $newOriginUrl) {
// Get domain ID for the article
- $articleId = $newClangUrls[0]['article_id'] ?? null;
+ $articleId = null;
+ if (!empty($newClangUrls)) {
+ $articleId = $newClangUrls[0]['article_id'] ?? null;
+ }
$domainId = 1; // default
if ($articleId && \rex_addon::get('yrewrite')->isAvailable()) {
diff --git a/lib/Url/RedirectManager.php b/lib/Url/RedirectManager.php
index 5361f7a..8677f0f 100644
--- a/lib/Url/RedirectManager.php
+++ b/lib/Url/RedirectManager.php
@@ -18,7 +18,7 @@ class RedirectManager
*
* @param string $sourceUrl The old URL to redirect from
* @param string $targetUrl The new URL to redirect to
- * @param int $domainId The yrewrite domain ID
+ * @param int $domainId The yrewrite domain ID (required, no default)
* @return bool True if redirect was created successfully
*/
public static function createRedirect(string $sourceUrl, string $targetUrl, int $domainId): bool
@@ -27,6 +27,11 @@ public static function createRedirect(string $sourceUrl, string $targetUrl, int
return false;
}
+ // Validate URLs are non-empty
+ if (empty($sourceUrl) || empty($targetUrl)) {
+ return false;
+ }
+
// Don't create redirect if source and target are the same
if ($sourceUrl === $targetUrl) {
return false;
@@ -56,6 +61,7 @@ public static function createRedirect(string $sourceUrl, string $targetUrl, int
self::clearYrewriteCache();
return true;
} catch (\rex_sql_exception $e) {
+ \rex_logger::logException($e);
return false;
}
}
@@ -75,6 +81,7 @@ public static function createRedirect(string $sourceUrl, string $targetUrl, int
self::clearYrewriteCache();
return true;
} catch (\rex_sql_exception $e) {
+ \rex_logger::logException($e);
return false;
}
}