['label' => 'BGG-Spiel-ID', 'type' => 'text', 'group' => 'infobox'], 'bsn-verlag' => ['label' => 'Verlag', 'type' => 'text', 'group' => 'infobox'], 'bsn-designer' => ['label' => 'Designer', 'type' => 'text', 'group' => 'infobox'], 'bsn-autor' => ['label' => 'Autor', 'type' => 'text', 'group' => 'infobox'], 'bsn-illustrator' => ['label' => 'Illustrator', 'type' => 'text', 'group' => 'infobox'], 'bsn-spieler' => ['label' => 'Spieleranzahl', 'type' => 'text', 'group' => 'infobox'], 'bsn-alter' => ['label' => 'Alter', 'type' => 'text', 'group' => 'infobox'], 'bsn-dauer' => ['label' => 'Spieldauer', 'type' => 'text', 'group' => 'infobox'], 'bsn-bgg-rating' => ['label' => 'BGG-Rating', 'type' => 'text', 'group' => 'infobox'], 'bsn-bgg-weight' => ['label' => 'BGG-Schwierigkeit', 'type' => 'text', 'group' => 'infobox'], // Wertungsbox 'bsn-wertung-titel' => ['label' => 'Wertung – Titel', 'type' => 'text', 'group' => 'wertung'], 'bsn-wertung-untertitel' => ['label' => 'Wertung – Untertitel', 'type' => 'text', 'group' => 'wertung'], 'bsn-wertung-komplex' => ['label' => 'Wertung – Komplexität', 'type' => 'text', 'group' => 'wertung'], 'bsn-wertung-sprache' => ['label' => 'Wertung – Sprache', 'type' => 'text', 'group' => 'wertung'], 'bsn-wertung-mechanik' => ['label' => 'Wertung – Mechanismen', 'type' => 'textarea', 'group' => 'wertung'], 'bsn-wertung-pro' => ['label' => 'Wertung – Pro', 'type' => 'textarea', 'group' => 'wertung'], 'bsn-wertung-kontra' => ['label' => 'Wertung – Kontra', 'type' => 'textarea', 'group' => 'wertung'], 'bsn-wertung-autor' => ['label' => 'Wertung – Rezensent', 'type' => 'text', 'group' => 'wertung'], 'bsn-wertung-punkte' => ['label' => 'Wertung – Punkte', 'type' => 'integer', 'group' => 'wertung'], ]; /** * onAfterRender — the Astroid-safe injection point */ public function onAfterRender(): void { try { // Safety: only frontend, only HTML if ($this->app->isClient('administrator')) return; $body = $this->app->getBody(); $contentType = $this->app->getDocument()->getType(); if ($contentType !== 'html') return; // Only process if we're on an article page (heuristic: com_content article container) $hasArticle = preg_match('/com-content-article.*itemscope/', $body); if (!$hasArticle) return; // 1) Replace {bsn_infobox} shortcodes $body = $this->processInfoboxShortcodes($body); // 2) Inject wertungsbox if category matches $body = $this->processWertungsbox($body); $this->app->setBody($body); } catch (\Throwable $e) { // Silent fail — never break the site } } /** * Find and replace {bsn_infobox} shortcodes with styled HTML */ private function processInfoboxShortcodes(string $body): string { if (!preg_match_all('/\{bsn_infobox\}/', $body, $matches, PREG_OFFSET_CAPTURE)) { return $body; } // Find article IDs for each shortcode occurrence $replacements = []; foreach ($matches[0] as $match) { $pos = $match[1]; // Find the enclosing article container $articleId = $this->findArticleId($body, $pos); if (!$articleId) continue; $fields = $this->getFieldValues($articleId); if (!$fields) continue; $box = $this->buildInfobox($fields); if ($box) { $replacements[] = ['pos' => $pos, 'len' => 14, 'html' => $box]; } } // Apply in reverse order foreach (array_reverse($replacements) as $r) { $body = substr_replace($body, $r['html'], $r['pos'], $r['len']); } return $body; } /** * Auto-inject wertungsbox at end of article content */ private function processWertungsbox(string $body): string { // Find article containers if (!preg_match_all( '/]*>/', $body, $matches, PREG_OFFSET_CAPTURE )) { return $body; } // Get authorized categories from plugin params $allowedCategories = $this->params->get('categories_wertung', []); if (empty($allowedCategories) || (is_array($allowedCategories) && count($allowedCategories) === 1 && empty($allowedCategories[0]))) { return $body; // No categories selected → don't inject } $injections = []; foreach ($matches[0] as $match) { $containerStart = $match[1]; $articleId = $this->findArticleId($body, $containerStart); if (!$articleId) continue; // Check category $catId = $this->getArticleCategory($articleId); if (!$catId || !in_array((string)$catId, (array)$allowedCategories)) { continue; } $fields = $this->getFieldValues($articleId); if (!$fields) continue; $box = $this->buildWertungsbox($fields); if (!$box) continue; // Find or that closes the article content // Inject just before the closing of the article container $closePos = $this->findArticleContentEnd($body, $containerStart); if ($closePos) { $injections[] = ['pos' => $closePos, 'html' => $box]; } } foreach (array_reverse($injections) as $inj) { $body = substr_replace($body, $inj['html'], $inj['pos'], 0); } return $body; } /** * Find article ID from HTML context near a position */ private function findArticleId(string $body, int $nearPos): ?int { $chunk = substr($body, max(0, $nearPos - 500), 3000); $patterns = [ '/data-id="(\d+)"/', '/itemprop="identifier"[^>]*content="(\d+)"/', '/ tag, or the before "Quellen" kasten $searchArea = substr($body, $containerStart, 20000); // Try first $articleEnd = strpos($searchArea, ''); if ($articleEnd !== false) { return $containerStart + $articleEnd; } // Try the last before "Quellen" heading if (preg_match('/]*>\s*Quellen\s*<\/h2>/', $searchArea, $m, PREG_OFFSET_CAPTURE)) { $quellenPos = $m[0][1]; // Find the closing just before Quellen $before = substr($searchArea, 0, $quellenPos); $lastDiv = strrpos($before, ''); if ($lastDiv !== false) { return $containerStart + $lastDiv; } } return null; } /** * Get article's category ID */ private function getArticleCategory(int $articleId): ?int { try { $db = Factory::getContainer()->get(DatabaseInterface::class); $query = $db->getQuery(true) ->select('catid') ->from('#__content') ->where('id = ' . (int)$articleId) ->setLimit(1); $db->setQuery($query); return (int)$db->loadResult() ?: null; } catch (\Throwable $e) { return null; } } /** * Read custom field values for an article */ private function getFieldValues(int $articleId): ?array { try { $db = Factory::getContainer()->get(DatabaseInterface::class); $names = array_keys(self::FIELDS); $quotedNames = array_map(function($n) use ($db) { return $db->quote($n); }, $names); $query = $db->getQuery(true) ->select(['f.name', 'v.value']) ->from($db->quoteName('#__fields', 'f')) ->join('LEFT', $db->quoteName('#__fields_values', 'v'), 'f.id = v.field_id AND v.item_id = ' . (int)$articleId) ->where('f.context = ' . $db->quote('com_content.article')) ->where('f.name IN (' . implode(',', $quotedNames) . ')') ->where('f.state = 1'); $db->setQuery($query); $rows = $db->loadAssocList('name'); if (empty($rows)) return null; $fields = []; $hasAny = false; foreach (self::FIELDS as $name => $def) { $val = isset($rows[$name]) && $rows[$name]['value'] !== null ? trim($rows[$name]['value']) : ''; $fields[$name] = $val; if ($val !== '') $hasAny = true; } return $hasAny ? $fields : null; } catch (\Throwable $e) { return null; } } /** * Build the infobox HTML */ private function buildInfobox(array $f): string { $needle = ['bsn-verlag','bsn-designer','bsn-autor','bsn-illustrator','bsn-spieler','bsn-alter','bsn-dauer']; $hasAny = false; foreach ($needle as $n) { if (!empty($f[$n])) { $hasAny = true; break; } } if (!$hasAny) return ''; $items = []; $labels = [ 'bsn-verlag' => 'Verlag', 'bsn-designer' => 'Designer', 'bsn-autor' => 'Autor', 'bsn-illustrator' => 'Illustrator', 'bsn-spieler' => 'Spieler', 'bsn-alter' => 'Alter', 'bsn-dauer' => 'Dauer', 'bsn-bgg-rating' => 'BGG-Rating', 'bsn-bgg-weight' => 'Schwierigkeit', ]; foreach ($labels as $key => $label) { if (!empty($f[$key])) { $items[] = '' . htmlspecialchars($label) . ': ' . htmlspecialchars($f[$key]); } } $rows = ''; foreach ($items as $item) { $rows .= '
' . $item . '
'; } return << {$rows} HTML; } /** * Build the wertungsbox HTML */ private function buildWertungsbox(array $f): string { if (empty($f['bsn-wertung-titel'])) return ''; $titel = htmlspecialchars($f['bsn-wertung-titel']); $subtitel = htmlspecialchars($f['bsn-wertung-untertitel']); $komplex = htmlspecialchars($f['bsn-wertung-komplex']); $sprache = htmlspecialchars($f['bsn-wertung-sprache']); $mechanik = $this->parseLines($f['bsn-wertung-mechanik']); $pro = $this->parseLines($f['bsn-wertung-pro']); $kontra = $this->parseLines($f['bsn-wertung-kontra']); $rezensent = htmlspecialchars($f['bsn-wertung-autor']); $punkte = (int)$f['bsn-wertung-punkte']; $meta = ''; if ($komplex) $meta .= 'Komplexität: ' . $komplex . ''; if ($sprache) { if ($meta) $meta .= '
'; $meta .= 'Sprachen: ' . $sprache . ''; } return << .bsn-wertungsbox{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;max-width:720px;margin:1.5rem 0;line-height:1.4} .bsn-wertungsbox *{box-sizing:border-box} .bsn-wb-header{background:#1a1e6b;color:#fff;padding:1.2rem 1.5rem;display:flex;justify-content:space-between;align-items:center;gap:1rem;flex-wrap:wrap} .bsn-wb-header .bsn-wb-titel-block{flex:1;min-width:200px} .bsn-wb-header .bsn-wb-titel-block .bsn-wb-titel{margin:0;font-size:1.4rem;font-weight:700} .bsn-wb-header .bsn-wb-titel-block .bsn-wb-untertitel{font-size:.9rem;opacity:.8;margin-top:2px} .bsn-wb-header .bsn-wb-meta{text-align:right;font-size:.85rem;line-height:1.6;min-width:150px} .bsn-wb-header .bsn-wb-logo{font-weight:700;font-size:.9rem;letter-spacing:.05em;text-align:right;line-height:1.3} .bsn-wb-content{background:#fff;display:flex;border:1px solid #ddd;border-top:none;flex-wrap:wrap} .bsn-wb-col{flex:1;min-width:200px;padding:1rem 1.2rem 1.2rem} .bsn-wb-col h4{margin:0 0 .6rem;font-size:.8rem;letter-spacing:.08em;color:#1a1e6b;text-align:center;text-transform:uppercase} .bsn-wb-col ul{list-style:none;padding:0;margin:0} .bsn-wb-col li{padding:.5rem 0;border-bottom:1px dotted #ccc;font-size:.85rem} .bsn-wb-col li:last-child{border-bottom:none} .bsn-wb-footer{background:#1a1e6b;color:#fff;padding:.9rem 1.5rem;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:1rem} .bsn-wb-footer .bsn-wb-rezensent{font-size:.85rem} .bsn-wb-footer .bsn-wb-rezensent .bsn-wb-name{font-size:1.1rem;font-style:italic} .bsn-wb-footer .bsn-wb-score{text-align:right} .bsn-wb-footer .bsn-wb-score .bsn-wb-score-label{font-size:.75rem;opacity:.8;text-transform:uppercase} .bsn-wb-footer .bsn-wb-score .bsn-wb-score-zahl{font-size:2rem;font-weight:700} @media(max-width:600px){.bsn-wb-col{min-width:100%}.bsn-wb-header{flex-direction:column;text-align:center}.bsn-wb-header .bsn-wb-meta{text-align:center}.bsn-wb-header .bsn-wb-logo{text-align:center}}
{$titel}
{$subtitel}
{$meta}
{$this->renderWbCol('MECHANISMEN', $mechanik)} {$this->renderWbCol('PRO', $pro)} {$this->renderWbCol('KONTRA', $kontra)}
HTML; } private function renderWbCol(string $title, array $items): string { $h = htmlspecialchars($title); $lis = ''; foreach ($items as $item) { $lis .= '
  • ' . htmlspecialchars($item) . '
  • '; } return <<

    {$h}

      {$lis}
    HTML; } private function parseLines(string $text): array { $text = trim($text); if ($text === '') return []; $lines = preg_split('/\r?\n/', $text); return array_values(array_filter(array_map('trim', $lines), fn($l) => $l !== '')); } // ─── INSTALL / UNINSTALL ─────────────────────────────── public function installFields(): bool { try { $db = Factory::getContainer()->get(DatabaseInterface::class); // Find or create field group $query = $db->getQuery(true) ->select('id')->from('#__fields_groups') ->where('context = ' . $db->quote('com_content.article')) ->where('title = ' . $db->quote('BSN Wertungsbox')) ->setLimit(1); $db->setQuery($query); $groupId = (int)$db->loadResult(); if (!$groupId) { $db->insertObject('#__fields_groups', (object)[ 'id' => 0, 'asset_id' => 0, 'context' => 'com_content.article', 'title' => 'BSN Wertungsbox', 'state' => 1, 'access' => 1, 'language' => '*', 'params' => '{}', 'created' => date('Y-m-d H:i:s'), 'created_by' => 0, ], 'id'); $groupId = (int)$db->insertid(); } $order = 1; foreach (self::FIELDS as $name => $def) { $query = $db->getQuery(true) ->select('id')->from('#__fields') ->where('name = ' . $db->quote($name)) ->where('context = ' . $db->quote('com_content.article')); $db->setQuery($query); if ($db->loadResult()) { $order++; continue; } $params = match($def['type']) { 'integer' => '{"class":"","default":"","first":"0","last":"100","step":"1","showlabel":"1"}', 'textarea' => '{"class":"","rows":"4","cols":"20","filter":"raw","showlabel":"1"}', default => '{"class":"","showlabel":"1"}', }; $db->insertObject('#__fields', (object)[ 'id' => 0, 'asset_id' => 0, 'context' => 'com_content.article', 'group_id' => $groupId, 'title' => $def['label'], 'name' => $name, 'label' => $def['label'], 'type' => $def['type'], 'state' => 1, 'required' => 0, 'default_value' => '', 'params' => $params, 'fieldparams' => '{}', 'access' => 1, 'language' => '*', 'ordering' => $order++, 'created' => date('Y-m-d H:i:s'), 'created_by' => 0, ]); } return true; } catch (\Throwable $e) { return false; } } public function uninstallFields(): bool { try { $db = Factory::getContainer()->get(DatabaseInterface::class); $names = array_keys(self::FIELDS); $quoted = array_map(fn($n) => $db->quote($n), $names); // Delete values first $query = $db->getQuery(true) ->delete('#__fields_values') ->where('field_id IN (SELECT id FROM ' . $db->quoteName('#__fields') . ' WHERE context = ' . $db->quote('com_content.article') . ' AND name IN (' . implode(',', $quoted) . '))'); $db->setQuery($query); $db->execute(); // Delete fields $query = $db->getQuery(true) ->delete('#__fields') ->where('context = ' . $db->quote('com_content.article')) ->where('name IN (' . implode(',', $quoted) . ')'); $db->setQuery($query); $db->execute(); return true; } catch (\Throwable $e) { return false; } } }