Browse Source

fix replacement of categories

imwald
Silberengel 4 days ago
parent
commit
7e6f172731
  1. 10
      assets/styles/article.css
  2. 2
      src/Command/PrewarmCommand.php
  3. 4
      src/Dto/NostrShareMenuContext.php
  4. 5
      src/Repository/ArticleRepository.php
  5. 4
      src/Service/MagazineContentService.php
  6. 18
      src/Service/MagazineRefresher.php
  7. 46
      src/Service/NostrClient.php
  8. 69
      src/Service/NostrShareMenuBuilder.php
  9. 5
      templates/components/Molecules/Card.html.twig
  10. 3
      templates/components/Molecules/NostrShareMenu.html.twig
  11. 2
      templates/components/Organisms/CardList.html.twig
  12. 7
      templates/pages/category.html.twig

10
assets/styles/article.css

@ -40,6 +40,10 @@ @@ -40,6 +40,10 @@
align-items: flex-start;
gap: 0.75rem;
flex-wrap: wrap;
/* .card-header { overflow: hidden } would clip the ⋯ dropdown; following siblings can paint on top. */
overflow: visible;
position: relative;
z-index: 5;
}
.card-header--article .card-title {
@ -48,6 +52,12 @@ @@ -48,6 +52,12 @@
margin: 0;
}
/* Sibling .category-body would paint over the ⋯ popover; lift the title card above the list. */
.category-page__header-card {
position: relative;
z-index: 6;
}
.card.comment .metadata.comment-card__head {
align-items: flex-start;
}

2
src/Command/PrewarmCommand.php

@ -65,7 +65,7 @@ final class PrewarmCommand extends Command @@ -65,7 +65,7 @@ final class PrewarmCommand extends Command
->addOption('deletion-since', null, InputOption::VALUE_REQUIRED, 'strtotime() window start for kind 5 fetch', '-2 month')
->addOption('no-metadata', null, InputOption::VALUE_NONE, 'Skip Nostr profile metadata cache')
->addOption('no-comments', null, InputOption::VALUE_NONE, 'Skip comment thread cache')
->addOption('magazine-budget', null, InputOption::VALUE_REQUIRED, 'Seconds wall time for magazine relay refresh (capped at 600s; if many category indices, raise this or set MAGAZINE_PREWARM_PREFER_SLUGS for hot slugs first)', '90')
->addOption('magazine-budget', null, InputOption::VALUE_REQUIRED, 'Seconds wall time for the category 30040 phase only (root fetch is not counted; capped at 600s). If many slugs, raise this or set MAGAZINE_PREWARM_PREFER_SLUGS', '90')
->addOption('metadata-limit', null, InputOption::VALUE_REQUIRED, 'Max distinct author pubkeys to warm (0 = all)', '0')
->addOption('metadata-batch', null, InputOption::VALUE_REQUIRED, 'Kind-0 metadata: pubkeys per Nostr REQ (batched)', '50')
->addOption('comments-max', null, InputOption::VALUE_REQUIRED, 'Newest N magazine category articles to warm comment cache for (0 = all, order: createdAt DESC; excludes generic /articles feed-only rows)', '10')

4
src/Dto/NostrShareMenuContext.php

