diff --git a/src/Controller/Administration/ArticleManagementController.php b/src/Controller/Administration/ArticleManagementController.php index a16e2dc..738da1a 100644 --- a/src/Controller/Administration/ArticleManagementController.php +++ b/src/Controller/Administration/ArticleManagementController.php @@ -41,20 +41,9 @@ class ArticleManagementController extends AbstractController if (count($articles) >= 50) break; } } - // Fetch main index and extract nested indexes - $mainIndex = $redisCacheService->getMagazineIndex('magazine-newsroom-magazine-by-newsroom'); - $indexes = []; - if ($mainIndex && $mainIndex->getTags() !== null) { - foreach ($mainIndex->getTags() as $tag) { - if ($tag[0] === 'a' && isset($tag[1])) { - $parts = explode(':', $tag[1], 3); - $indexes[$tag[1]] = end($parts); // Extract index key from tag - } - } - } return $this->render('admin/articles.html.twig', [ 'articles' => $articles, - 'indexes' => $indexes, + 'indexes' => [], ]); } diff --git a/src/Controller/Administration/MagazineAdminController.php b/src/Controller/Administration/MagazineAdminController.php index 6e059d3..a7d6dcf 100644 --- a/src/Controller/Administration/MagazineAdminController.php +++ b/src/Controller/Administration/MagazineAdminController.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace App\Controller\Administration; use App\Entity\Article; +use App\Entity\Event; +use App\Enum\KindsEnum; use Doctrine\ORM\EntityManagerInterface; use Redis as RedisClient; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -19,7 +21,296 @@ class MagazineAdminController extends AbstractController #[IsGranted('ROLE_ADMIN')] public function index(RedisClient $redis, CacheInterface $redisCache, EntityManagerInterface $em): Response { - // 1) Collect known top-level magazine slugs from Redis set (populated on publish) + // Optimized database-first approach + $magazines = $this->getMagazinesFromDatabase($em, $redis, $redisCache); + + return $this->render('admin/magazines.html.twig', [ + 'magazines' => $magazines, + ]); + } + + private function getMagazinesFromDatabase(EntityManagerInterface $em, RedisClient $redis, CacheInterface $redisCache): array + { + // 1) Get magazine events directly from database using indexed queries + $magazineEvents = $this->getMagazineEvents($em); + + // Fallback: if no magazines found in DB, try Redis as backup + if (empty($magazineEvents)) { + return $this->getFallbackMagazinesFromRedis($redis, $redisCache, $em); + } + + // 2) Get all category events in one query + $categoryCoordinates = $this->extractCategoryCoordinates($magazineEvents); + $categoryEvents = $this->getCategoryEventsByCoordinates($em, $categoryCoordinates); + + // 3) Get all article slugs and batch query articles + $articleCoordinates = $this->extractArticleCoordinates($categoryEvents); + $articles = $this->getArticlesByCoordinates($em, $articleCoordinates); + + // 4) Build magazine structure efficiently + return $this->buildMagazineStructure($magazineEvents, $categoryEvents, $articles); + } + + private function getMagazineEvents(EntityManagerInterface $em): array + { + // Query magazines using database index on kind + $qb = $em->createQueryBuilder(); + $qb->select('e') + ->from(Event::class, 'e') + ->where('e.kind = :magazineKind') + ->setParameter('magazineKind', KindsEnum::PUBLICATION_INDEX->value) + ->orderBy('e.created_at', 'DESC'); + + $allMagazineEvents = $qb->getQuery()->getResult(); + + // Filter to only show top-level magazines (those that contain other indexes, not direct articles) + return $this->filterTopLevelMagazines($allMagazineEvents); + } + + private function filterTopLevelMagazines(array $magazineEvents): array + { + $topLevelMagazines = []; + + foreach ($magazineEvents as $event) { + $hasSubIndexes = false; + $hasDirectArticles = false; + + foreach ($event->getTags() as $tag) { + if ($tag[0] === 'a' && isset($tag[1])) { + $coord = $tag[1]; + + // Check if this coordinate points to another index (30040:...) + if (str_starts_with($coord, '30040:')) { + $hasSubIndexes = true; + } + // Check if this coordinate points to articles (30023: or 30024:) + elseif (str_starts_with($coord, '30023:') || str_starts_with($coord, '30024:')) { + $hasDirectArticles = true; + } + } + } + + // Only include magazines that have sub-indexes but no direct articles + // This identifies top-level magazines that organize other indexes + if ($hasSubIndexes && !$hasDirectArticles) { + $topLevelMagazines[] = $event; + } + } + + return $topLevelMagazines; + } + + private function extractCategoryCoordinates(array $magazineEvents): array + { + $coordinates = []; + foreach ($magazineEvents as $event) { + foreach ($event->getTags() as $tag) { + if ($tag[0] === 'a' && isset($tag[1]) && str_starts_with($tag[1], '30040:')) { + $coordinates[] = $tag[1]; + } + } + } + return array_unique($coordinates); + } + + private function getCategoryEventsByCoordinates(EntityManagerInterface $em, array $coordinates): array + { + if (empty($coordinates)) { + return []; + } + + // Extract slugs from coordinates for efficient querying + $slugs = []; + foreach ($coordinates as $coord) { + $parts = explode(':', $coord, 3); + if (count($parts) === 3) { + $slugs[] = $parts[2]; + } + } + + if (empty($slugs)) { + return []; + } + + // Use PostgreSQL-compatible JSON operations + $connection = $em->getConnection(); + $placeholders = str_repeat('?,', count($slugs) - 1) . '?'; + + $sql = " + SELECT * FROM event e + WHERE e.kind = ? + AND EXISTS ( + SELECT 1 FROM json_array_elements(e.tags) AS tag_element + WHERE tag_element->>0 = 'd' + AND tag_element->>1 IN ({$placeholders}) + ) + "; + + $params = [KindsEnum::PUBLICATION_INDEX->value, ...$slugs]; + $result = $connection->executeQuery($sql, $params); + $rows = $result->fetchAllAssociative(); + + // Convert result rows back to Event entities + $indexed = []; + foreach ($rows as $row) { + $event = new Event(); + $event->setId($row['id']); + if ($row['event_id'] !== null) { + $event->setEventId($row['event_id']); + } + $event->setKind($row['kind']); + $event->setPubkey($row['pubkey']); + $event->setContent($row['content']); + $event->setCreatedAt($row['created_at']); + $event->setTags(json_decode($row['tags'], true) ?: []); + $event->setSig($row['sig']); + + $slug = $event->getSlug(); + if ($slug) { + $indexed[$slug] = $event; + } + } + + return $indexed; + } + + private function extractArticleCoordinates(array $categoryEvents): array + { + $coordinates = []; + foreach ($categoryEvents as $event) { + foreach ($event->getTags() as $tag) { + if ($tag[0] === 'a' && isset($tag[1])) { + $coordinates[] = $tag[1]; + } + } + } + return array_unique($coordinates); + } + + private function getArticlesByCoordinates(EntityManagerInterface $em, array $coordinates): array + { + if (empty($coordinates)) { + return []; + } + + // Extract slugs for batch query + $slugs = []; + foreach ($coordinates as $coord) { + $parts = explode(':', $coord, 3); + if (count($parts) === 3 && !empty($parts[2])) { + $slugs[] = $parts[2]; + } + } + + if (empty($slugs)) { + return []; + } + + // Batch query articles using index on slug + $qb = $em->createQueryBuilder(); + $qb->select('a') + ->from(Article::class, 'a') + ->where($qb->expr()->in('a.slug', ':slugs')) + ->setParameter('slugs', $slugs); + + $articles = $qb->getQuery()->getResult(); + + // Index by slug for efficient lookup + $indexed = []; + foreach ($articles as $article) { + if ($article->getSlug()) { + $indexed[$article->getSlug()] = $article; + } + } + + return $indexed; + } + + private function buildMagazineStructure(array $magazineEvents, array $categoryEvents, array $articles): array + { + $magazines = []; + + foreach ($magazineEvents as $event) { + $magazineData = $this->parseEventTags($event); + + // Build categories for this magazine + $categories = []; + foreach ($magazineData['a'] as $coord) { + if (!str_starts_with($coord, '30040:')) continue; + + $parts = explode(':', $coord, 3); + if (count($parts) !== 3) continue; + + $catSlug = $parts[2]; + if (!isset($categoryEvents[$catSlug])) continue; + + $categoryEvent = $categoryEvents[$catSlug]; + $categoryData = $this->parseEventTags($categoryEvent); + + // Build files for this category + $files = []; + foreach ($categoryData['a'] as $aCoord) { + $partsA = explode(':', $aCoord, 3); + if (count($partsA) !== 3) continue; + + $artSlug = $partsA[2]; + $authorPubkey = $partsA[1] ?? ''; + + /** @var Article $article */ + $article = $articles[$artSlug] ?? null; + $title = $article ? $article->getTitle() : $artSlug; + + // Get the date - prefer publishedAt, fallback to createdAt + $date = null; + if ($article) { + $date = $article->getPublishedAt() ?? $article->getCreatedAt(); + } + + $files[] = [ + 'name' => $title ?? $artSlug, + 'slug' => $artSlug, + 'coordinate' => $aCoord, + 'authorPubkey' => $authorPubkey, + 'date' => $date, + ]; + } + + $categories[] = [ + 'name' => $categoryData['title'], + 'slug' => $categoryData['slug'], + 'files' => $files, + ]; + } + + $magazines[] = [ + 'name' => $magazineData['title'], + 'slug' => $magazineData['slug'], + 'categories' => $categories, + ]; + } + + return $magazines; + } + + private function parseEventTags($event): array + { + $title = null; $slug = null; $a = []; + foreach ($event->getTags() as $tag) { + if (!is_array($tag) || !isset($tag[0])) continue; + if ($tag[0] === 'title' && isset($tag[1])) $title = $tag[1]; + if ($tag[0] === 'd' && isset($tag[1])) $slug = $tag[1]; + if ($tag[0] === 'a' && isset($tag[1])) $a[] = $tag[1]; + } + return [ + 'title' => $title ?? ($slug ?? '(untitled)'), + 'slug' => $slug ?? '', + 'a' => $a, + ]; + } + + private function getFallbackMagazinesFromRedis(RedisClient $redis, CacheInterface $redisCache, EntityManagerInterface $em): array + { + // Fallback to original Redis implementation for backward compatibility $slugs = []; try { $members = $redis->sMembers('magazine_slugs'); @@ -30,22 +321,16 @@ class MagazineAdminController extends AbstractController // ignore set errors } - // 2) Ensure the known main magazine is included if present in cache try { $main = $redisCache->get('magazine-newsroom-magazine-by-newsroom', fn() => null); - if ($main) { - if (!in_array('newsroom-magazine-by-newsroom', $slugs, true)) { - $slugs[] = 'newsroom-magazine-by-newsroom'; - } + if ($main && !in_array('newsroom-magazine-by-newsroom', $slugs, true)) { + $slugs[] = 'newsroom-magazine-by-newsroom'; } } catch (\Throwable) { // ignore } - // 3) Load magazine events and build structure $magazines = []; - - // Helper to parse tags $parse = function($event): array { $title = null; $slug = null; $a = []; foreach ((array) $event->getTags() as $tag) { @@ -63,40 +348,48 @@ class MagazineAdminController extends AbstractController foreach ($slugs as $slug) { $event = $redisCache->get('magazine-' . $slug, fn() => null); - if (!$event || !method_exists($event, 'getTags')) { - continue; - } - $data = $parse($event); + if (!$event || !method_exists($event, 'getTags')) continue; - // Resolve categories + $data = $parse($event); $categories = []; + foreach ($data['a'] as $coord) { if (!str_starts_with((string)$coord, '30040:')) continue; $parts = explode(':', (string)$coord, 3); if (count($parts) !== 3) continue; + $catSlug = $parts[2]; $catEvent = $redisCache->get('magazine-' . $catSlug, fn() => null); if (!$catEvent || !method_exists($catEvent, 'getTags')) continue; - $catData = $parse($catEvent); - // Files under category from its 'a' coordinates + $catData = $parse($catEvent); $files = []; $repo = $em->getRepository(Article::class); + foreach ($catData['a'] as $aCoord) { $partsA = explode(':', (string)$aCoord, 3); if (count($partsA) !== 3) continue; + $artSlug = $partsA[2]; $authorPubkey = $partsA[1] ?? ''; $title = null; + $date = null; + if ($artSlug !== '') { $article = $repo->findOneBy(['slug' => $artSlug]); - if ($article) { $title = $article->getTitle(); } + if ($article) { + $title = $article->getTitle(); + // Get the date - prefer publishedAt, fallback to createdAt + $date = $article->getPublishedAt() ?? $article->getCreatedAt(); + } } + $files[] = [ 'name' => $title ?? $artSlug, 'slug' => $artSlug, 'coordinate' => $aCoord, 'authorPubkey' => $authorPubkey, + 'date' => $date, ]; } @@ -114,8 +407,6 @@ class MagazineAdminController extends AbstractController ]; } - return $this->render('admin/magazines.html.twig', [ - 'magazines' => $magazines, - ]); + return $magazines; } } diff --git a/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeExtension.php b/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeExtension.php index ea5cec9..65e767e 100644 --- a/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeExtension.php +++ b/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeExtension.php @@ -17,7 +17,7 @@ class NostrSchemeExtension implements ExtensionInterface { $environment ->addInlineParser(new NostrMentionParser($this->redisCacheService), 200) - ->addInlineParser(new NostrSchemeParser(), 199) + ->addInlineParser(new NostrSchemeParser($this->redisCacheService), 199) ->addInlineParser(new NostrRawNpubParser($this->redisCacheService), 198) ->addRenderer(NostrSchemeData::class, new NostrEventRenderer(), 2) diff --git a/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php b/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php index b40a0b0..c221d19 100644 --- a/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php +++ b/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php @@ -18,7 +18,7 @@ class NostrSchemeParser implements InlineParserInterface private RedisCacheService $redisCacheService; - public function __construct(RedisCacheService $redisCache, RedisCacheService $redisCacheService) + public function __construct(RedisCacheService $redisCacheService) { $this->redisCacheService = $redisCacheService; } diff --git a/templates/admin/cache.html.twig b/templates/admin/cache.html.twig index 91424bd..71602cd 100644 --- a/templates/admin/cache.html.twig +++ b/templates/admin/cache.html.twig @@ -54,101 +54,103 @@ {% endblock %} {% block layout %} -
-

