Unique and stable event aliases from the frontend form

The frontend form built the alias from date + first 20 title characters
without checking for existing aliases and recalculated it on every save.
Duplicating an event and saving the copy with the same title and date
(or entering an event twice) produced the same alias, so only one of
the events was reachable.

- aliases are made unique (-2, -3, ...)
- existing events keep their alias on update (stable URLs); only an
  empty alias, e.g. of a fresh copy, is generated
- migration renames existing duplicates; the oldest event keeps its
  alias and URL

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
admin.juergenandClaude Opus 5.5 committed 2026-11-04 17:24:50 +01:00
1 parent 7df08b9017
commit 23cccf170a
2 files changed
+126 -3

No files matched your search

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Mummert\EventManagerBundle\Migration;
use Contao\CoreBundle\Migration\AbstractMigration;
use Contao\CoreBundle\Migration\MigrationResult;
use Doctrine\DBAL\Connection;
/**
* Doppelte Event-Aliasse auflösen.
*
* Das Frontend-Formular hat Aliasse früher ohne Eindeutigkeitsprüfung aus Datum und
* Titel gebaut. Bei gleichem Titel und Datum (z. B. nach dem Duplizieren) entstand
* derselbe Alias, und nur eines der Events war über seine URL erreichbar.
*
* Pro Gruppe behält das älteste Event (niedrigste ID) seinen Alias und damit seine
* bisherige URL, die übrigen bekommen -2, -3, ... angehängt. Leere Aliasse (Entwürfe)
* bleiben unberührt, Contao verwendet dort die ID.
*/
class DuplicateEventAliasMigration extends AbstractMigration
{
public function __construct(
private readonly Connection $connection,
) {
}
public function getName(): string
{
return 'Eventmanager: doppelte Event-Aliasse eindeutig machen';
}
public function shouldRun(): bool
{
if (!$this->connection->createSchemaManager()->tablesExist(['tl_calendar_events'])) {
return false;
}
return [] !== $this->findDuplicateAliases();
}
public function run(): MigrationResult
{
$renamed = [];
foreach ($this->findDuplicateAliases() as $alias) {
$ids = $this->connection->fetchFirstColumn('SELECT id FROM tl_calendar_events WHERE alias = ? ORDER BY id', [$alias]);
// ältestes Event behält den Alias
array_shift($ids);
$counter = 2;
foreach ($ids as $id) {
do {
$candidate = $alias.'-'.$counter++;
} while (false !== $this->connection->fetchOne('SELECT id FROM tl_calendar_events WHERE alias = ?', [$candidate]));
$this->connection->update('tl_calendar_events', ['alias' => $candidate, 'tstamp' => time()], ['id' => (int) $id]);
$renamed[] = sprintf('ID %d: %s', $id, $candidate);
}
}
return $this->createResult(true, 'Umbenannt: '.implode(', ', $renamed));
}
/**
* @return list<string>
*/
private function findDuplicateAliases(): array
{
return $this->connection->fetchFirstColumn(
"SELECT alias FROM tl_calendar_events WHERE alias != '' GROUP BY alias HAVING COUNT(*) > 1",
);
}
}
+50 -3
View File
@@ -526,7 +526,7 @@ class EventRepository
*/
public function update(int $eventId, array $data): void
{
$eventData = $this->buildEventData($data);
$eventData = $this->buildEventData($data, $eventId);
$this->connection->update(
'tl_calendar_events',
@@ -631,13 +631,13 @@ class EventRepository
*
* @return array<string, mixed>
*/
private function buildEventData(array $data): array
private function buildEventData(array $data, ?int $eventId = null): array
{
$startDate = !empty($data['startDate']) ? strtotime((string) $data['startDate']) : false;
$endDate = !empty($data['endDate']) ? strtotime((string) $data['endDate']) : false;
$url = trim((string) ($data['url'] ?? ''));
$addTime = !empty($data['addTime']);
$alias = $this->buildAlias($startDate, (string) ($data['title'] ?? ''));
$alias = $this->resolveAlias($startDate, (string) ($data['title'] ?? ''), $eventId);
$startDateTimestamp = false !== $startDate ? (int) $startDate : 0;
$endDateTimestamp = false !== $endDate ? (int) $endDate : 0;
@@ -678,6 +678,53 @@ class EventRepository
];
}
/**
* Alias für ein Event aus dem Frontend-Formular.
*
* - Bestehende Events behalten ihren Alias (stabile URLs, auch wenn Titel oder
* Datum korrigiert werden); nur ein leerer Alias wird neu erzeugt, z. B. bei
* einer frisch duplizierten Kopie.
* - Der Alias ist immer eindeutig: belegte Aliasse bekommen -2, -3, ... angehängt.
*/
private function resolveAlias(false|int $startDate, string $title, ?int $eventId): string
{
if (null !== $eventId && $eventId > 0) {
$existing = (string) $this->connection->fetchOne('SELECT alias FROM tl_calendar_events WHERE id = ?', [$eventId]);
if ('' !== $existing && !$this->aliasExists($existing, $eventId)) {
return $existing;
}
}
$base = $this->buildAlias($startDate, $title);
if ('' === $base) {
return '';
}
return $this->makeUniqueAlias($base, $eventId);
}
private function makeUniqueAlias(string $base, ?int $eventId = null): string
{
$candidate = $base;
$counter = 2;
while ($this->aliasExists($candidate, $eventId)) {
$candidate = $base.'-'.$counter++;
}
return $candidate;
}
private function aliasExists(string $alias, ?int $exceptEventId = null): bool
{
return false !== $this->connection->fetchOne(
'SELECT id FROM tl_calendar_events WHERE alias = ? AND id != ? LIMIT 1',
[$alias, (int) $exceptEventId],
);
}
private function buildAlias(false|int $startDate, string $title): string
{
if (false === $startDate || $startDate <= 0) {