You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
256 lines
9.1 KiB
256 lines
9.1 KiB
<?php |
|
|
|
declare(strict_types=1); |
|
|
|
namespace App\Controller; |
|
|
|
use App\Util\ForumTopics; |
|
use Elastica\Aggregation\Filters as FiltersAgg; |
|
use Elastica\Query; |
|
use Elastica\Query\BoolQuery; |
|
use Elastica\Query\Term; |
|
use Elastica\Query\Terms; |
|
use FOS\ElasticaBundle\Finder\PaginatedFinderInterface; |
|
use Pagerfanta\Pagerfanta; |
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
|
use Symfony\Component\DependencyInjection\Attribute\Autowire; |
|
use Symfony\Component\HttpFoundation\Request; |
|
use Symfony\Component\HttpFoundation\Response; |
|
use Symfony\Component\Routing\Attribute\Route; |
|
use Symfony\Contracts\Cache\CacheInterface; |
|
|
|
class ForumController extends AbstractController |
|
{ |
|
#[Route('/forum', name: 'forum')] |
|
public function index( |
|
#[Autowire(service: 'fos_elastica.index.articles')] \Elastica\Index $index, |
|
CacheInterface $cache, |
|
Request $request |
|
): Response { |
|
// Optional: small cache so we don’t hammer ES on every page view |
|
//$categoriesWithCounts = $cache->get('forum.index.counts.v2', function (ItemInterface $item) use ($index) { |
|
// $item->expiresAfter(30); // 30s is a nice compromise for “live enough” |
|
$allTags = $this->flattenAllTags(ForumTopics::TOPICS); // ['tag' => true, ...] |
|
$counts = $this->fetchTagCounts($index, array_keys($allTags)); // ['tag' => count] |
|
|
|
// return $this->hydrateCategoryCounts(self::TOPICS, $counts); |
|
//}); |
|
$categoriesWithCounts = $this->hydrateCategoryCounts(ForumTopics::TOPICS, $counts); |
|
return $this->render('forum/index.html.twig', [ |
|
'topics' => $categoriesWithCounts, |
|
]); |
|
} |
|
|
|
#[Route('/forum/topic/{key}', name: 'forum_topic')] |
|
public function topic( |
|
string $key, |
|
#[Autowire(service: 'fos_elastica.finder.articles')] PaginatedFinderInterface $finder, |
|
#[Autowire(service: 'fos_elastica.index.articles')] \Elastica\Index $index, |
|
Request $request |
|
): Response { |
|
// key format: "{category}-{subcategory}" |
|
$key = strtolower(trim($key)); |
|
[$cat, $sub] = array_pad(explode('-', $key, 2), 2, null); |
|
|
|
if (!$cat || !$sub || !isset(ForumTopics::TOPICS[$cat]['subcategories'][$sub])) { |
|
throw $this->createNotFoundException('Topic not found'); |
|
} |
|
|
|
$topic = ForumTopics::TOPICS[$cat]['subcategories'][$sub]; |
|
|
|
// Count each tag in this subcategory in one shot |
|
$tags = array_map('strval', $topic['tags']); |
|
$tagCounts = $this->fetchTagCounts($index, $tags); |
|
|
|
// Fetch articles for the topic |
|
$bool = new BoolQuery(); |
|
$bool->addFilter(new Terms('topics', $tags)); |
|
|
|
$query = new Query($bool); |
|
$query->setSize(20); |
|
$query->setSort(['createdAt' => ['order' => 'desc']]); |
|
|
|
/** @var Pagerfanta $pager */ |
|
$pager = $finder->findPaginated($query); |
|
$pager->setMaxPerPage(20); |
|
$pager->setCurrentPage(max(1, (int) $request->query->get('page', 1))); |
|
$articles = iterator_to_array($pager->getCurrentPageResults()); |
|
|
|
// (Optional) also show latest threads under this topic scope |
|
$page = max(1, (int) $request->query->get('page', 1)); |
|
$perPage = 20; |
|
$threads = $this->fetchThreads($index, [$tags]); // OR scope: any tag in subcategory |
|
$threadsPage = array_slice($threads, ($page-1)*$perPage, $perPage); |
|
|
|
return $this->render('forum/topic.html.twig', [ |
|
'categoryKey' => $cat, |
|
'subcategoryKey' => $sub, |
|
'topic' => [ |
|
'name' => $topic['name'], |
|
'tags' => $tags, |
|
], |
|
'tags' => $tagCounts, // ['tag' => count] |
|
'threads' => $threadsPage, |
|
'total' => count($threads), |
|
'page' => $page, |
|
'perPage' => $perPage, |
|
'topics' => $this->getHydratedTopics($index), |
|
'articles' => $articles |
|
]); |
|
} |
|
|
|
#[Route('/forum/tag/{tag}', name: 'forum_tag')] |
|
public function tag( |
|
string $tag, |
|
#[Autowire(service: 'fos_elastica.finder.articles')] PaginatedFinderInterface $finder, |
|
#[Autowire(service: 'fos_elastica.index.articles')] \Elastica\Index $index, |
|
Request $request |
|
): Response { |
|
$tag = strtolower(trim($tag)); |
|
|
|
$bool = new BoolQuery(); |
|
// Correct Term usage: |
|
$bool->addFilter(new Term(['topics' => $tag])); |
|
|
|
$query = new Query($bool); |
|
$query->setSize(20); |
|
|
|
$query->setSort(['createdAt' => ['order' => 'desc']]); |
|
|
|
/** @var Pagerfanta $pager */ |
|
$pager = $finder->findPaginated($query); |
|
$pager->setMaxPerPage(20); |
|
$pager->setCurrentPage(max(1, (int) $request->query->get('page', 1))); |
|
$articles = iterator_to_array($pager->getCurrentPageResults()); |
|
|
|
return $this->render('forum/tag.html.twig', [ |
|
'tag' => $tag, |
|
'articles' => $articles, |
|
'pager' => $pager, // expose if you want numbered pagination links |
|
'topics' => $this->getHydratedTopics($index), |
|
]); |
|
} |
|
|
|
// ---------- Helpers ---------- |
|
|
|
/** |
|
* Flatten all tags from the taxonomy into a unique set. |
|
* @return array<string, true> |
|
*/ |
|
private function flattenAllTags(array $categories): array |
|
{ |
|
$set = []; |
|
foreach ($categories as $cat) { |
|
foreach ($cat['subcategories'] as $sub) { |
|
foreach ($sub['tags'] as $tag) { |
|
$set[strtolower($tag)] = true; |
|
} |
|
} |
|
} |
|
return $set; |
|
} |
|
|
|
/** |
|
* Run one ES query that returns counts for each tag (OR scope per tag). |
|
* Uses a Filters aggregation keyed by tag to avoid N queries. |
|
* |
|
* @param \Elastica\Index $index |
|
* @param string[] $tags |
|
* @return array<string,int> |
|
*/ |
|
private function fetchTagCounts(\Elastica\Index $index, array $tags): array |
|
{ |
|
$tags = array_values(array_unique(array_map('strtolower', array_map('trim', $tags)))); |
|
if (!$tags) return []; |
|
|
|
$q = new Query(new Query\MatchAll()); |
|
$filters = new FiltersAgg('tag_counts'); |
|
|
|
foreach ($tags as $tag) { |
|
$b = new BoolQuery(); |
|
$b->addFilter(new Term(['topics' => $tag])); // topics must be keyword + lowercase normalizer |
|
$filters->addFilter($b, $tag); |
|
} |
|
|
|
$q->addAggregation($filters); |
|
$q->setSize(0); |
|
|
|
$res = $index->search($q); |
|
$agg = $res->getAggregation('tag_counts')['buckets'] ?? []; |
|
|
|
$out = []; |
|
foreach ($tags as $tag) { |
|
$out[$tag] = isset($agg[$tag]['doc_count']) ? (int) $agg[$tag]['doc_count'] : 0; |
|
} |
|
return $out; |
|
} |
|
|
|
/** |
|
* Rehydrate taxonomy with counts per subcategory (sum of its tags). |
|
* @param array<string,int> $counts |
|
*/ |
|
private function hydrateCategoryCounts(array $taxonomy, array $counts): array |
|
{ |
|
$out = []; |
|
foreach ($taxonomy as $catKey => $cat) { |
|
$subs = []; |
|
foreach ($cat['subcategories'] as $subKey => $sub) { |
|
$sum = 0; |
|
foreach ($sub['tags'] as $tag) { |
|
$sum += $counts[strtolower($tag)] ?? 0; |
|
} |
|
$subs[$subKey] = $sub + ['count' => $sum]; |
|
} |
|
$out[$catKey] = $cat; |
|
$out[$catKey]['subcategories'] = $subs; |
|
} |
|
return $out; |
|
} |
|
|
|
/** |
|
* (Optional) Fetch latest threads for a given OR-scope of tag groups. |
|
* You can replace this with your Finder if you want entity hydration. |
|
* |
|
* @param array<int,array<int,string>> $tagGroups e.g. [ ['bitcoin','lightning'] ] |
|
* @return array<int,array<string,mixed>> |
|
*/ |
|
private function fetchThreads(\Elastica\Index $index, array $tagGroups, int $size = 200): array |
|
{ |
|
$bool = new BoolQuery(); |
|
|
|
// For a simple OR across tags: use Terms query on 'topics' |
|
// If you pass multiple groups and want AND across groups, adapt here. |
|
$flatTags = []; |
|
foreach ($tagGroups as $g) { foreach ($g as $t) { $flatTags[] = strtolower($t); } } |
|
$flatTags = array_values(array_unique($flatTags)); |
|
|
|
if ($flatTags) { |
|
$bool->addFilter(new Terms('topics', $flatTags)); |
|
} |
|
|
|
$q = (new Query($bool)) |
|
->setSize($size) |
|
->addSort(['createdAt' => ['order' => 'desc']]); |
|
|
|
$rs = $index->search($q); |
|
|
|
// Map raw sources you need (adjust to your mapping) |
|
return array_map(static function (\Elastica\Result $hit) { |
|
$s = $hit->getSource(); |
|
return [ |
|
'id' => $s['id'] ?? $hit->getId(), |
|
'title' => $s['title'] ?? '(untitled)', |
|
'excerpt' => $s['excerpt'] ?? null, |
|
'topics' => $s['topics'] ?? [], |
|
'created_at' => $s['createdAt'] ?? null, |
|
]; |
|
}, $rs->getResults()); |
|
} |
|
|
|
private function getHydratedTopics(\Elastica\Index $index): array |
|
{ |
|
$allTags = $this->flattenAllTags(ForumTopics::TOPICS); |
|
$counts = $this->fetchTagCounts($index, array_keys($allTags)); |
|
return $this->hydrateCategoryCounts(ForumTopics::TOPICS, $counts); |
|
} |
|
}
|
|
|