Cache Management

- -
-

Caching Strategy Overview

-

Your newsroom application uses a multi-layered caching strategy:

- -
- -
- Loading cache status... -
- -
- - - - - - - - - - - -
- -
-

Cache Types Explained

-
-
Assets Cache (newsroom-assets-v1)
-
Stores JS files, CSS files, fonts, and images. Uses "cache-first" strategy for fast loading.
- -
Static Cache (newsroom-static-v1)
-
Stores static pages like About, Roadmap, etc. Uses "network-first" strategy for fresh content.
- -
Runtime Cache (newsroom-runtime-v1)
-
Stores API responses and dynamic content with short expiration times.
-
-
- -
-

Asset Caching Details

-

The following asset types are automatically cached:

- -
- -
-

Performance Benefits

- -
-
+
+
+

Cache Management

+ +
+

Caching Strategy Overview

+

Your newsroom application uses a multi-layered caching strategy:

+
    +
  • Service Worker Cache - Caches JS, CSS, fonts, and images for offline access
  • +
  • Browser Cache - HTTP cache headers for optimal browser caching
  • +
  • Asset Versioning - Content-hashed filenames for cache busting
  • +
+
+ +
+ Loading cache status... +
+ +
+ + + + + + + + + + + +
+ +
+

Cache Types Explained

