['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}}
{$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}
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;
}
}
}