31 changed files with 744 additions and 289 deletions
@ -0,0 +1,33 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace DoctrineMigrations; |
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema; |
||||||
|
use Doctrine\Migrations\AbstractMigration; |
||||||
|
|
||||||
|
/** |
||||||
|
* Auto-generated Migration: Please modify to your needs! |
||||||
|
*/ |
||||||
|
final class Version20251130103218 extends AbstractMigration |
||||||
|
{ |
||||||
|
public function getDescription(): string |
||||||
|
{ |
||||||
|
return ''; |
||||||
|
} |
||||||
|
|
||||||
|
public function up(Schema $schema): void |
||||||
|
{ |
||||||
|
// this up() migration is auto-generated, please modify it to your needs |
||||||
|
$this->addSql('ALTER TABLE article ALTER id ADD GENERATED BY DEFAULT AS IDENTITY'); |
||||||
|
$this->addSql('DROP INDEX idx_event_kind'); |
||||||
|
} |
||||||
|
|
||||||
|
public function down(Schema $schema): void |
||||||
|
{ |
||||||
|
// this down() migration is auto-generated, please modify it to your needs |
||||||
|
$this->addSql('ALTER TABLE article ALTER id DROP IDENTITY'); |
||||||
|
$this->addSql('CREATE INDEX idx_event_kind ON event (kind)'); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,120 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Command; |
||||||
|
|
||||||
|
use App\Repository\HighlightRepository; |
||||||
|
use App\Service\HighlightService; |
||||||
|
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\Output\OutputInterface; |
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle; |
||||||
|
|
||||||
|
#[AsCommand( |
||||||
|
name: 'app:debug-highlights', |
||||||
|
description: 'Debug highlights for an article coordinate', |
||||||
|
)] |
||||||
|
class DebugHighlightsCommand extends Command |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
private readonly HighlightRepository $highlightRepository, |
||||||
|
private readonly HighlightService $highlightService |
||||||
|
) { |
||||||
|
parent::__construct(); |
||||||
|
} |
||||||
|
|
||||||
|
protected function configure(): void |
||||||
|
{ |
||||||
|
$this |
||||||
|
->addArgument('coordinate', InputArgument::OPTIONAL, 'Article coordinate (kind:pubkey:slug)') |
||||||
|
->setHelp('Debug highlights storage and retrieval. Run without arguments to see all stored coordinates.'); |
||||||
|
} |
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int |
||||||
|
{ |
||||||
|
$io = new SymfonyStyle($input, $output); |
||||||
|
$coordinate = $input->getArgument('coordinate'); |
||||||
|
|
||||||
|
if (!$coordinate) { |
||||||
|
// List all stored coordinates |
||||||
|
$coordinates = $this->highlightRepository->getAllArticleCoordinates(); |
||||||
|
|
||||||
|
$io->title('All Article Coordinates in Database'); |
||||||
|
$io->writeln(sprintf('Found %d unique coordinates:', count($coordinates))); |
||||||
|
$io->newLine(); |
||||||
|
|
||||||
|
foreach ($coordinates as $coord) { |
||||||
|
$count = count($this->highlightRepository->findByArticleCoordinate($coord)); |
||||||
|
$io->writeln(sprintf(' %s (%d highlights)', $coord, $count)); |
||||||
|
} |
||||||
|
|
||||||
|
$io->newLine(); |
||||||
|
$io->info('Run with a coordinate argument to see details: app:debug-highlights "30023:pubkey:slug"'); |
||||||
|
|
||||||
|
return Command::SUCCESS; |
||||||
|
} |
||||||
|
|
||||||
|
// Debug specific coordinate |
||||||
|
$io->title('Highlight Debug for: ' . $coordinate); |
||||||
|
|
||||||
|
// Check database |
||||||
|
$io->section('Database Check'); |
||||||
|
$dbHighlights = $this->highlightRepository->findByArticleCoordinate($coordinate); |
||||||
|
$io->writeln(sprintf('Found %d highlights in database', count($dbHighlights))); |
||||||
|
|
||||||
|
if (count($dbHighlights) > 0) { |
||||||
|
$io->table( |
||||||
|
['Event ID', 'Content Preview', 'Created At', 'Cached At'], |
||||||
|
array_map(function($h) { |
||||||
|
return [ |
||||||
|
substr($h->getEventId(), 0, 16) . '...', |
||||||
|
substr($h->getContent(), 0, 50) . '...', |
||||||
|
date('Y-m-d H:i:s', $h->getCreatedAt()), |
||||||
|
$h->getCachedAt()->format('Y-m-d H:i:s'), |
||||||
|
]; |
||||||
|
}, array_slice($dbHighlights, 0, 5)) |
||||||
|
); |
||||||
|
|
||||||
|
if (count($dbHighlights) > 5) { |
||||||
|
$io->writeln(sprintf('... and %d more', count($dbHighlights) - 5)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Check cache status |
||||||
|
$io->section('Cache Status'); |
||||||
|
$needsRefresh = $this->highlightRepository->needsRefresh($coordinate, 24); |
||||||
|
$lastCache = $this->highlightRepository->getLastCacheTime($coordinate); |
||||||
|
|
||||||
|
$io->writeln(sprintf('Needs refresh (24h): %s', $needsRefresh ? 'YES' : 'NO')); |
||||||
|
$io->writeln(sprintf('Last cached: %s', $lastCache ? $lastCache->format('Y-m-d H:i:s') : 'Never')); |
||||||
|
|
||||||
|
// Try to fetch through service |
||||||
|
$io->section('Service Fetch Test'); |
||||||
|
$io->writeln('Fetching highlights through HighlightService...'); |
||||||
|
|
||||||
|
try { |
||||||
|
$highlights = $this->highlightService->getHighlightsForArticle($coordinate); |
||||||
|
$io->success(sprintf('Successfully fetched %d highlights', count($highlights))); |
||||||
|
|
||||||
|
if (count($highlights) > 0) { |
||||||
|
$io->table( |
||||||
|
['Content Preview', 'Created At', 'Pubkey'], |
||||||
|
array_map(function($h) { |
||||||
|
return [ |
||||||
|
substr($h['content'], 0, 50) . '...', |
||||||
|
date('Y-m-d H:i:s', $h['created_at']), |
||||||
|
substr($h['pubkey'], 0, 16) . '...', |
||||||
|
]; |
||||||
|
}, array_slice($highlights, 0, 5)) |
||||||
|
); |
||||||
|
} |
||||||
|
} catch (\Exception $e) { |
||||||
|
$io->error('Failed to fetch highlights: ' . $e->getMessage()); |
||||||
|
return Command::FAILURE; |
||||||
|
} |
||||||
|
|
||||||
|
return Command::SUCCESS; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,55 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Command; |
||||||
|
|
||||||
|
use App\Entity\Article; |
||||||
|
use Doctrine\ORM\EntityManagerInterface; |
||||||
|
use Symfony\Component\Console\Attribute\AsCommand; |
||||||
|
use Symfony\Component\Console\Command\Command; |
||||||
|
use Symfony\Component\Console\Input\InputInterface; |
||||||
|
use Symfony\Component\Console\Output\OutputInterface; |
||||||
|
|
||||||
|
#[AsCommand( |
||||||
|
name: 'app:export-article-lists', |
||||||
|
description: 'Export article event IDs and coordinates for relay ingest.' |
||||||
|
)] |
||||||
|
class ExportArticleListsCommand extends Command |
||||||
|
{ |
||||||
|
public function __construct(private readonly EntityManagerInterface $entityManager) |
||||||
|
{ |
||||||
|
parent::__construct(); |
||||||
|
} |
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int |
||||||
|
{ |
||||||
|
// Query recent articles (last 7 days, kind 30023) |
||||||
|
$since = (new \DateTimeImmutable('-7 days')); |
||||||
|
$repo = $this->entityManager->getRepository(Article::class); |
||||||
|
$qb = $repo->createQueryBuilder('a') |
||||||
|
->where('a.kind = :kind') |
||||||
|
->andWhere('a.createdAt >= :since') |
||||||
|
->setParameter('kind', 30023) |
||||||
|
->setParameter('since', $since); |
||||||
|
$articles = $qb->getQuery()->getResult(); |
||||||
|
|
||||||
|
$eventIds = []; |
||||||
|
$coordinates = []; |
||||||
|
foreach ($articles as $article) { |
||||||
|
if (method_exists($article, 'getEventId')) { |
||||||
|
$eventIds[] = $article->getEventId(); |
||||||
|
} |
||||||
|
// Build coordinate: "30023:<pubkey>:<d>" |
||||||
|
if (method_exists($article, 'getPubkey') && method_exists($article, 'getSlug')) { |
||||||
|
$coordinates[] = '30023:' . $article->getPubkey() . ':' . $article->getSlug(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Output event IDs (first line) |
||||||
|
$output->writeln(json_encode($eventIds)); |
||||||
|
// Output coordinates (second line) |
||||||
|
$output->writeln(json_encode($coordinates)); |
||||||
|
|
||||||
|
return Command::SUCCESS; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,49 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Command; |
||||||
|
|
||||||
|
use App\Service\NostrRelayPool; |
||||||
|
use Symfony\Component\Console\Attribute\AsCommand; |
||||||
|
use Symfony\Component\Console\Command\Command; |
||||||
|
use Symfony\Component\Console\Input\InputInterface; |
||||||
|
use Symfony\Component\Console\Input\InputOption; |
||||||
|
use Symfony\Component\Console\Output\OutputInterface; |
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle; |
||||||
|
|
||||||
|
#[AsCommand( |
||||||
|
name: 'nostr:pool:cleanup', |
||||||
|
description: 'Clean up stale relay connections from the pool', |
||||||
|
)] |
||||||
|
class NostrRelayPoolCleanupCommand extends Command |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
private readonly NostrRelayPool $relayPool |
||||||
|
) { |
||||||
|
parent::__construct(); |
||||||
|
} |
||||||
|
|
||||||
|
protected function configure(): void |
||||||
|
{ |
||||||
|
$this |
||||||
|
->addOption('max-age', null, InputOption::VALUE_REQUIRED, 'Maximum age of connections in seconds', 300) |
||||||
|
; |
||||||
|
} |
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int |
||||||
|
{ |
||||||
|
$io = new SymfonyStyle($input, $output); |
||||||
|
$maxAge = (int) $input->getOption('max-age'); |
||||||
|
|
||||||
|
$io->info(sprintf('Cleaning up connections older than %d seconds...', $maxAge)); |
||||||
|
|
||||||
|
$cleaned = $this->relayPool->cleanupStaleConnections($maxAge); |
||||||
|
|
||||||
|
if ($cleaned > 0) { |
||||||
|
$io->success(sprintf('Cleaned up %d stale connection(s).', $cleaned)); |
||||||
|
} else { |
||||||
|
$io->info('No stale connections found.'); |
||||||
|
} |
||||||
|
|
||||||
|
return Command::SUCCESS; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,62 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Command; |
||||||
|
|
||||||
|
use App\Service\NostrRelayPool; |
||||||
|
use Symfony\Component\Console\Attribute\AsCommand; |
||||||
|
use Symfony\Component\Console\Command\Command; |
||||||
|
use Symfony\Component\Console\Input\InputInterface; |
||||||
|
use Symfony\Component\Console\Output\OutputInterface; |
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle; |
||||||
|
|
||||||
|
#[AsCommand( |
||||||
|
name: 'nostr:pool:stats', |
||||||
|
description: 'Display statistics about the Nostr relay connection pool', |
||||||
|
)] |
||||||
|
class NostrRelayPoolStatsCommand extends Command |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
private readonly NostrRelayPool $relayPool |
||||||
|
) { |
||||||
|
parent::__construct(); |
||||||
|
} |
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int |
||||||
|
{ |
||||||
|
$io = new SymfonyStyle($input, $output); |
||||||
|
|
||||||
|
$stats = $this->relayPool->getStats(); |
||||||
|
|
||||||
|
$io->title('Nostr Relay Pool Statistics'); |
||||||
|
|
||||||
|
$io->section('Overview'); |
||||||
|
$io->table( |
||||||
|
['Metric', 'Value'], |
||||||
|
[ |
||||||
|
['Active Connections', $stats['active_connections']], |
||||||
|
] |
||||||
|
); |
||||||
|
|
||||||
|
if (!empty($stats['relays'])) { |
||||||
|
$io->section('Relay Details'); |
||||||
|
$rows = []; |
||||||
|
foreach ($stats['relays'] as $relay) { |
||||||
|
$rows[] = [ |
||||||
|
$relay['url'], |
||||||
|
$relay['attempts'], |
||||||
|
$relay['last_connected'] ? date('Y-m-d H:i:s', $relay['last_connected']) : 'Never', |
||||||
|
$relay['age'] . 's', |
||||||
|
]; |
||||||
|
} |
||||||
|
$io->table( |
||||||
|
['Relay URL', 'Failed Attempts', 'Last Connected', 'Age'], |
||||||
|
$rows |
||||||
|
); |
||||||
|
} else { |
||||||
|
$io->info('No active relay connections.'); |
||||||
|
} |
||||||
|
|
||||||
|
return Command::SUCCESS; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,34 @@ |
|||||||
|
<div class="nostr-article-preview card"> |
||||||
|
<div class="card-body"> |
||||||
|
{% if article.image %} |
||||||
|
<div class="article-preview-image"> |
||||||
|
<img src="{{ article.image }}" alt="{{ article.title }}" class="img-fluid rounded"> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
<div class="article-preview-meta d-flex flex-row justify-content-between align-items-center px-2"> |
||||||
|
<div class="author-info"> |
||||||
|
<twig:Molecules:UserFromNpub |
||||||
|
ident="{{ article.pubkey }}" |
||||||
|
:compact="true" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="article-date text-muted small"> |
||||||
|
{{ article.createdAt|date('M j, Y') }} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="px-2"> |
||||||
|
<h5 class="card-title"> |
||||||
|
<a href="{{ path('author-article-slug', {npub: npub, slug: article.slug}) }}" class="text-decoration-none"> |
||||||
|
{{ article.title }} |
||||||
|
</a> |
||||||
|
</h5> |
||||||
|
|
||||||
|
{% if article.summary %} |
||||||
|
<p class="card-text text-muted small line-clamp-3">{{ article.summary }}</p> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
@ -0,0 +1,24 @@ |
|||||||
|
{% extends 'layout.html.twig' %} |
||||||
|
|
||||||
|
{% block body %} |
||||||
|
<h1>Disambiguation: Multiple Articles for "{{ slug }}"</h1> |
||||||
|
<p>There are multiple articles with the slug <strong>{{ slug }}</strong>. Please select the author:</p> |
||||||
|
<ul> |
||||||
|
{% for author, article in authors|zip(articles) %} |
||||||
|
<li> |
||||||
|
<a href="{{ path('author-article-slug', { npub: author.npub, slug: slug }) }}"> |
||||||
|
Author: {{ author.npub }} |
||||||
|
</a> |
||||||
|
<span>Published: {{ article.createdAt|date('Y-m-d H:i') }}</span> |
||||||
|
{% if article.title %} - <strong>{{ article.title }}</strong>{% endif %} |
||||||
|
</li> |
||||||
|
{% endfor %} |
||||||
|
</ul> |
||||||
|
{% endblock %} |
||||||
|
|
||||||
|
{% block aside %} |
||||||
|
<div class="aside-box"> |
||||||
|
<h3>What is this?</h3> |
||||||
|
<p>This page lists all articles with the same slug. Click an author to view their version.</p> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
Loading…
Reference in new issue