@ -5,8 +5,8 @@ declare(strict_types=1); @@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Dto;
/**
* Nostr "⋯" share menu: copy npub; copy nevent or naddr (Jumble uses the same bech in /feed/notes/…).
* Addressable (NIP-33) long-form / index events: prefer naddr; one-off stateless events: nevent.
* Nostr "⋯" share menu: copy npub; copy naddr and/or nevent (Jumble /feed/notes/… uses the naddr when present, else nevent).
* For NIP-33 replaceable events, both can be set: naddr is the coordinate, nevent is the specific revision.
*/
final class NostrShareMenuContext
{

5
src/Repository/ArticleRepository.php

@ -110,11 +110,12 @@ class ArticleRepository extends ServiceEntityRepository @@ -110,11 +110,12 @@ class ArticleRepository extends ServiceEntityRepository
$qb = $this->createQueryBuilder('a');
$orX = $qb->expr()->orX();
foreach ($pairs as $i => $p) {
$pkQ = strtolower((string) $p['pubkey']);
$orX->add($qb->expr()->andX(
$qb->expr()->eq('a.pubkey', ':pk'.$i),
$qb->expr()->eq('a.slug', ':sl'.$i)
));
$qb->setParameter('pk'.$i, $p['pubkey']);
$qb->setParameter('pk'.$i, $pkQ);
$qb->setParameter('sl'.$i, $p['slug']);
}
$qb->where($orX);
@ -123,7 +124,7 @@ class ArticleRepository extends ServiceEntityRepository @@ -123,7 +124,7 @@ class ArticleRepository extends ServiceEntityRepository
$rows = $qb->getQuery()->getResult();
$out = [];
foreach ($rows as $a) {
$pk = (string) $a->getPubkey();
$pk = strtolower((string) $a->getPubkey());
$sl = trim((string) $a->getSlug());
if ($sl !== '') {
$out[$pk."\0".$sl] = $a;

4
src/Service/MagazineContentService.php

@ -236,7 +236,7 @@ final class MagazineContentService @@ -236,7 +236,7 @@ final class MagazineContentService
continue;
}
$pairs[] = [
'pubkey' => (string) $parts[1],
'pubkey' => strtolower((string) $parts[1]),
'slug' => $slugPart,
];
}
@ -246,7 +246,7 @@ final class MagazineContentService @@ -246,7 +246,7 @@ final class MagazineContentService
if (\count($parts) < 3) {
continue;
}
$k = (string) $parts[1]."\0".trim((string) $parts[2]);
$k = strtolower((string) $parts[1])."\0".trim((string) $parts[2]);
if (isset($byAddress[$k])) {
$list[] = $byAddress[$k];
}

18
src/Service/MagazineRefresher.php

@ -39,8 +39,13 @@ final class MagazineRefresher @@ -39,8 +39,13 @@ final class MagazineRefresher
}
/**
* 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.
* Fetches the root 30040, then each category 30040. The soft wall-time budget applies to the
* **category phase only** (after the root is stored). The root fetch is not counted against that
* window—otherwise a slow root can consume the entire default budget and no category would be
* refreshed (stale per-category cache while the root looks current).
*
* $preferSlugs are requested first (e.g. current /cat route) so they are less likely to miss
* the category budget if the slug list is long.
*
* @param (callable(string, array<string, int|string|bool|null>): void)|null $onProgress
* Phases: `before_root`, `after_root` (total_steps, step, slug_count, slugs: list<string>),
@ -50,15 +55,12 @@ final class MagazineRefresher @@ -50,15 +55,12 @@ final class MagazineRefresher
{
// Allow large budgets (PrewarmCommand --magazine-budget). Hard cap only to avoid runaway PHP time.
$budgetSeconds = max(1, min(600, $budgetSeconds));
$deadline = microtime(true) + $budgetSeconds;
$npub = (string) $this->params->get('npub');
$dTag = (string) $this->params->get('d_tag');
$preferFromEnv = $this->parseCommaSeparatedSlugs($this->magazinePrewarmPreferSlugs);
// 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);
// Allow enough PHP wall time for a slow root fetch plus the full category-phase budget.
$this->applyExecutionTimeCap(2 * $budgetSeconds);
$defaultRelay = (string) $this->params->get('default_relay');
$relayLabel = (string) (parse_url($defaultRelay, \PHP_URL_HOST) ?: $defaultRelay);
@ -86,6 +88,8 @@ final class MagazineRefresher @@ -86,6 +88,8 @@ final class MagazineRefresher
$this->store->putRoot($npub, $dTag, $root);
$deadline = microtime(true) + $budgetSeconds;
$mergedPrefer = $this->mergePreferSlugsInOrder($preferSlugs, $preferFromEnv);
$alsoFromEnv = $this->parseCommaSeparatedSlugs($this->magazinePrewarmAlsoSlugs);
if ($alsoFromEnv !== []) {

46
src/Service/NostrClient.php

@ -2441,8 +2441,13 @@ class NostrClient @@ -2441,8 +2441,13 @@ class NostrClient
}
/**
* NIP-33: among relay results for a single (kind, author, d) filter, keep the live revision per
* {@see wireEventSupersedes}.
* NIP-33: from merged relay results, the event at the replaceable address kind:pubkeyLower:d.
* Uses {@see mergeNip33ParameterizedWireEvents} so every relay’s copies collapse to the live
* revision the same way everywhere; then we match the requested address only.
*
* (Older logic reimplemented “merge” by hand and had a fallback that could return a **different**
* 30040 (wrong #d) when the expected address key did not line up, which surfaced as “stale”
* category indices even when a newer note existed on a relay such as TheForest.)
*
* @param list<mixed> $events
*/
@ -2457,42 +2462,25 @@ class NostrClient @@ -2457,42 +2462,25 @@ class NostrClient
}
$wantD = trim($dTag);
$expectedAddr = (string) $expectedKind.':'.$authorHexLower.':'.$wantD;
$byAddress = [];
foreach ($events as $e) {
$addr = self::nip33ParameterizedReplaceableAddress($e);
if ($addr === null) {
continue;
}
if (strtolower(self::magazineEventPubkeyHex($e)) !== $authorHexLower) {
continue;
}
if (self::eventDTagValue($e) !== $wantD) {
$merged = self::mergeNip33ParameterizedWireEvents($events);
foreach ($merged as $e) {
if (!\is_object($e)) {
continue;
}
if (self::magazineEventKind($e) !== $expectedKind) {
continue;
}
if (!isset($byAddress[$addr]) || self::wireEventSupersedes($e, $byAddress[$addr])) {
$byAddress[$addr] = $e;
if (strtolower(self::magazineEventPubkeyHex($e)) !== $authorHexLower) {
continue;
}
}
if ($byAddress === []) {
return null;
}
if (isset($byAddress[$expectedAddr])) {
return $byAddress[$expectedAddr];
}
if (\count($byAddress) === 1) {
return $byAddress[array_key_first($byAddress)];
}
$best = null;
foreach ($byAddress as $e) {
if ($best === null || self::wireEventSupersedes($e, $best)) {
$best = $e;
$addr = self::nip33ParameterizedReplaceableAddress($e);
if ($addr === $expectedAddr) {
return $e;
}
}
return $best;
return null;
}
/**

69
src/Service/NostrShareMenuBuilder.php

@ -38,17 +38,25 @@ final class NostrShareMenuBuilder @@ -38,17 +38,25 @@ final class NostrShareMenuBuilder
$npub = $key->convertPublicKeyToBech32($pubkeyHex);
$kind = (int) ($event->kind ?? 0);
$d = self::dTagFromWireEvent($event);
$eventIdHex = strtolower((string) ($event->id ?? ''));
if (Nip19Addressable::isParameterizedReplaceableKind($kind) && $d !== null) {
$naddr = Nip19Addressable::naddrBech32($kind, $pubkeyHex, $d, $relayHints);
$neventForRev = (64 === \strlen($eventIdHex) && ctype_xdigit($eventIdHex))
? (string) Bech32::nevent(
id: $eventIdHex,
relays: $relayHints,
author: $pubkeyHex,
kind: $kind,
)
: null;
return new NostrShareMenuContext(
$npub,
null,
$neventForRev,
$naddr,
$this->feedJumble($naddr),
);
}
$eventIdHex = strtolower((string) ($event->id ?? ''));
if (64 === \strlen($eventIdHex) && ctype_xdigit($eventIdHex)) {
$rebuilt = (string) Bech32::nevent(
id: $eventIdHex,
@ -87,8 +95,6 @@ final class NostrShareMenuBuilder @@ -87,8 +95,6 @@ final class NostrShareMenuBuilder
$request->attributes->set(self::ATTR_NPUB, $ctx->npub);
if ($ctx->naddrBech32 !== null && $ctx->naddrBech32 !== '') {
$request->attributes->set(self::ATTR_NADDR_BECH32, $ctx->naddrBech32);
return;
}
if ($ctx->neventBech32 !== null && $ctx->neventBech32 !== '') {
$request->attributes->set(self::ATTR_NEVENT_BECH32, $ctx->neventBech32);
@ -205,10 +211,19 @@ final class NostrShareMenuBuilder @@ -205,10 +211,19 @@ final class NostrShareMenuBuilder
}
$pk = strtolower((string) $article->getPubkey());
$naddr = Nip19Addressable::naddrBech32($kind, $pk, $d, []);
$eid = strtolower((string) ($article->getEventId() ?? ''));
$nevent = (64 === \strlen($eid) && ctype_xdigit($eid))
? (string) Bech32::nevent(
id: $eid,
relays: [],
author: $pk,
kind: $kind,
)
: null;
return new NostrShareMenuContext(
$npub,
null,
$nevent,
$naddr,
$this->feedJumble($naddr),
);
@ -231,27 +246,23 @@ final class NostrShareMenuBuilder @@ -231,27 +246,23 @@ final class NostrShareMenuBuilder
private function forNevent(Request $request, string $neventFromRoute): NostrShareMenuContext
{
if ($request->attributes->has(self::ATTR_NPUB) && $request->attributes->has(self::ATTR_NADDR_BECH32)) {
$naddr = (string) $request->attributes->get(self::ATTR_NADDR_BECH32);
$np = (string) $request->attributes->get(self::ATTR_NPUB);
return new NostrShareMenuContext(
$np,
null,
$naddr,
$this->feedJumble($naddr),
);
}
if ($request->attributes->has(self::ATTR_NPUB) && $request->attributes->has(self::ATTR_NEVENT_BECH32)) {
$nb = (string) $request->attributes->get(self::ATTR_NEVENT_BECH32);
if ($request->attributes->has(self::ATTR_NPUB)
&& ($request->attributes->has(self::ATTR_NADDR_BECH32) || $request->attributes->has(self::ATTR_NEVENT_BECH32))) {
$np = (string) $request->attributes->get(self::ATTR_NPUB);
return new NostrShareMenuContext(
$np,
$nb,
null,
$this->feedJumble($nb),
);
$naddrRaw = $request->attributes->get(self::ATTR_NADDR_BECH32);
$naddr = \is_string($naddrRaw) && $naddrRaw !== '' ? $naddrRaw : null;
$neventRaw = $request->attributes->get(self::ATTR_NEVENT_BECH32);
$nb = \is_string($neventRaw) && $neventRaw !== '' ? $neventRaw : null;
if (null !== $naddr || null !== $nb) {
$jumble = $this->feedJumble($naddr ?? $nb);
return new NostrShareMenuContext(
$np,
$nb,
$naddr,
$jumble,
);
}
}
$nevent = $neventFromRoute;
@ -331,10 +342,16 @@ final class NostrShareMenuBuilder @@ -331,10 +342,16 @@ final class NostrShareMenuBuilder
$npub = $this->nostrKey()->convertPublicKeyToBech32($pk);
if (Nip19Addressable::isParameterizedReplaceableKind($kind) && $d !== null) {
$naddr = Nip19Addressable::naddrBech32($kind, $pk, $d, []);
$neventForRev = (string) Bech32::nevent(
id: $id,
relays: [],
author: $pk,
kind: $kind,
);
return new NostrShareMenuContext(
$npub,
null,
$neventForRev,
$naddr,
$this->feedJumble($naddr),
);

5
templates/components/Molecules/Card.html.twig

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
{% if article is defined %}
{% set card_title = article.title|default('')|trim %}
<div class="card">
<div class="metadata">
{% if category %}
@ -14,11 +15,11 @@ @@ -14,11 +15,11 @@
<div class="card-header">
{% if category %}<small class="text-uppercase">{{ category }}</small>{% endif %}
{% if article.image %}
<img src="{{ article.image }}" alt="Cover image for {{ article.title }}" onerror="this.style.display='none';" >
<img src="{{ article.image }}" alt="Cover image for {{ card_title != '' ? card_title : (article.slug|default('')) }}" onerror="this.style.display='none';" >
{% endif %}
</div>
<div class="card-body">
<h2 class="card-title">{{ article.title }}</h2>
<h2 class="card-title">{% if card_title != '' %}{{ card_title }}{% else %}{{ article.slug|default('')|replace({'-': ' '})|title }}{% endif %}</h2>
{% if article.summary %}
<p class="lede">
{{ article.summary }}

3
templates/components/Molecules/NostrShareMenu.html.twig

@ -24,7 +24,8 @@ @@ -24,7 +24,8 @@
data-copy-text-text-value="{{ share.naddrBech32|e('html_attr') }}">
<button type="button" class="nostr-share-menu__action" data-action="click->copy-text#copy" data-copy-text-target="button" role="menuitem">Copy naddr</button>
</li>
{% elseif share.neventBech32 is not null and share.neventBech32 is not same as('') %}
{% endif %}
{% if share.neventBech32 is not null and share.neventBech32 is not same as('') %}
<li class="nostr-share-menu__item" role="none"
data-controller="copy-text"
data-copy-text-text-value="{{ share.neventBech32|e('html_attr') }}">

2
templates/components/Organisms/CardList.html.twig

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
<div {{ attributes }}>
{% set is_author_profile = is_author_profile|default(false) %}
{% for item in list %}
{% if item.slug is not empty and item.title is not empty %}
{% if item.slug is not empty %}
<twig:Molecules:Card :article="item" :is_author_profile="is_author_profile"></twig:Molecules:Card>
{% endif %}
{% endfor %}

7
templates/pages/category.html.twig

@ -29,6 +29,13 @@ @@ -29,6 +29,13 @@
{% endblock %}
{% block body %}
<div class="card category-page__header-card">
<div class="card-header card-header--article">
<h1 class="card-title">{{ (category.title|default('')|trim) != '' ? category.title : 'Category' }}</h1>
{% set _cat_share = nostr_share_menu() %}
{% if _cat_share %}{% include 'components/Molecules/NostrShareMenu.html.twig' with { share: _cat_share, event_menu: true } only %}{% endif %}
</div>
</div>
<div class="category-body">
<twig:Organisms:CardList :list="list" class="article-list" />
</div>

Loading…
Cancel
Save