29 changed files with 538 additions and 200 deletions
@ -0,0 +1,22 @@ |
|||||||
|
--- a/src/EventInterface.php 2026-04-27 18:53:54.019977813 +0200
|
||||||
|
+++ b/src/EventInterface.php 2026-04-27 18:53:54.021843273 +0200
|
||||||
|
@@ -109,7 +109,7 @@
|
||||||
|
/**
|
||||||
|
* Set the event tags with values.
|
||||||
|
*
|
||||||
|
- * @param array $tags[]
|
||||||
|
+ * @param array $tags
|
||||||
|
* [
|
||||||
|
* ["e", "..."],
|
||||||
|
* ["p", "...", "..."],
|
||||||
|
--- a/src/RelaySetInterface.php 2026-04-27 18:53:54.021655232 +0200
|
||||||
|
+++ b/src/RelaySetInterface.php 2026-04-27 18:53:54.023843373 +0200
|
||||||
|
@@ -54,7 +54,7 @@
|
||||||
|
/**
|
||||||
|
* Connect to all relays in this set.
|
||||||
|
*
|
||||||
|
- * @param bool $throwOnErrorx
|
||||||
|
+ * @param bool $throwOnError
|
||||||
|
* If true, throw an exception if any relay fails to connect.
|
||||||
|
* If false, return false if any relay fails to connect.
|
||||||
|
* @return bool
|
||||||
@ -0,0 +1,160 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Command; |
||||||
|
|
||||||
|
use App\Entity\ArticleHighlight; |
||||||
|
use App\Repository\ArticleHighlightRepository; |
||||||
|
use App\Repository\ArticleRepository; |
||||||
|
use App\Service\ArticleBodyHighlightInjector; |
||||||
|
use App\Util\CommonMark\Converter; |
||||||
|
use League\CommonMark\Exception\CommonMarkException; |
||||||
|
use swentel\nostr\Key\Key; |
||||||
|
use Symfony\Component\Console\Attribute\AsCommand; |
||||||
|
use Symfony\Component\Console\Command\Command; |
||||||
|
use Symfony\Component\Console\Input\InputArgument; |
||||||
|
use Symfony\Component\Console\Input\InputInterface; |
||||||
|
use Symfony\Component\Console\Input\InputOption; |
||||||
|
use Symfony\Component\Console\Output\OutputInterface; |
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle; |
||||||
|
|
||||||
|
/** |
||||||
|
* Run inside the app container, e.g.: |
||||||
|
* `php bin/console app:article-highlights-audit bitcoin-is-time --npub=npub1…` |
||||||
|
*/ |
||||||
|
#[AsCommand( |
||||||
|
name: 'app:article-highlights-audit', |
||||||
|
description: 'Show how many kind-9802 rows match the article and how many <mark> injections succeed (debugging)', |
||||||
|
)] |
||||||
|
final class ArticleHighlightsAuditCommand extends Command |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
private readonly ArticleRepository $articleRepository, |
||||||
|
private readonly ArticleHighlightRepository $articleHighlightRepository, |
||||||
|
private readonly Converter $converter, |
||||||
|
private readonly ArticleBodyHighlightInjector $articleBodyHighlightInjector, |
||||||
|
) { |
||||||
|
parent::__construct(); |
||||||
|
} |
||||||
|
|
||||||
|
protected function configure(): void |
||||||
|
{ |
||||||
|
$this |
||||||
|
->addArgument('slug', InputArgument::REQUIRED, 'Article d-identifier (slug), e.g. bitcoin-is-time') |
||||||
|
->addOption('npub', null, InputOption::VALUE_OPTIONAL, 'If set, must match the article author (npub1…)'); |
||||||
|
} |
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int |
||||||
|
{ |
||||||
|
$io = new SymfonyStyle($input, $output); |
||||||
|
$slug = trim((string) $input->getArgument('slug')); |
||||||
|
if ($slug === '') { |
||||||
|
$io->error('Empty slug.'); |
||||||
|
|
||||||
|
return Command::FAILURE; |
||||||
|
} |
||||||
|
|
||||||
|
$article = $this->articleRepository->findLatestBySlug($slug); |
||||||
|
if (null === $article) { |
||||||
|
$io->error('No article row for this slug.'); |
||||||
|
|
||||||
|
return Command::FAILURE; |
||||||
|
} |
||||||
|
|
||||||
|
$key = new Key(); |
||||||
|
$expectedNpub = $key->convertPublicKeyToBech32((string) $article->getPubkey()); |
||||||
|
$optNpub = $input->getOption('npub'); |
||||||
|
if (\is_string($optNpub) && $optNpub !== '') { |
||||||
|
if ($key->convertToHex($optNpub) !== strtolower((string) $article->getPubkey())) { |
||||||
|
$io->error('npub does not match this article’s author (expected: '.$expectedNpub.').'); |
||||||
|
|
||||||
|
return Command::FAILURE; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
$io->title('Article highlights audit: '.$slug); |
||||||
|
$io->writeln('Author npub: <info>'.$expectedNpub.'</info>'); |
||||||
|
$io->writeln('Article id: <info>'.(string) $article->getId().'</info> · kind: <info>'. |
||||||
|
($article->getKind()?->value ?? 'null').'</info>'); |
||||||
|
|
||||||
|
$highlights = $this->articleHighlightRepository->findByArticle($article); |
||||||
|
$io->writeln('Rows from <comment>findByArticle</comment>: <info>'.\count($highlights).'</info>'); |
||||||
|
|
||||||
|
try { |
||||||
|
$html = $this->converter->convertToHTML((string) $article->getContent()); |
||||||
|
} catch (CommonMarkException $e) { |
||||||
|
$io->error('CommonMark: '.$e->getMessage()); |
||||||
|
|
||||||
|
return Command::FAILURE; |
||||||
|
} |
||||||
|
|
||||||
|
$out = $this->articleBodyHighlightInjector->inject($html, $highlights); |
||||||
|
$injected = $out['injectedEventIds']; |
||||||
|
$markCount = \substr_count($out['html'], 'user-highlight__marker'); |
||||||
|
$io->writeln('Injected event ids with <comment>all highlights together</comment> (duplicates = same passage): <info>'.\count($injected).'</info>'); |
||||||
|
$io->writeln('<mark class="user-highlight__marker"> count in body: <info>'.$markCount.'</info>'); |
||||||
|
|
||||||
|
$io->section('Each highlight in isolation (same HTML, one 9802 at a time)'); |
||||||
|
$rows = []; |
||||||
|
$isolatedOk = 0; |
||||||
|
foreach ($highlights as $h) { |
||||||
|
if (! $h instanceof ArticleHighlight) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$eid = \strtolower($h->getEventId()); |
||||||
|
$one = $this->articleBodyHighlightInjector->inject($html, [$h]); |
||||||
|
$found = 1 === \preg_match( |
||||||
|
'/\bid=([\'"])highlight-'.preg_quote($eid, '/').'\1/i', |
||||||
|
$one['html'] |
||||||
|
); |
||||||
|
if ($found) { |
||||||
|
++$isolatedOk; |
||||||
|
} |
||||||
|
$snippet = $this->excerptOneLine((string) $h->getContent(), 72); |
||||||
|
$rows[] = [ |
||||||
|
$found ? 'yes' : 'no', |
||||||
|
$eid, |
||||||
|
$snippet, |
||||||
|
]; |
||||||
|
} |
||||||
|
$io->table(['Match', 'event id', 'stored `content` (excerpt)'], $rows); |
||||||
|
if ($isolatedOk < \count($highlights)) { |
||||||
|
$io->writeln( |
||||||
|
'‘Match: no’ means the stored passage is absent from the flattened body text, or it diverges '. |
||||||
|
'(soft hyphens, smart quotes, edits, footnotes, etc.). Re-sync kind 9802 from relays, or adjust matching in ArticleBodyHighlightInjector.' |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
if ($markCount < 1 && \count($highlights) > 0) { |
||||||
|
$io->warning('With all highlights together, nothing was injected. Per-row check above still shows if any row matches in isolation.'); |
||||||
|
} elseif (\count($highlights) < 1) { |
||||||
|
$io->note('No article_highlight rows for this slug+author. Run prewarm highlight sync or check MySQL.'); |
||||||
|
} elseif ($markCount > 0) { |
||||||
|
$io->success('At least one <mark> was produced when all rows were passed to the injector together.'); |
||||||
|
} |
||||||
|
|
||||||
|
if ($io->isVerbose() && $injected !== []) { |
||||||
|
$io->section('Injected event ids (batch, may include several per passage)'); |
||||||
|
$io->listing($injected); |
||||||
|
} |
||||||
|
|
||||||
|
return Command::SUCCESS; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* One line for the table: reflect {@see ArticleHighlight::getContent()} bytes faithfully. |
||||||
|
* Only line breaks are folded to a space so the row stays one line — we do not collapse |
||||||
|
* {@see \p{Z}} or remove U+00AD (soft hyphen); doing that made passages look like they |
||||||
|
* contained ASCII spaces the Nostr `content` never had. |
||||||
|
*/ |
||||||
|
private function excerptOneLine(string $s, int $max): string |
||||||
|
{ |
||||||
|
$s = (string) \preg_replace('/\R/u', ' ', $s); |
||||||
|
if (\mb_strlen($s, 'UTF-8') > $max) { |
||||||
|
$s = \mb_substr($s, 0, $max - 1, 'UTF-8').'…'; |
||||||
|
} |
||||||
|
|
||||||
|
return $s; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,120 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Nostr; |
||||||
|
|
||||||
|
use swentel\nostr\Event\Event; |
||||||
|
use swentel\nostr\Nip19\Nip19Helper; |
||||||
|
|
||||||
|
/** |
||||||
|
* NIP-19 encode/decode using swentel/nostr-php, output-shaped for code that previously used nostriphant\NIP19\Bech32 |
||||||
|
* (objects with {@see $type} and {@see $data} for decoded entities). |
||||||
|
*/ |
||||||
|
final class Nip19Codec |
||||||
|
{ |
||||||
|
private Nip19Helper $nip19Helper; |
||||||
|
|
||||||
|
public function __construct(?Nip19Helper $nip19Helper = null) |
||||||
|
{ |
||||||
|
$this->nip19Helper = $nip19Helper ?? new Nip19Helper(); |
||||||
|
} |
||||||
|
|
||||||
|
public function decode(string $bech32): object |
||||||
|
{ |
||||||
|
$pos = strrpos($bech32, '1'); |
||||||
|
if (false === $pos || $pos < 1) { |
||||||
|
throw new \InvalidArgumentException('Invalid bech32 string'); |
||||||
|
} |
||||||
|
$hrp = substr($bech32, 0, $pos); |
||||||
|
$raw = $this->nip19Helper->decode($bech32); |
||||||
|
|
||||||
|
$out = new \stdClass(); |
||||||
|
|
||||||
|
if ($hrp === 'npub' || $hrp === 'nsec') { |
||||||
|
if (!\is_array($raw) || !isset($raw[1]) || !\is_array($raw[1])) { |
||||||
|
throw new \RuntimeException('Unexpected npub/nsec decode shape'); |
||||||
|
} |
||||||
|
$out->type = $hrp; |
||||||
|
$d = new \stdClass(); |
||||||
|
$hex = ''; |
||||||
|
foreach ($raw[1] as $byte) { |
||||||
|
$hex .= str_pad(\dechex($byte & 0xff), 2, '0', STR_PAD_LEFT); |
||||||
|
} |
||||||
|
$d->data = $hex; |
||||||
|
$out->data = $d; |
||||||
|
|
||||||
|
return $out; |
||||||
|
} |
||||||
|
|
||||||
|
if ($hrp === 'note') { |
||||||
|
if (!\is_array($raw) || !isset($raw['event_id']) || !\is_string($raw['event_id'])) { |
||||||
|
throw new \RuntimeException('Unexpected note decode shape'); |
||||||
|
} |
||||||
|
$out->type = 'note'; |
||||||
|
$d = new \stdClass(); |
||||||
|
$d->data = $raw['event_id']; |
||||||
|
$d->relays = []; |
||||||
|
$out->data = $d; |
||||||
|
|
||||||
|
return $out; |
||||||
|
} |
||||||
|
|
||||||
|
if (!\is_array($raw)) { |
||||||
|
throw new \RuntimeException('Unexpected NIP-19 decode shape'); |
||||||
|
} |
||||||
|
|
||||||
|
$out->type = $hrp; |
||||||
|
$d = new \stdClass(); |
||||||
|
if ($hrp === 'nprofile') { |
||||||
|
$d->pubkey = $raw['pubkey'] ?? ''; |
||||||
|
$d->relays = isset($raw['relays']) && \is_array($raw['relays']) ? $raw['relays'] : []; |
||||||
|
} elseif ($hrp === 'nevent') { |
||||||
|
$d->id = (string) ($raw['event_id'] ?? ''); |
||||||
|
$d->relays = isset($raw['relays']) && \is_array($raw['relays']) ? $raw['relays'] : []; |
||||||
|
$d->author = \array_key_exists('author', $raw) ? (string) $raw['author'] : null; |
||||||
|
if ($d->author === '') { |
||||||
|
$d->author = null; |
||||||
|
} |
||||||
|
$d->pubkey = $d->author; |
||||||
|
$d->kind = \array_key_exists('kind', $raw) && $raw['kind'] !== null && $raw['kind'] !== '' ? (int) $raw['kind'] : null; |
||||||
|
} elseif ($hrp === 'naddr') { |
||||||
|
$d->identifier = (string) ($raw['identifier'] ?? ''); |
||||||
|
$d->pubkey = (string) ($raw['author'] ?? ''); |
||||||
|
$d->relays = isset($raw['relays']) && \is_array($raw['relays']) ? $raw['relays'] : []; |
||||||
|
$d->kind = \array_key_exists('kind', $raw) && $raw['kind'] !== null && $raw['kind'] !== '' ? (int) $raw['kind'] : 0; |
||||||
|
} else { |
||||||
|
throw new \InvalidArgumentException('Unsupported NIP-19 prefix: '.$hrp); |
||||||
|
} |
||||||
|
$out->data = $d; |
||||||
|
|
||||||
|
return $out; |
||||||
|
} |
||||||
|
|
||||||
|
public function encodeNevent(string $eventIdHex, array $relays, string $authorHex, int $kind): string |
||||||
|
{ |
||||||
|
$e = new Event(); |
||||||
|
$e->setId(strtolower($eventIdHex)); |
||||||
|
$e->setPublicKey(strtolower($authorHex)); |
||||||
|
$e->setKind($kind); |
||||||
|
|
||||||
|
return $this->nip19Helper->encodeEvent($e, $relays, $authorHex, $kind); |
||||||
|
} |
||||||
|
|
||||||
|
public function encodeNaddr(int $kind, string $pubkeyHex, string $dTag, array $relays = []): string |
||||||
|
{ |
||||||
|
$pk = strtolower($pubkeyHex); |
||||||
|
if (64 !== \strlen($pk) || !ctype_xdigit($pk)) { |
||||||
|
throw new \InvalidArgumentException('Invalid pubkey hex for naddr.'); |
||||||
|
} |
||||||
|
if ($dTag === '') { |
||||||
|
throw new \InvalidArgumentException('d tag required for naddr'); |
||||||
|
} |
||||||
|
$e = new Event(); |
||||||
|
$e->setPublicKey($pk); |
||||||
|
$e->setKind($kind); |
||||||
|
$e->setId(str_repeat('0', 64)); |
||||||
|
|
||||||
|
return $this->nip19Helper->encodeAddr($e, $dTag, $kind, $pk, $relays); |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue