Files

516 lines
20 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* @package BSN_Wertungsbox
* @copyright (C) 2026 brettspiel-news.de
* @license GPL-2.0-or-later
*
* System-Plugin: Rendert Infobox + Wertungsbox aus Joomla Custom Fields.
* Astroid-kompatibel via onAfterRender. Kategorie-gesteuert.
*
* REVIEW-ARTIKEL-AUFBAU:
* [Einleitung]
* {bsn_infobox} ← Shortcode, manuell gesetzt
* [Haupttext]
* ← Wertungsbox erscheint automatisch hier
*/
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
use Joomla\CMS\Uri\Uri;
class PlgSystemBsn_Wertungsbox extends CMSPlugin
{
protected $app;
protected $autoloadLanguage = true;
// Custom Field definitions
private const FIELDS = [
// Infobox
'bsn-spiel-id' => ['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(
'/<div\s+class="[^"]*com-content-article[^"]*"[^>]*>/',
$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 </article> or </div> 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+)"/',
'/<meta\s+itemprop="identifier"\s+content="(\d+)"/',
];
foreach ($patterns as $p) {
if (preg_match($p, $chunk, $m)) {
return (int)$m[1];
}
}
return null;
}
/**
* Find the closing position of article content (before the kasten/quellen div)
*/
private function findArticleContentEnd(string $body, int $containerStart): ?int
{
// Look for </article> tag, or the </div> before "Quellen" kasten
$searchArea = substr($body, $containerStart, 20000);
// Try </article> first
$articleEnd = strpos($searchArea, '</article>');
if ($articleEnd !== false) {
return $containerStart + $articleEnd;
}
// Try the last </div> before "Quellen" heading
if (preg_match('/<h2[^>]*>\s*Quellen\s*<\/h2>/', $searchArea, $m, PREG_OFFSET_CAPTURE)) {
$quellenPos = $m[0][1];
// Find the closing </div> just before Quellen
$before = substr($searchArea, 0, $quellenPos);
$lastDiv = strrpos($before, '</div>');
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[] = '<strong>' . htmlspecialchars($label) . ':</strong> '
. htmlspecialchars($f[$key]);
}
}
$rows = '';
foreach ($items as $item) {
$rows .= '<div>' . $item . '</div>';
}
return <<<HTML
<div class="bsn-infobox" style="background:#f5f0eb;border:1px solid #d4c5b2;border-radius:8px;padding:1rem 1.3rem;margin:1.5rem 0;font-size:0.9rem;line-height:1.8;">
{$rows}
</div>
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: <strong>' . $komplex . '</strong>';
if ($sprache) {
if ($meta) $meta .= '<br>';
$meta .= 'Sprachen: <strong>' . $sprache . '</strong>';
}
return <<<HTML
<style>
.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}}
</style>
<div class="bsn-wertungsbox">
<div class="bsn-wb-header">
<div class="bsn-wb-titel-block">
<div class="bsn-wb-titel">{$titel}</div>
<div class="bsn-wb-untertitel">{$subtitel}</div>
</div>
<div class="bsn-wb-meta">{$meta}</div>
<div class="bsn-wb-logo">BRETT<br>SPIEL<br>NEWS</div>
</div>
<div class="bsn-wb-content">
{$this->renderWbCol('MECHANISMEN', $mechanik)}
{$this->renderWbCol('PRO', $pro)}
{$this->renderWbCol('KONTRA', $kontra)}
</div>
<div class="bsn-wb-footer">
<div class="bsn-wb-rezensent">
Bewertung von:<br>
<span class="bsn-wb-name">{$rezensent}</span>
</div>
<div class="bsn-wb-score">
<div class="bsn-wb-score-label">MEINE WERTUNG</div>
<div class="bsn-wb-score-zahl">{$punkte} / 100</div>
</div>
</div>
</div>
HTML;
}
private function renderWbCol(string $title, array $items): string
{
$h = htmlspecialchars($title);
$lis = '';
foreach ($items as $item) {
$lis .= '<li>' . htmlspecialchars($item) . '</li>';
}
return <<<HTML
<div class="bsn-wb-col">
<h4>{$h}</h4>
<ul>{$lis}</ul>
</div>
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;
}
}
}