27 changed files with 767 additions and 277 deletions
@ -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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<div class="category-body" data-magazine-sync-target="pageBody"> |
||||||
|
<twig:Organisms:CardList :list="list" class="article-list" /> |
||||||
|
</div> |
||||||
@ -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