diff --git a/src/Command/TagRebuildRunJobCommand.php b/src/Command/TagRebuildRunJobCommand.php index f32d3c6..840a471 100644 --- a/src/Command/TagRebuildRunJobCommand.php +++ b/src/Command/TagRebuildRunJobCommand.php @@ -36,68 +36,90 @@ final class TagRebuildRunJobCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): int { - $jobId = (string)$input->getArgument('jobId'); + $jobId = (string) $input->getArgument('jobId'); /** @var TagRebuildJob|null $job */ $job = $this->em->getRepository(TagRebuildJob::class)->find($jobId); + if (!$job instanceof TagRebuildJob) { $output->writeln('Job not found.'); return Command::FAILURE; } - // --------------------------------------------------------- - // Global lock to avoid parallel rebuilds - // --------------------------------------------------------- - $lockDir = \dirname($this->lockFilePath); - if (!\is_dir($lockDir)) { - @\mkdir($lockDir, 0775, true); - } - - $fh = @\fopen($this->lockFilePath, 'c+'); - if (!$fh) { - $job->markFailed('Cannot open lock file: ' . $this->lockFilePath); - $this->em->flush(); - $output->writeln('Cannot open lock file.'); - return Command::FAILURE; - } - - // If another rebuild runs, we fail fast (simple & safe). - if (!@\flock($fh, LOCK_EX | LOCK_NB)) { - \fclose($fh); - $job->markFailed('Another tag rebuild is currently running (lock busy).'); - $this->em->flush(); - $output->writeln('Lock busy. Another rebuild is running.'); - return Command::FAILURE; - } - - // mark running - $job->markRunning(); - $this->em->flush(); + $fh = null; try { + // --------------------------------------------------------- + // LOCK INITIALIZATION + // --------------------------------------------------------- + $lockDir = \dirname($this->lockFilePath); + if (!\is_dir($lockDir) && !@\mkdir($lockDir, 0775, true) && !\is_dir($lockDir)) { + throw new \RuntimeException('Cannot create lock directory.'); + } + + $fh = @\fopen($this->lockFilePath, 'c+'); + if (!$fh) { + throw new \RuntimeException('Cannot open lock file: ' . $this->lockFilePath); + } + + if (!@\flock($fh, LOCK_EX | LOCK_NB)) { + throw new \RuntimeException('Another tag rebuild is currently running (lock busy).'); + } + + // --------------------------------------------------------- + // MARK RUNNING + // --------------------------------------------------------- + $job->markRunning(); + $this->em->flush(); + + // --------------------------------------------------------- + // EXPORT TAGS (NDJSON) + // --------------------------------------------------------- $export = $this->exporter->export(); + + if ( + !isset($export['path']) || + !\is_string($export['path']) || + !\file_exists($export['path']) + ) { + throw new \RuntimeException('Export failed: NDJSON file missing.'); + } + + if (isset($export['count']) && (int)$export['count'] === 0) { + throw new \RuntimeException('Export produced zero tags.'); + } + + // --------------------------------------------------------- + // BUILD VECTOR INDEX + // --------------------------------------------------------- $this->builder->build(); + // --------------------------------------------------------- + // MARK COMPLETED + // --------------------------------------------------------- $job->markCompleted(); $this->em->flush(); - $output->writeln('OK'); - $output->writeln('tags.ndjson: ' . $export['path']); - } catch (\Throwable $e) { + $output->writeln('Tag rebuild successful.'); + $output->writeln('NDJSON: ' . $export['path']); + + return Command::SUCCESS; + } + catch (\Throwable $e) { + $job->markFailed($e->getMessage()); $this->em->flush(); $output->writeln('FAILED: ' . $e->getMessage() . ''); - @\flock($fh, LOCK_UN); - @\fclose($fh); - return Command::FAILURE; } + finally { - @\flock($fh, LOCK_UN); - @\fclose($fh); - - return Command::SUCCESS; + if ($fh) { + @\flock($fh, LOCK_UN); + @\fclose($fh); + } + } } } \ No newline at end of file diff --git a/src/Controller/Admin/DocumentTagController.php b/src/Controller/Admin/DocumentTagController.php index 52930f8..7527b42 100644 --- a/src/Controller/Admin/DocumentTagController.php +++ b/src/Controller/Admin/DocumentTagController.php @@ -6,27 +6,33 @@ namespace App\Controller\Admin; use App\Entity\Document; use App\Entity\Tag; +use App\Entity\TagRebuildJob; use App\Service\TagRebuildJobService; -use Doctrine\DBAL\Types\Types; +use App\Tag\TagService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -use Symfony\Component\Uid\Uuid; #[Route('/admin/documents')] final class DocumentTagController extends AbstractController { #[Route('/{id}/tags', name: 'admin_document_tags_edit', methods: ['GET'])] - public function edit(string $id, EntityManagerInterface $em): Response - { + public function edit( + string $id, + EntityManagerInterface $em, + TagRebuildJobService $jobs + ): Response { + $document = $em->getRepository(Document::class)->find($id); if (!$document instanceof Document) { throw $this->createNotFoundException('Document not found'); } + // 🔹 Alle verfügbaren Tags laden (fehlte!) $allTags = $em->createQueryBuilder() ->select('t') ->from(Tag::class, 't') @@ -34,15 +40,16 @@ final class DocumentTagController extends AbstractController ->getQuery() ->getResult(); - $assigned = []; - foreach ($document->getTags() as $tag) { - $assigned[(string)$tag->getId()] = true; - } + $latestJob = $jobs->getLatestJob(); return $this->render('admin/document_tags/edit.html.twig', [ - 'document' => $document, - 'allTags' => $allTags, - 'assigned' => $assigned, + 'document' => $document, + 'allTags' => $allTags, // ✅ jetzt vorhanden + 'latestJob' => $latestJob, + 'statusRunning' => TagRebuildJob::STATUS_RUNNING, + 'statusQueued' => TagRebuildJob::STATUS_QUEUED, + 'statusCompleted' => TagRebuildJob::STATUS_COMPLETED, + 'statusFailed' => TagRebuildJob::STATUS_FAILED, ]); } @@ -51,7 +58,7 @@ final class DocumentTagController extends AbstractController string $id, Request $request, EntityManagerInterface $em, - TagRebuildJobService $jobs + TagService $tagService ): RedirectResponse { $document = $em->getRepository(Document::class)->find($id); @@ -61,33 +68,23 @@ final class DocumentTagController extends AbstractController $selected = $request->request->all('tag_ids') ?? []; - $uuidObjects = []; - foreach ($selected as $value) { - try { - $uuidObjects[] = \Symfony\Component\Uid\Uuid::fromString($value); - } catch (\Throwable) { - continue; - } + try { + $tagService->syncDocumentTags($document, $selected); + $this->addFlash('success', 'Tags wurden aktualisiert. Rebuild läuft im Hintergrund.'); + } catch (\Throwable $e) { + $this->addFlash('danger', $e->getMessage()); } - // Remove - foreach ($document->getTags() as $tag) { - if (!in_array($tag->getId(), $uuidObjects, false)) { - $document->removeTag($tag); - } - } - - // Add - foreach ($uuidObjects as $uuid) { - $tag = $em->find(\App\Entity\Tag::class, $uuid); - if ($tag && !$document->hasTag($tag)) { - $document->addTag($tag); - } - } - - $em->flush(); - $jobs->enqueueAndStartAsync(); - return $this->redirectToRoute('admin_document_tags_edit', ['id' => $id]); } + + #[Route('/admin/tags/status', name: 'admin_tags_status', methods: ['GET'])] + public function status(TagRebuildJobService $jobs): JsonResponse + { + $job = $jobs->getLatestJob(); + + return $this->json([ + 'status' => $job?->getStatus(), + ]); + } } \ No newline at end of file diff --git a/src/Controller/Admin/TagController.php b/src/Controller/Admin/TagController.php index 42c8ecb..7340dae 100644 --- a/src/Controller/Admin/TagController.php +++ b/src/Controller/Admin/TagController.php @@ -5,7 +5,8 @@ declare(strict_types=1); namespace App\Controller\Admin; use App\Entity\Tag; -use App\Service\TagRebuildJobService; +use App\Entity\TagRebuildJob; +use App\Tag\TagService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\RedirectResponse; @@ -17,85 +18,76 @@ use Symfony\Component\Routing\Attribute\Route; final class TagController extends AbstractController { #[Route('', name: 'admin_tags_index', methods: ['GET'])] - public function index(EntityManagerInterface $em): Response - { - $tags = $em->createQueryBuilder() - ->select('t') - ->from(Tag::class, 't') - ->orderBy('t.label', 'ASC') - ->getQuery() - ->getResult(); + public function index( + EntityManagerInterface $em, + \App\Service\TagRebuildJobService $jobs + ): Response { + + $tags = $em->getRepository(\App\Entity\Tag::class) + ->findBy([], ['label' => 'ASC']); return $this->render('admin/tag/index.html.twig', [ 'tags' => $tags, + 'latestJob' => $jobs->getLatestJob(), + 'hasActiveJob' => $jobs->hasActiveJob(), + 'statusRunning' => TagRebuildJob::STATUS_RUNNING, + 'statusQueued' => TagRebuildJob::STATUS_QUEUED, + 'statusCompleted' => TagRebuildJob::STATUS_COMPLETED, + 'statusFailed' => TagRebuildJob::STATUS_FAILED, ]); } #[Route('/create', name: 'admin_tags_create', methods: ['POST'])] - public function create(Request $request, EntityManagerInterface $em, TagRebuildJobService $jobs): RedirectResponse - { - $token = (string)$request->request->get('_token', ''); + public function create( + Request $request, + TagService $tagService + ): RedirectResponse { + $token = (string) $request->request->get('_token', ''); + if (!$this->isCsrfTokenValid('admin_tag_create', $token)) { $this->addFlash('danger', 'Ungültiges CSRF Token.'); return $this->redirectToRoute('admin_tags_index'); } - $label = trim((string)$request->request->get('label', '')); - $slug = trim((string)$request->request->get('slug', '')); - $desc = trim((string)$request->request->get('description', '')); + try { + $tagService->create( + (string) $request->request->get('slug', ''), + (string) $request->request->get('label', ''), + $request->request->get('description') + ? (string) $request->request->get('description') + : null + ); - if ($label === '' || $slug === '') { - $this->addFlash('danger', 'Label und Slug sind Pflichtfelder.'); - return $this->redirectToRoute('admin_tags_index'); + $this->addFlash('success', 'Tag wurde erstellt. Rebuild läuft im Hintergrund.'); + } catch (\Throwable $e) { + $this->addFlash('danger', $e->getMessage()); } - $exists = (int)$em->createQueryBuilder() - ->select('COUNT(t.id)') - ->from(Tag::class, 't') - ->where('t.slug = :slug') - ->setParameter('slug', $slug) - ->getQuery() - ->getSingleScalarResult(); - - if ($exists > 0) { - $this->addFlash('danger', 'Slug existiert bereits.'); - return $this->redirectToRoute('admin_tags_index'); - } - - $tag = new Tag($slug, $label, $desc !== '' ? $desc : null); - - $em->persist($tag); - $em->flush(); - - // enqueue async rebuild - $jobs->enqueueAndStartAsync(); - - $this->addFlash('success', 'Tag wurde erstellt. Rebuild läuft im Hintergrund.'); return $this->redirectToRoute('admin_tags_index'); } #[Route('/{id}/delete', name: 'admin_tags_delete', methods: ['POST'])] - public function delete(string $id, Request $request, EntityManagerInterface $em, TagRebuildJobService $jobs): RedirectResponse - { - $token = (string)$request->request->get('_token', ''); + public function delete( + string $id, + Request $request, + TagService $tagService + ): RedirectResponse { + $token = (string) $request->request->get('_token', ''); + if (!$this->isCsrfTokenValid('admin_tag_delete_' . $id, $token)) { $this->addFlash('danger', 'Ungültiges CSRF Token.'); return $this->redirectToRoute('admin_tags_index'); } - $tag = $em->getRepository(Tag::class)->find($id); - if (!$tag instanceof Tag) { - $this->addFlash('danger', 'Tag nicht gefunden.'); - return $this->redirectToRoute('admin_tags_index'); + try { + $tagService->deleteById($id); + $this->addFlash('success', 'Tag wurde gelöscht. Rebuild läuft im Hintergrund.'); + } catch (\Throwable $e) { + $this->addFlash('danger', $e->getMessage()); } - $em->remove($tag); - $em->flush(); - - // enqueue async rebuild - $jobs->enqueueAndStartAsync(); - - $this->addFlash('success', 'Tag wurde gelöscht. Rebuild läuft im Hintergrund.'); return $this->redirectToRoute('admin_tags_index'); } + + } \ No newline at end of file diff --git a/src/Controller/Admin/TagRebuildStreamController.php b/src/Controller/Admin/TagRebuildStreamController.php new file mode 100644 index 0000000..7570aa2 --- /dev/null +++ b/src/Controller/Admin/TagRebuildStreamController.php @@ -0,0 +1,62 @@ + true]) . "\n\n"; + + @ob_flush(); + @flush(); + + while (!connection_aborted()) { + + $em->clear(); + + $job = $em->createQueryBuilder() + ->select('j') + ->from(TagRebuildJob::class, 'j') + ->orderBy('j.createdAt', 'DESC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + + if ($job) { + echo "event: message\n"; + echo "data: " . json_encode([ + 'status' => $job->getStatus(), + 'startedAt' => $job->getStartedAt()?->format(DATE_ATOM), + 'finishedAt' => $job->getFinishedAt()?->format(DATE_ATOM), + 'error' => $job->getErrorMessage(), + ]) . "\n\n"; + + @ob_flush(); + @flush(); + } + + sleep(2); + } + }); + + $response->headers->set('Content-Type', 'text/event-stream'); + $response->headers->set('Cache-Control', 'no-cache'); + $response->headers->set('Connection', 'keep-alive'); + $response->headers->set('X-Accel-Buffering', 'no'); // 🔥 wichtig bei nginx + + return $response; + } +} \ No newline at end of file diff --git a/src/Service/TagRebuildJobService.php b/src/Service/TagRebuildJobService.php index 46bbc7e..e07dec5 100644 --- a/src/Service/TagRebuildJobService.php +++ b/src/Service/TagRebuildJobService.php @@ -10,6 +10,12 @@ use Psr\Log\LoggerInterface; final readonly class TagRebuildJobService { + /** + * Wenn ein QUEUED-Job länger nicht startet, gilt er als "stale" und wird auf FAILED gesetzt, + * damit das System nicht dauerhaft blockiert. + */ + private const STALE_QUEUED_AFTER_SECONDS = 300; // 5 Minuten + public function __construct( private EntityManagerInterface $em, private LoggerInterface $agentLogger, @@ -28,24 +34,153 @@ final readonly class TagRebuildJobService return $job; } + public function enqueueIfIdle(): ?TagRebuildJob + { + // Coalescing: Wenn ein Job läuft oder queued ist -> nichts tun + if ($this->hasActiveJob()) { + return null; + } + + return $this->enqueueAndStartAsync(); + } + + /** + * Letzter Job (egal welcher Status). + */ + public function getLatestJob(): ?TagRebuildJob + { + return $this->em->createQueryBuilder() + ->select('j') + ->from(TagRebuildJob::class, 'j') + ->orderBy('j.createdAt', 'DESC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } + + /** + * Letzter Job mit Status COMPLETED. + */ + public function getLatestCompletedJob(): ?TagRebuildJob + { + return $this->em->createQueryBuilder() + ->select('j') + ->from(TagRebuildJob::class, 'j') + ->where('j.status = :status') + ->setParameter('status', TagRebuildJob::STATUS_COMPLETED) + ->orderBy('j.createdAt', 'DESC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } + + /** + * Ob gerade ein Job aktiv ist: + * - RUNNING ist immer aktiv + * - QUEUED ist nur aktiv, wenn er nicht stale ist + * + * Zusätzlich: stale QUEUED Jobs werden auf FAILED gesetzt (Recovery). + */ + public function hasActiveJob(): bool + { + $this->markStaleQueuedJobsFailed(); + + $cutoff = new \DateTimeImmutable('-' . self::STALE_QUEUED_AFTER_SECONDS . ' seconds'); + + $qb = $this->em->createQueryBuilder(); + $qb->select('COUNT(j.id)') + ->from(TagRebuildJob::class, 'j') + ->where( + $qb->expr()->orX( + 'j.status = :running', + $qb->expr()->andX( + 'j.status = :queued', + 'j.createdAt >= :cutoff' + ) + ) + ) + ->setParameter('running', TagRebuildJob::STATUS_RUNNING) + ->setParameter('queued', TagRebuildJob::STATUS_QUEUED) + ->setParameter('cutoff', $cutoff); + + return (int) $qb->getQuery()->getSingleScalarResult() > 0; + } + + /** + * Startet den Job async über bin/console. + * Wichtige Fixes: + * - php explizit verwenden + * - --no-interaction + * - Logfile statt /dev/null + */ private function startAsync(TagRebuildJob $job): void { - $php = PHP_BINARY; // safest in runtime - $console = rtrim($this->projectDir, '/') . '/bin/console'; + $projectDir = rtrim($this->projectDir, '/'); + $console = $projectDir . '/bin/console'; + $jobId = (string) $job->getId(); + + $logDir = $projectDir . '/var/log/tags'; + if (!is_dir($logDir)) { + @mkdir($logDir, 0777, true); + } + $logFile = $logDir . '/job_' . $jobId . '.log'; + + // Robust: cd ins Projekt, dann nohup php bin/console ... $cmd = sprintf( - '%s %s %s %s > /dev/null 2>&1 &', - escapeshellarg($php), + 'cd %s && nohup %s %s %s %s --no-interaction >> %s 2>&1 &', + escapeshellarg($projectDir), + escapeshellcmd('php'), escapeshellarg($console), - 'mto:agent:tags:job:run', - escapeshellarg((string)$job->getId()) + escapeshellarg('mto:agent:tags:job:run'), + escapeshellarg($jobId), + escapeshellarg($logFile) ); $this->agentLogger->info('[tags] enqueue job async', [ - 'job' => (string)$job->getId(), + 'job' => $jobId, 'cmd' => $cmd, + 'log' => $logFile, ]); @exec($cmd); } + + /** + * Recovery gegen "ewig QUEUED": + * Setzt alte QUEUED Jobs auf FAILED, damit enqueueIfIdle() nicht dauerhaft blockiert. + */ + private function markStaleQueuedJobsFailed(): void + { + $cutoff = new \DateTimeImmutable('-' . self::STALE_QUEUED_AFTER_SECONDS . ' seconds'); + + $qb = $this->em->createQueryBuilder(); + $qb->select('j') + ->from(TagRebuildJob::class, 'j') + ->where('j.status = :queued') + ->andWhere('j.createdAt < :cutoff') + ->setParameter('queued', TagRebuildJob::STATUS_QUEUED) + ->setParameter('cutoff', $cutoff) + ->setMaxResults(25); + + /** @var TagRebuildJob[] $stale */ + $stale = $qb->getQuery()->getResult(); + + if (!$stale) { + return; + } + + foreach ($stale as $job) { + $jobId = (string) $job->getId(); + + $job->markFailed('Stale QUEUED job detected (async start likely failed).'); + + $this->agentLogger->warning('[tags] stale QUEUED job marked FAILED', [ + 'job' => $jobId, + 'cutoff' => $cutoff->format(\DateTimeInterface::ATOM), + ]); + } + + $this->em->flush(); + } } \ No newline at end of file diff --git a/src/Tag/TagService.php b/src/Tag/TagService.php new file mode 100644 index 0000000..2dbf4bb --- /dev/null +++ b/src/Tag/TagService.php @@ -0,0 +1,134 @@ +slugExists($slug)) { + throw new \RuntimeException('Slug existiert bereits.'); + } + + $tag = new Tag($slug, $label, $description); + + $this->em->persist($tag); + $this->em->flush(); + + $this->triggerRebuildIfIdle(); + + return $tag; + } + + // ========================================================= + // TAG DELETE + // ========================================================= + + public function deleteById(string $tagId): void + { + $tag = $this->em->getRepository(Tag::class)->find($tagId); + + if (!$tag instanceof Tag) { + throw new \RuntimeException('Tag nicht gefunden.'); + } + + $this->delete($tag); + } + + public function delete(Tag $tag): void + { + $this->em->remove($tag); + $this->em->flush(); + + $this->triggerRebuildIfIdle(); + } + + // ========================================================= + // DOCUMENT TAG SYNC + // ========================================================= + + /** + * Synchronisiert alle Tags eines Dokuments. + * Löst einen Rebuild aus, da document_ids Teil des NDJSON sind. + */ + public function syncDocumentTags(Document $document, array $newTagIds): void + { + $newTagIds = array_unique($newTagIds); + + $currentRelations = $this->em + ->getRepository(DocumentTag::class) + ->findBy(['document' => $document]); + + $currentTagIds = array_map( + fn(DocumentTag $dt) => (string) $dt->getTag()->getId(), + $currentRelations + ); + + $toAdd = array_diff($newTagIds, $currentTagIds); + $toRemove = array_diff($currentTagIds, $newTagIds); + + foreach ($toAdd as $tagId) { + $tag = $this->em->getRepository(Tag::class)->find($tagId); + if ($tag instanceof Tag) { + $this->em->persist(new DocumentTag($document, $tag)); + } + } + + foreach ($currentRelations as $relation) { + if (in_array((string) $relation->getTag()->getId(), $toRemove, true)) { + $this->em->remove($relation); + } + } + + if ($toAdd || $toRemove) { + $this->em->flush(); + $this->triggerRebuildIfIdle(); + } + } + + // ========================================================= + // INTERNAL HELPERS + // ========================================================= + + private function slugExists(string $slug): bool + { + return (int) $this->em->createQueryBuilder() + ->select('COUNT(t.id)') + ->from(Tag::class, 't') + ->where('t.slug = :slug') + ->setParameter('slug', $slug) + ->getQuery() + ->getSingleScalarResult() > 0; + } + + private function triggerRebuildIfIdle(): void + { + if (!$this->jobs->hasActiveJob()) { + $this->jobs->enqueueAndStartAsync(); + } + } +} \ No newline at end of file diff --git a/templates/admin/document_tags/edit.html.twig b/templates/admin/document_tags/edit.html.twig index ba45e3a..e0fbb01 100644 --- a/templates/admin/document_tags/edit.html.twig +++ b/templates/admin/document_tags/edit.html.twig @@ -4,6 +4,76 @@ {% block body %} + {# ============================================= #} + {# Tag-Rebuild Status (Echte Live-Anzeige) #} + {# ============================================= #} + +
+ + +

Tags fĂĽr Dokument @@ -33,8 +103,8 @@
{% for tag in document.tags %} - {{ tag.label }} - + {{ tag.label }} + {% endfor %}
{% endif %} diff --git a/templates/admin/tag/index.html.twig b/templates/admin/tag/index.html.twig index b83c070..252c3c3 100644 --- a/templates/admin/tag/index.html.twig +++ b/templates/admin/tag/index.html.twig @@ -5,80 +5,179 @@ {% block body %}
-

Tags

+

Tag-Management

+ {# ========================================================= #} + {# Flash Messages #} + {# ========================================================= #} {% for message in app.flashes('success') %} -
{{ message }}
+
{{ message }}
{% endfor %} {% for message in app.flashes('danger') %} -
{{ message }}
+
{{ message }}
{% endfor %} -
+ {# ========================================================= #} + {# LIVE REBUILD STATUS (SSE) #} + {# ========================================================= #} + +
+ {% if latestJob %} +
+ Status wird geladen… +
+ {% endif %} +
+ + + + {# ========================================================= #} + {# Create Tag Card #} + {# ========================================================= #} +
Neuen Tag erstellen
-
+
- + +
- + +
- + +
-
- -
- -
- - Hinweis: Nach Änderungen an Tags/Zuweisungen bitte bin/console mto:agent:tags:rebuild ausführen. - +
+
-
+ {# ========================================================= #} + {# Tag Table #} + {# ========================================================= #} +
+ +
+ Vorhandene Tags + + {{ tags|length }} Einträge + +
+ - - - - + + + + {% for tag in tags %} - + - + {% else %} - + + + {% endfor %}
LabelSlugBeschreibungAktionLabelSlugBeschreibungAktion
{{ tag.label }}{{ tag.label }} {{ tag.slug }}{{ tag.description ?: '' }}{{ tag.description ?: '-' }} -
- -
Noch keine Tags vorhanden.
+ Noch keine Tags vorhanden. +
+