+
+
Assets Cache (newsroom-assets-v1)
+
Stores JS files, CSS files, fonts, and images. Uses "cache-first" strategy for fast loading.
+ +
Static Cache (newsroom-static-v1)
+
Stores static pages like About, Roadmap, etc. Uses "network-first" strategy for fresh content.
+ +
Runtime Cache (newsroom-runtime-v1)
+
Stores API responses and dynamic content with short expiration times.
+
+
+ +
+

Asset Caching Details

+

The following asset types are automatically cached:

+
    +
  • JavaScript files (.js) - Cached for 30 days
  • +
  • CSS files (.css) - Cached for 30 days
  • +
  • Font files (.woff2, .woff, .ttf) - Cached for 1 year
  • +
  • Images (.png, .jpg, .svg, .ico) - Cached for 30 days
  • +
  • Static pages - Cached for 1 day with network-first strategy
  • +
+
+ +
+

Performance Benefits

+
    +
  • Faster page loads after first visit
  • +
  • Reduced bandwidth usage
  • +
  • Better offline experience
  • +
  • Automatic cache invalidation when assets change
  • +
+
+
+
{% endblock %} diff --git a/templates/admin/magazines.html.twig b/templates/admin/magazines.html.twig index a5295ef..5b3a9b6 100644 --- a/templates/admin/magazines.html.twig +++ b/templates/admin/magazines.html.twig @@ -6,7 +6,7 @@ {% if magazines is empty %}

No magazines found.

{% else %} -