31 changed files with 744 additions and 289 deletions
@ -0,0 +1,33 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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