27 changed files with 767 additions and 277 deletions
@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
|
||||
import { Controller } from "@hotwired/stimulus"; |
||||
|
||||
/** |
||||
* After first paint, refreshes Nostr magazine indices (server-side, ≤5s) and swaps header/body HTML. |
||||
*/ |
||||
export default class extends Controller { |
||||
static targets = ["headerNav", "pageBody"]; |
||||
static values = { |
||||
page: String, |
||||
slug: String, |
||||
url: String, |
||||
}; |
||||
|
||||
connect() { |
||||
this.sync(); |
||||
} |
||||
|
||||
async sync() { |
||||
const base = this.urlValue || "/ux/magazine-sync"; |
||||
const params = new URLSearchParams(); |
||||
params.set("page", this.pageValue || "article"); |
||||
const slug = this.slugValue || ""; |
||||
if (slug !== "") { |
||||
params.set("slug", slug); |
||||
} |
||||
const url = `${base}?${params.toString()}`; |
||||
try { |
||||
const res = await fetch(url, { |
||||
headers: { Accept: "application/json" }, |
||||
credentials: "same-origin", |
||||
}); |
||||
if (!res.ok) { |
||||
return; |
||||
} |
||||
const data = await res.json(); |
||||
if (!data.ok) { |
||||
return; |
||||
} |
||||
if (this.hasHeaderNavTarget && data.header) { |
||||
this.headerNavTarget.outerHTML = data.header; |
||||
} |
||||
if (this.hasPageBodyTarget && data.body) { |
||||
this.pageBodyTarget.outerHTML = data.body; |
||||
} |
||||
} catch { |
||||
/* ignore network errors */ |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,106 @@
@@ -0,0 +1,106 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Controller; |
||||
|
||||
use App\Service\MagazineContentService; |
||||
use App\Service\MagazineRefresher; |
||||
use Psr\Log\LoggerInterface; |
||||
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; |
||||
use Symfony\Component\HttpFoundation\JsonResponse; |
||||
use Symfony\Component\HttpFoundation\Request; |
||||
use Symfony\Component\HttpFoundation\Response; |
||||
use Symfony\Component\HttpKernel\Attribute\AsController; |
||||
use Symfony\Component\Routing\Attribute\Route; |
||||
use Twig\Environment; |
||||
|
||||
/** Stale-first: the main request only reads {@see \App\Service\MagazineIndexStore}; this refetches Nostr, updates that store, and returns HTML fragments for Stimulus to patch the document. */ |
||||
#[AsController] |
||||
final class MagazineSyncController |
||||
{ |
||||
public function __construct( |
||||
private readonly Environment $twig, |
||||
private readonly MagazineRefresher $refresher, |
||||
private readonly MagazineContentService $magazineContent, |
||||
private readonly ParameterBagInterface $params, |
||||
private readonly LoggerInterface $logger, |
||||
) { |
||||
} |
||||
|
||||
#[Route('/ux/magazine-sync', name: 'ux_magazine_sync', methods: ['GET'])] |
||||
public function __invoke(Request $request): JsonResponse |
||||
{ |
||||
@set_time_limit(8); |
||||
@ini_set('max_execution_time', '8'); |
||||
|
||||
try { |
||||
$page = (string) $request->query->get('page', 'article'); |
||||
if (!\in_array($page, ['home', 'category', 'article', 'articles'], true)) { |
||||
$page = 'article'; |
||||
} |
||||
$slug = (string) $request->query->get('slug', ''); |
||||
|
||||
$prefer = $slug !== '' ? [$slug] : []; |
||||
|
||||
try { |
||||
$this->refresher->refreshFromRelays(8, $prefer); |
||||
} catch (\Throwable $e) { |
||||
$this->logger->warning('MagazineSyncController: refresh failed', [ |
||||
'message' => $e->getMessage(), |
||||
'exception' => $e, |
||||
]); |
||||
|
||||
return new JsonResponse( |
||||
['ok' => false, 'error' => 'refresh_failed', 'message' => $e->getMessage()], |
||||
Response::HTTP_OK |
||||
); |
||||
} |
||||
|
||||
$community = (bool) $this->params->get('community_articles'); |
||||
$tags = $this->magazineContent->getHomeCategoryAIndexTagsFromStoreOnly(); |
||||
$globals = [ |
||||
'magazine_community_articles' => $community, |
||||
]; |
||||
|
||||
$header = $this->twig->render('ux/magazine/header_ul.html.twig', array_merge($globals, [ |
||||
'cats' => $tags, |
||||
])); |
||||
|
||||
$body = null; |
||||
if ($page === 'home') { |
||||
$body = $this->twig->render('ux/magazine/home_body.html.twig', array_merge($globals, [ |
||||
'indices' => $tags, |
||||
])); |
||||
} elseif ($page === 'category' && $slug !== '') { |
||||
$data = $this->magazineContent->getCategoryPageData($slug); |
||||
$body = $this->twig->render('ux/magazine/category_body.html.twig', array_merge($globals, [ |
||||
'list' => $data['list'], |
||||
'category' => $data['category'], |
||||
])); |
||||
} elseif ($page === 'articles') { |
||||
$body = null; |
||||
} |
||||
|
||||
return new JsonResponse([ |
||||
'ok' => true, |
||||
'header' => $header, |
||||
'body' => $body, |
||||
]); |
||||
} catch (\Throwable $e) { |
||||
$this->logger->error('MagazineSyncController: unexpected failure', [ |
||||
'message' => $e->getMessage(), |
||||
'exception' => $e, |
||||
]); |
||||
|
||||
return new JsonResponse( |
||||
[ |
||||
'ok' => false, |
||||
'error' => 'server_error', |
||||
'message' => 'Magazine UI sync could not be rendered.', |
||||
], |
||||
Response::HTTP_OK |
||||
); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,163 @@
@@ -0,0 +1,163 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Service; |
||||
|
||||
use App\Entity\Article; |
||||
use App\Entity\Event; |
||||
use App\Repository\ArticleRepository; |
||||
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; |
||||
|
||||
/** |
||||
* Magazine index events for templates. Reads {@see MagazineIndexStore} first; on a cold cache or when |
||||
* the last successful relay sync is older than {@see self::ROOT_REVALIDATE_SECONDS}, the service |
||||
* calls {@see MagazineRefresher} so the root index (and nav) can pick up new categories. |
||||
*/ |
||||
final class MagazineContentService |
||||
{ |
||||
/** Re-fetch root from relays at most this often so new `a` tags appear in the header. */ |
||||
private const ROOT_REVALIDATE_SECONDS = 300; |
||||
|
||||
public function __construct( |
||||
private readonly MagazineIndexStore $store, |
||||
private readonly MagazineRefresher $refresher, |
||||
private readonly ParameterBagInterface $params, |
||||
private readonly ArticleRepository $articleRepository, |
||||
) { |
||||
} |
||||
|
||||
/** |
||||
* "indices" for the home template: Nostr `a` tag rows for each category. |
||||
* |
||||
* @return list<array<int, string>> |
||||
*/ |
||||
public function getHomeCategoryIndexTags(): array |
||||
{ |
||||
$npub = (string) $this->params->get('npub'); |
||||
$dTag = (string) $this->params->get('d_tag'); |
||||
if ($this->store->getRoot($npub, $dTag) === null) { |
||||
$this->refresher->refreshFromRelays(8, []); |
||||
} elseif ($this->shouldRevalidateRootFromRelay()) { |
||||
$this->refresher->refreshFromRelays(8, []); |
||||
} |
||||
|
||||
return $this->getHomeCategoryAIndexTagsFromStoreOnly(); |
||||
} |
||||
|
||||
/** |
||||
* Category `a` tags from the persisted root only (no relay). Used after /ux/magazine-sync |
||||
* has already called {@see MagazineRefresher::refreshFromRelays}. |
||||
* |
||||
* @return list<array<int, string>> |
||||
*/ |
||||
public function getHomeCategoryAIndexTagsFromStoreOnly(): array |
||||
{ |
||||
return $this->categoryATagsFromStoredRoot(); |
||||
} |
||||
|
||||
/** |
||||
* @return list<array<int, string>> |
||||
*/ |
||||
private function categoryATagsFromStoredRoot(): array |
||||
{ |
||||
$npub = (string) $this->params->get('npub'); |
||||
$dTag = (string) $this->params->get('d_tag'); |
||||
$mag = $this->store->getRoot($npub, $dTag); |
||||
|
||||
return $this->categoryATagsFromMag($mag); |
||||
} |
||||
|
||||
/** |
||||
* @return list<array<int, string>> |
||||
*/ |
||||
private function categoryATagsFromMag(?Event $mag): array |
||||
{ |
||||
if ($mag === null) { |
||||
return []; |
||||
} |
||||
$tags = $mag->getTags(); |
||||
$cats = array_filter($tags, static function (mixed $tag): bool { |
||||
return \is_array($tag) && ($tag[0] ?? null) === 'a'; |
||||
}); |
||||
|
||||
return array_values($cats); |
||||
} |
||||
|
||||
private function shouldRevalidateRootFromRelay(): bool |
||||
{ |
||||
$age = $this->refresher->getSecondsSinceLastRelayRun(); |
||||
if ($age === null) { |
||||
return true; |
||||
} |
||||
|
||||
return $age > self::ROOT_REVALIDATE_SECONDS; |
||||
} |
||||
|
||||
/** |
||||
* @return array{list: list<Article>, category: array{title: string, summary: string}} |
||||
*/ |
||||
public function getCategoryPageData(string $slug): array |
||||
{ |
||||
$catIndex = $this->store->getCategory($slug); |
||||
if ($catIndex === null) { |
||||
$this->refresher->refreshFromRelays(8, [$slug]); |
||||
$catIndex = $this->store->getCategory($slug); |
||||
} |
||||
$list = []; |
||||
$coordinates = []; |
||||
$category = []; |
||||
if ($catIndex) { |
||||
foreach ($catIndex->getTags() as $tag) { |
||||
if ($tag[0] === 'title') { |
||||
$category['title'] = (string) $tag[1]; |
||||
} |
||||
if ($tag[0] === 'summary') { |
||||
$category['summary'] = (string) $tag[1]; |
||||
} |
||||
if ($tag[0] === 'a') { |
||||
$coordinates[] = $tag[1]; |
||||
} |
||||
} |
||||
} |
||||
|
||||
if (!empty($coordinates)) { |
||||
$slugs = array_map(static function ($coordinate) { |
||||
$parts = explode(':', (string) $coordinate, 3); |
||||
|
||||
return trim((string) end($parts)); |
||||
}, $coordinates); |
||||
$slugs = array_values(array_filter($slugs, static fn (string $s): bool => $s !== '')); |
||||
$articles = $this->articleRepository->findBySlugsCriteria($slugs); |
||||
$slugMap = []; |
||||
foreach ($articles as $item) { |
||||
$s = trim((string) $item->getSlug()); |
||||
if ($s !== '') { |
||||
if (!isset($slugMap[$s])) { |
||||
$slugMap[$s] = $item; |
||||
} else { |
||||
$existingItem = $slugMap[$s]; |
||||
if ($item->getCreatedAt() > $existingItem->getCreatedAt()) { |
||||
$slugMap[$s] = $item; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
foreach ($coordinates as $coordinate) { |
||||
$parts = explode(':', (string) $coordinate, 3); |
||||
$slugKey = trim((string) end($parts)); |
||||
if ($slugKey !== '' && isset($slugMap[$slugKey])) { |
||||
$list[] = $slugMap[$slugKey]; |
||||
} |
||||
} |
||||
} |
||||
|
||||
$category['title'] = $category['title'] ?? ''; |
||||
$category['summary'] = $category['summary'] ?? ''; |
||||
|
||||
return [ |
||||
'list' => $list, |
||||
'category' => $category, |
||||
]; |
||||
} |
||||
} |
||||
@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Service; |
||||
|
||||
use App\Entity\Event; |
||||
use Psr\Cache\CacheItemPoolInterface; |
||||
use Psr\Cache\InvalidArgumentException; |
||||
|
||||
/** |
||||
* Read/write persisted magazine Nostr index events (kinds 30040) without callback-based relay I/O |
||||
* on the request path. Updated by {@see MagazineRefresher} or the /ux/magazine-sync action. |
||||
*/ |
||||
final class MagazineIndexStore |
||||
{ |
||||
private const ROOT_PREFIX = 'mroot_v1_'; |
||||
private const CAT_PREFIX = 'mcat_v1_'; |
||||
|
||||
/** 30 days — we refresh on page load, TTL is a safety cap if sync stops working. */ |
||||
private const PERSIST_TTL = 2_592_000; |
||||
|
||||
public function __construct( |
||||
private readonly CacheItemPoolInterface $pool, |
||||
) { |
||||
} |
||||
|
||||
public function getRoot(string $npub, string $dTag): ?Event |
||||
{ |
||||
$item = $this->pool->getItem($this->rootKey($npub, $dTag)); |
||||
if (!$item->isHit()) { |
||||
return null; |
||||
} |
||||
|
||||
return $this->unwrap($item->get()); |
||||
} |
||||
|
||||
public function getCategory(string $slug): ?Event |
||||
{ |
||||
if ($slug === '') { |
||||
return null; |
||||
} |
||||
$item = $this->pool->getItem(self::CAT_PREFIX.$slug); |
||||
if (!$item->isHit()) { |
||||
return null; |
||||
} |
||||
|
||||
return $this->unwrap($item->get()); |
||||
} |
||||
|
||||
/** |
||||
* @throws InvalidArgumentException |
||||
*/ |
||||
public function putRoot(string $npub, string $dTag, Event $event): void |
||||
{ |
||||
$item = $this->pool->getItem($this->rootKey($npub, $dTag)); |
||||
$item->set(serialize($event)); |
||||
$item->expiresAfter(self::PERSIST_TTL); |
||||
$this->pool->save($item); |
||||
} |
||||
|
||||
/** |
||||
* @throws InvalidArgumentException |
||||
*/ |
||||
public function putCategory(string $slug, Event $event): void |
||||
{ |
||||
if ($slug === '') { |
||||
return; |
||||
} |
||||
$item = $this->pool->getItem(self::CAT_PREFIX.$slug); |
||||
$item->set(serialize($event)); |
||||
$item->expiresAfter(self::PERSIST_TTL); |
||||
$this->pool->save($item); |
||||
} |
||||
|
||||
private function rootKey(string $npub, string $dTag): string |
||||
{ |
||||
return self::ROOT_PREFIX.hash('sha256', $npub."\0".$dTag); |
||||
} |
||||
|
||||
private function unwrap(mixed $value): ?Event |
||||
{ |
||||
if (!\is_string($value) || $value === '') { |
||||
return null; |
||||
} |
||||
$e = unserialize($value, ['allowed_classes' => [Event::class]]); |
||||
if (!$e instanceof Event) { |
||||
return null; |
||||
} |
||||
|
||||
return $e; |
||||
} |
||||
} |
||||
@ -0,0 +1,165 @@
@@ -0,0 +1,165 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Service; |
||||
|
||||
use App\Entity\Event; |
||||
use Psr\Cache\CacheItemPoolInterface; |
||||
use Psr\Cache\InvalidArgumentException; |
||||
use Psr\Log\LoggerInterface; |
||||
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; |
||||
|
||||
/** |
||||
* Pulls magazine indices from relays within a wall-clock budget and persists them to {@see MagazineIndexStore}. |
||||
*/ |
||||
final class MagazineRefresher |
||||
{ |
||||
private const RELAY_STAMP_KEY = 'mag_relay_v1'; |
||||
|
||||
public function __construct( |
||||
private readonly NostrClient $nostrClient, |
||||
private readonly MagazineIndexStore $store, |
||||
private readonly ParameterBagInterface $params, |
||||
private readonly LoggerInterface $logger, |
||||
private readonly CacheItemPoolInterface $appCache, |
||||
) { |
||||
} |
||||
|
||||
/** |
||||
* Fetches the root index then each category index until $budgetSeconds elapses. $preferSlugs |
||||
* are requested first (e.g. current /cat route) so they are less likely to miss the budget. |
||||
*/ |
||||
public function refreshFromRelays(int $budgetSeconds = 8, array $preferSlugs = []): void |
||||
{ |
||||
$budgetSeconds = max(1, min(30, $budgetSeconds)); |
||||
$deadline = microtime(true) + $budgetSeconds; |
||||
$npub = (string) $this->params->get('npub'); |
||||
$dTag = (string) $this->params->get('d_tag'); |
||||
|
||||
// Do not set max_execution_time to the *remaining* soft budget: PHP resets the timer, so |
||||
// after a 6s root fetch, "2s left" would become a 2s hard cap for the *next* relay I/O |
||||
// (e.g. slow TLS) and can fatal. Cap once with headroom; the $deadline loop limits work. |
||||
$this->applyExecutionTimeCap($budgetSeconds); |
||||
|
||||
$root = $this->nostrClient->getMagazineIndex($npub, $dTag); |
||||
if ($root === null) { |
||||
$this->logger->warning('MagazineRefresher: root index not returned from relay', [ |
||||
'd_tag' => $dTag, |
||||
]); |
||||
|
||||
return; |
||||
} |
||||
|
||||
$this->store->putRoot($npub, $dTag, $root); |
||||
|
||||
$slugs = $this->orderedCategorySlugs($this->categorySlugsFromRoot($root), $preferSlugs); |
||||
foreach ($slugs as $slug) { |
||||
if (microtime(true) >= $deadline) { |
||||
$this->logger->notice('MagazineRefresher: stopped at time budget; some categories not fetched', [ |
||||
'unprocessed_from' => $slug, |
||||
]); |
||||
break; |
||||
} |
||||
try { |
||||
$cat = $this->nostrClient->getMagazineIndex($npub, $slug); |
||||
if ($cat !== null) { |
||||
$this->store->putCategory($slug, $cat); |
||||
} |
||||
} catch (\Throwable $e) { |
||||
$this->logger->error('MagazineRefresher: category fetch failed', [ |
||||
'slug' => $slug, |
||||
'message' => $e->getMessage(), |
||||
]); |
||||
} |
||||
} |
||||
|
||||
$this->touchLastRelayTime(); |
||||
} |
||||
|
||||
/** |
||||
* @throws InvalidArgumentException |
||||
*/ |
||||
public function getSecondsSinceLastRelayRun(): ?int |
||||
{ |
||||
try { |
||||
$item = $this->appCache->getItem(self::RELAY_STAMP_KEY); |
||||
} catch (InvalidArgumentException) { |
||||
return null; |
||||
} |
||||
if (!$item->isHit()) { |
||||
return null; |
||||
} |
||||
|
||||
return time() - (int) $item->get(); |
||||
} |
||||
|
||||
/** |
||||
* Child category indices are kind 30040; each root "a" tag is a NIP-33 address |
||||
* kind:hexpubkey:d-identifier. The third segment is the child #d (e.g. the long |
||||
* newsroom-…-category-… string), not a shortened title. |
||||
* |
||||
* @return list<string> |
||||
*/ |
||||
private function categorySlugsFromRoot(Event $root): array |
||||
{ |
||||
$slugs = []; |
||||
foreach ($root->getTags() as $tag) { |
||||
if (($tag[0] ?? null) !== 'a' || !isset($tag[1])) { |
||||
continue; |
||||
} |
||||
$parts = explode(':', (string) $tag[1], 3); |
||||
if (\count($parts) < 3) { |
||||
continue; |
||||
} |
||||
$s = trim((string) end($parts)); |
||||
if ($s !== '' && !\in_array($s, $slugs, true)) { |
||||
$slugs[] = $s; |
||||
} |
||||
} |
||||
|
||||
return $slugs; |
||||
} |
||||
|
||||
/** |
||||
* @param list<string> $allFromRoot |
||||
* @param list<string> $prefer |
||||
* @return list<string> |
||||
*/ |
||||
private function orderedCategorySlugs(array $allFromRoot, array $prefer): array |
||||
{ |
||||
$prefer = array_values(array_filter($prefer, static function (string $s): bool { |
||||
return $s !== ''; |
||||
})); |
||||
$out = $prefer; |
||||
foreach ($allFromRoot as $s) { |
||||
if (!\in_array($s, $out, true)) { |
||||
$out[] = $s; |
||||
} |
||||
} |
||||
|
||||
return $out; |
||||
} |
||||
|
||||
/** |
||||
* @throws InvalidArgumentException |
||||
*/ |
||||
private function touchLastRelayTime(): void |
||||
{ |
||||
$item = $this->appCache->getItem(self::RELAY_STAMP_KEY); |
||||
$item->set((string) time()); |
||||
$item->expiresAfter(86_400); |
||||
$this->appCache->save($item); |
||||
} |
||||
|
||||
/** |
||||
* One generous ceiling for PHP so relay/WebSocket I/O in one Nostr call can outlast the soft |
||||
* $deadline by seconds without a fatal, while the loop still stops *starting* new fetches in time. |
||||
*/ |
||||
private function applyExecutionTimeCap(int $budgetSeconds): void |
||||
{ |
||||
$sec = max(30, min(120, $budgetSeconds + 30)); |
||||
@set_time_limit($sec); |
||||
@ini_set('max_execution_time', (string) $sec); |
||||
} |
||||
} |
||||
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
<div class="category-body" data-magazine-sync-target="pageBody"> |
||||
<twig:Organisms:CardList :list="list" class="article-list" /> |
||||
</div> |
||||
@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
|
||||
<ul data-magazine-sync-target="headerNav"> |
||||
{% for category in cats %} |
||||
<li><twig:Molecules:CategoryLink :category="category" /></li> |
||||
{% endfor %} |
||||
{% if magazine_community_articles %} |
||||
<li> |
||||
<a href="{{ path('articles') }}">Latest Articles</a> |
||||
</li> |
||||
{% endif %} |
||||
</ul> |
||||
Loading…
Reference in new issue