From a58e9f7bb86c846d2ed746c7d5cc87e6b952c60a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Wed, 14 May 2025 22:09:49 +0200 Subject: [PATCH] Reorganize fetching data --- config/packages/fos_elastica.yaml | 3 +- config/services.yaml | 4 + public/service-worker.js | 8 +- src/Command/DatabaseCleanupCommand.php | 5 +- src/Command/DeduplicateArticlesCommand.php | 67 ++++++ .../NostrEventFromYamlDefinitionCommand.php | 29 ++- src/Controller/AuthorController.php | 34 ++- src/Controller/DefaultController.php | 199 +++++------------- src/Entity/Article.php | 19 +- src/Entity/Event.php | 8 +- src/Service/NostrClient.php | 81 ++++--- .../Components/Molecules/UserFromNpub.php | 20 +- .../Components/Organisms/FeaturedList.php | 25 ++- src/Twig/Filters.php | 38 ++++ templates/pages/author.html.twig | 9 +- 15 files changed, 336 insertions(+), 213 deletions(-) create mode 100644 src/Command/DeduplicateArticlesCommand.php diff --git a/config/packages/fos_elastica.yaml b/config/packages/fos_elastica.yaml index ded92e8..372974e 100644 --- a/config/packages/fos_elastica.yaml +++ b/config/packages/fos_elastica.yaml @@ -13,7 +13,8 @@ fos_elastica: title: ~ summary: ~ content: ~ - slug: ~ + slug: + type: keyword topics: ~ persistence: driver: orm diff --git a/config/services.yaml b/config/services.yaml index 7fb58f2..52f09f0 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -71,3 +71,7 @@ services: App\Command\IndexArticlesCommand: arguments: $itemPersister: '@fos_elastica.object_persister.articles' + + App\Command\NostrEventFromYamlDefinitionCommand: + arguments: + $itemPersister: '@fos_elastica.object_persister.articles' diff --git a/public/service-worker.js b/public/service-worker.js index e48980b..62794ef 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -54,7 +54,13 @@ self.addEventListener('fetch', (event) => { // Skip cache for dynamic routes const isDynamic = request.url.includes('/cat/') ; - if (isDynamic) { + // Exclude dynamic paths + const isExcluded = + request.url.startsWith('/login') || + request.url.startsWith('/logout') || + request.url.startsWith('/_components/'); + + if (isDynamic || isExcluded) { return; // Don't intercept } diff --git a/src/Command/DatabaseCleanupCommand.php b/src/Command/DatabaseCleanupCommand.php index 13eadd9..89c08cb 100644 --- a/src/Command/DatabaseCleanupCommand.php +++ b/src/Command/DatabaseCleanupCommand.php @@ -13,7 +13,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; #[AsCommand(name: 'db:cleanup', description: 'Remove articles with do_not_index rating')] -class DatabaseCleanupCommand extends Command +class + + + DatabaseCleanupCommand extends Command { public function __construct(private readonly EntityManagerInterface $entityManager) { diff --git a/src/Command/DeduplicateArticlesCommand.php b/src/Command/DeduplicateArticlesCommand.php new file mode 100644 index 0000000..4f633cc --- /dev/null +++ b/src/Command/DeduplicateArticlesCommand.php @@ -0,0 +1,67 @@ +em->getRepository(Article::class); + $slugIndex = []; + $page = 0; + + // Process articles in batches + while (true) { + // Fetch a batch of articles + $articles = $repo->findBy([], ['createdAt' => 'DESC'], self::BATCH_SIZE, $page * self::BATCH_SIZE); + + if (empty($articles)) { + break; + } + + foreach ($articles as $article) { + $slug = $article->getSlug(); + + // If this slug hasn't been seen, store the slug + if (!in_array($slug, $slugIndex)) { + $slugIndex[] = $slug; + continue; + } + // The articles are sorted, so the first one should be kept + // Mark current article as DO_NOT_INDEX + $article->setIndexStatus(IndexStatusEnum::DO_NOT_INDEX); + } + + // Flush the batch and clear memory to avoid overload + $this->em->flush(); + $this->em->clear(); // Clear the entity manager to free up memory + + $output->writeln("Processed batch " . ($page + 1)); + $page++; + } + + $output->writeln('Article deduplication complete.'); + return Command::SUCCESS; + + } + +} diff --git a/src/Command/NostrEventFromYamlDefinitionCommand.php b/src/Command/NostrEventFromYamlDefinitionCommand.php index 516247e..e2160ec 100644 --- a/src/Command/NostrEventFromYamlDefinitionCommand.php +++ b/src/Command/NostrEventFromYamlDefinitionCommand.php @@ -4,6 +4,9 @@ declare(strict_types=1); namespace App\Command; +use App\Entity\Article; +use Doctrine\ORM\EntityManagerInterface; +use FOS\ElasticaBundle\Persister\ObjectPersisterInterface; use swentel\nostr\Event\Event; use swentel\nostr\Sign\Sign; use Symfony\Component\Console\Attribute\AsCommand; @@ -21,7 +24,9 @@ class NostrEventFromYamlDefinitionCommand extends Command { private string $nsec; - public function __construct(private readonly CacheInterface $redisCache, ParameterBagInterface $bag) + public function __construct(private readonly CacheInterface $redisCache, ParameterBagInterface $bag, + private readonly ObjectPersisterInterface $itemPersister, + private readonly EntityManagerInterface $entityManager) { $this->nsec = $bag->get('nsec'); parent::__construct(); @@ -49,6 +54,8 @@ class NostrEventFromYamlDefinitionCommand extends Command return Command::SUCCESS; } + $articleSlugsList = []; + foreach ($finder as $file) { $filePath = $file->getRealPath(); $output->writeln("Processing file: $filePath"); @@ -60,6 +67,13 @@ class NostrEventFromYamlDefinitionCommand extends Command $event->setKind(30040); $tags = $yamlContent['tags']; $event->setTags($tags); + $items = array_filter($tags, function ($tag) { + return ($tag[0] === 'a'); + }); + foreach ($items as $one) { + $parts = explode(':', $one[1]); + $articleSlugsList[] = end($parts); + } $signer = new Sign(); $signer->signEvent($event, $this->nsec); @@ -81,6 +95,19 @@ class NostrEventFromYamlDefinitionCommand extends Command } } + // look up all articles in the db and push to index whatever you find + $articles = $this->entityManager->getRepository(Article::class)->createQueryBuilder('a') + ->where('a.slug IN (:slugs)') + ->setParameter('slugs', $articleSlugsList) + ->getQuery() + ->getResult(); + + // to elastic + if (count($articles) > 0 ) { + $this->itemPersister->insertMany($articles); // Insert or skip existing + $output->writeln('Added to index.'); + } + $output->writeln('Conversion complete.'); return Command::SUCCESS; } diff --git a/src/Controller/AuthorController.php b/src/Controller/AuthorController.php index 01ffff7..adff1af 100644 --- a/src/Controller/AuthorController.php +++ b/src/Controller/AuthorController.php @@ -14,6 +14,8 @@ use swentel\nostr\Key\Key; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; class AuthorController extends AbstractController { @@ -22,14 +24,37 @@ class AuthorController extends AbstractController * @throws InvalidArgumentException */ #[Route('/p/{npub}', name: 'author-profile', requirements: ['npub' => '^npub1.*'])] - public function index($npub, EntityManagerInterface $entityManager, NostrClient $client): Response + public function index($npub, CacheInterface $redisCache, EntityManagerInterface $entityManager, NostrClient $client): Response { $keys = new Key(); $pubkey = $keys->convertToHex($npub); + $relays = []; - $meta = $client->getNpubMetadata($pubkey); + try { + $cacheKey = '0_' . $pubkey; - $author = json_decode($meta->content ?? '{}'); + $author = $redisCache->get($cacheKey, function (ItemInterface $item) use ($pubkey, $client) { + $item->expiresAfter(3600); // 1 hour, adjust as needed + + $meta = $client->getNpubMetadata($pubkey); + return (array) json_decode($meta->content ?? '{}'); + }); + + } catch (InvalidArgumentException | \Exception $e) { + // nothing to do + } + + try { + $cacheKey = '10002_' . $pubkey; + + $relays = $redisCache->get($cacheKey, function (ItemInterface $item) use ($pubkey, $client) { + $item->expiresAfter(3600); // 1 hour, adjust as needed + + return $client->getNpubRelays($pubkey); + }); + } catch (InvalidArgumentException | \Exception $e) { + // nothing to do + } $list = $client->getLongFormContentForPubkey($pubkey); @@ -53,7 +78,8 @@ class AuthorController extends AbstractController 'articles' => $articles, 'nzine' => null, 'nzines' => null, - 'idx' => null + 'idx' => null, + 'relays' => $relays ]); } diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php index 7ad63ee..1ca8b05 100644 --- a/src/Controller/DefaultController.php +++ b/src/Controller/DefaultController.php @@ -4,20 +4,24 @@ declare(strict_types=1); namespace App\Controller; +use App\Factory\ArticleFactory; +use App\Service\NostrClient; +use Elastica\Query; use Elastica\Query\MatchQuery; +use Elastica\Query\Terms; use FOS\ElasticaBundle\Finder\FinderInterface; use Psr\Cache\InvalidArgumentException; -use swentel\nostr\Event\Event; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Serializer; use Symfony\Contracts\Cache\CacheInterface; -use Symfony\Contracts\Cache\ItemInterface; class DefaultController extends AbstractController { public function __construct( - private readonly FinderInterface $esFinder, private readonly CacheInterface $redisCache) { } @@ -27,7 +31,7 @@ class DefaultController extends AbstractController * @throws InvalidArgumentException */ #[Route('/', name: 'home')] - public function index(FinderInterface $finder): Response + public function index(): Response { // get newsroom index, loop over categories, pick top three from each and display in sections $mag = $this->redisCache->get('magazine-newsroom-magazine-by-newsroom', function (){ @@ -40,7 +44,7 @@ class DefaultController extends AbstractController }); return $this->render('home.html.twig', [ - 'indices' => $cats + 'indices' => array_values($cats) ]); } @@ -49,175 +53,70 @@ class DefaultController extends AbstractController * @throws InvalidArgumentException */ #[Route('/cat/{slug}', name: 'magazine-category')] - public function magCategory($slug, CacheInterface $redisCache, FinderInterface $finder): Response + public function magCategory($slug, CacheInterface $redisCache, + NostrClient $client, ArticleFactory $articleFactory, + FinderInterface $finder): Response { $catIndex = $redisCache->get('magazine-' . $slug, function (){ throw new \Exception('Not found'); }); - $articles = []; + $list = []; + $slugs = []; + $returnedSlugs = []; + foreach ($catIndex->getTags() as $tag) { if ($tag[0] === 'a') { $parts = explode(':', $tag[1]); if (count($parts) === 3) { - $fieldQuery = new MatchQuery(); - $fieldQuery->setFieldQuery('slug', $parts[2]); - $res = $finder->find($fieldQuery); - $articles[] = $res[0]; + $slugs[] = $parts[2]; } } } + if (!empty($slugs)) { - return $this->render('pages/category.html.twig', [ - 'list' => array_slice($articles, 0, 9) - ]); - } + $query = new Terms('slug', array_values($slugs)); + $articles = $finder->find($query); - /** - * @throws InvalidArgumentException - */ - #[Route('/business', name: 'business')] - public function business(CacheInterface $redisCache): Response - { - $articles = $redisCache->get('main_category_business', function (ItemInterface $item): array { - $item->expiresAfter(36000); - $search = [ - 'finance business', - 'trading stock commodity', - 's&p500 gold oil', - 'currency bitcoin', - 'international military incident' - ]; - - return $this->getArticles($search); - }); + // Create a map of slug => item to remove duplicates + $slugMap = []; - return $this->render('pages/category.html.twig', [ - 'list' => array_slice($articles, 0, 9) - ]); - } + foreach ($articles as $item) { + // $item = $articleFactory->createFromLongFormContentEvent($article); + $slug = $item->getSlug(); - /** - * @throws InvalidArgumentException - */ - #[Route('/technology', name: 'technology')] - public function technology(CacheInterface $redisCache): Response - { - $articles = $redisCache->get('main_category_technology', function (ItemInterface $item): array { - $item->expiresAfter(36000); - $search = [ - 'technology innovation', - 'ai llm chatgpt claude agent', - 'blockchain mining cryptography', - 'cypherpunk nostr', - 'server hosting' - ]; - - return $this->getArticles($search); - }); + if ($slug !== '' && !isset($slugMap[$slug])) { + $slugMap[$slug] = $item; + $returnedSlugs[] = $slug; + } + } - return $this->render('pages/category.html.twig', [ - 'list' => array_slice($articles, 0, 9) - ]); - } + if (!empty($res)) { + foreach ($res as $result) { + if (!isset($slugMap[$result->getSlug()])) { + $slugMap[$result->getSlug()] = $result; + } + } + } - /** - * @throws InvalidArgumentException - */ - #[Route('/world', name: 'world')] - public function world(CacheInterface $redisCache): Response - { - $articles = $redisCache->get('main_category_world', function (ItemInterface $item): array { - $item->expiresAfter(36000); - $search = [ - 'politics policy president', - 'agreement law resolution', - 'tariffs taxes trade', - 'international military incident' - ]; - - return $this->getArticles($search); - }); - return $this->render('pages/category.html.twig', [ - 'list' => array_slice($articles, 0, 9) - ]); - } + // Reorder by the original $slugs + $results = []; + foreach ($slugs as $slug) { + if (isset($slugMap[$slug])) { + $results[] = $slugMap[$slug]; + } + } + $list = array_values($results); + } - /** - * @throws InvalidArgumentException - */ - #[Route('/lifestyle', name: 'lifestyle')] - public function lifestyle(CacheInterface $redisCache): Response - { - $articles = $redisCache->get('main_category_lifestyle', function (ItemInterface $item): array { - $item->expiresAfter(36000); - $search = [ - 'touch grass', - 'health healthy', - 'lifestyle wellness diet sunshine' - ]; - - return $this->getArticles($search); - }); + // if any are missing, look in index - return $this->render('pages/category.html.twig', [ - 'list' => array_slice($articles, 0, 9) - ]); - } - - /** - * @throws InvalidArgumentException - */ - #[Route('/art', name: 'art')] - public function art(CacheInterface $redisCache): Response - { - $articles = $redisCache->get('main_category_art', function (ItemInterface $item): array { - $item->expiresAfter(36000); - $search = [ - 'photo photography', - 'travel', - 'art painting' - ]; - - return $this->getArticles($search); - }); return $this->render('pages/category.html.twig', [ - 'list' => array_slice($articles, 0, 9) + 'list' => $list, + 'index' => $catIndex ]); } - - /** - * @param $search - * @return array - */ - public function getArticles($search): array - { - $articles = []; - - foreach ($search as $q) { - $articles = array_merge($articles, $this->esFinder->find($q, 10)); - } - - // sort articles by created at date - usort($articles, function ($a, $b) { - return $b->getCreatedAt() <=> $a->getCreatedAt(); - }); - - // deduplicate by slugs - $deduplicated = []; - foreach ($articles as $item) { - if (!key_exists((string)$item->getSlug(), $deduplicated)) { - $deduplicated[(string)$item->getSlug()] = $item; - } - // keep 10 - if (count($deduplicated) > 9) { - break; - } - } - - return $deduplicated; - } } diff --git a/src/Entity/Article.php b/src/Entity/Article.php index 27e0716..d1494b8 100644 --- a/src/Entity/Article.php +++ b/src/Entity/Article.php @@ -23,7 +23,7 @@ class Article #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(length: 225, nullable: true)] - private ?int $id = null; + private null|int|string $id = null; #[ORM\Column(type: Types::JSON, nullable: true)] private $raw = null; @@ -80,12 +80,12 @@ class Article #[ORM\Column(type: Types::INTEGER, nullable: true)] private ?int $ratingPositive = null; - public function getId(): ?int + public function getId(): null|int|string { return $this->id; } - public function setId(int $id): static + public function setId(int|string $id): static { $this->id = $id; @@ -133,8 +133,14 @@ class Article return $this->kind; } - public function setKind(?KindsEnum $kind): static + public function setKind(null|KindsEnum|int $kind): static { + if (is_int($kind)) { + $kind = KindsEnum::tryFrom($kind); + if ($kind === null) { + throw new \InvalidArgumentException("Invalid kind value: $kind"); + } + } $this->kind = $kind; return $this; @@ -181,8 +187,11 @@ class Article return $this->createdAt; } - public function setCreatedAt(\DateTimeImmutable $createdAt): static + public function setCreatedAt(\DateTimeImmutable|int $createdAt): static { + if (is_int($createdAt)) { + $createdAt = (new \DateTimeImmutable())->setTimestamp($createdAt); + } $this->createdAt = $createdAt; return $this; diff --git a/src/Entity/Event.php b/src/Entity/Event.php index 52efbda..196a701 100644 --- a/src/Entity/Event.php +++ b/src/Entity/Event.php @@ -125,8 +125,8 @@ class Event public function getSummary(): ?string { foreach ($this->getTags() as $tag) { - if (array_key_first($tag) === 'summary') { - return $tag['summary']; + if ($tag[0] === 'summary') { + return $tag[1]; } } return null; @@ -135,8 +135,8 @@ class Event public function getSlug(): ?string { foreach ($this->getTags() as $tag) { - if (array_key_first($tag) === 'd') { - return $tag['d']; + if ($tag[0] === 'd') { + return $tag[1]; } } diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index 93dc6a1..c10bf9f 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -20,11 +20,7 @@ use swentel\nostr\Request\Request; use swentel\nostr\Subscription\Subscription; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Serializer\Encoder\JsonEncoder; -use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; -use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\SerializerInterface; -use Symfony\Contracts\Cache\CacheInterface; class NostrClient { @@ -35,14 +31,13 @@ class NostrClient private readonly ArticleFactory $articleFactory, private readonly SerializerInterface $serializer, private readonly TokenStorageInterface $tokenStorage, - private readonly CacheInterface $cacheApp, private readonly LoggerInterface $logger) { // TODO configure read and write relays for logged in users from their 10002 events $this->defaultRelaySet = new RelaySet(); - // $this->defaultRelaySet->addRelay(new Relay('wss://relay.damus.io')); // public relay + $this->defaultRelaySet->addRelay(new Relay('wss://relay.damus.io')); // public relay // $this->defaultRelaySet->addRelay(new Relay('wss://relay.primal.net')); // public relay - $this->defaultRelaySet->addRelay(new Relay('wss://nos.lol')); // public relay + // $this->defaultRelaySet->addRelay(new Relay('wss://nos.lol')); // public relay // $this->defaultRelaySet->addRelay(new Relay('wss://relay.snort.social')); // public relay $this->defaultRelaySet->addRelay(new Relay('wss://theforest.nostr1.com')); // public relay // $this->defaultRelaySet->addRelay(new Relay('wss://purplepag.es')); // public relay @@ -341,11 +336,8 @@ class NostrClient } } - /** - * - * @return array - */ - public function getNpubRelays($pubkey) + + public function getNpubRelays($pubkey): array { $subscription = new Subscription(); $subscriptionId = $subscription->setId(); @@ -353,13 +345,7 @@ class NostrClient $filter->setKinds([KindsEnum::RELAY_LIST]); $filter->setAuthors([$pubkey]); $requestMessage = new RequestMessage($subscriptionId, [$filter]); - // TODO make relays configurable - $relays = new RelaySet(); - $relays->addRelay(new Relay('wss://purplepag.es')); // default metadata aggregator - $relays->addRelay(new Relay('wss://nos.lol')); // default public - $relays->addRelay(new Relay('wss://theforest.nostr1.com')); // default public - - $request = new Request($relays, $requestMessage); + $request = new Request($this->defaultRelaySet, $requestMessage); $response = $request->send(); @@ -372,8 +358,13 @@ class NostrClient $relays = []; foreach ($event->tags as $tag) { if ($tag[0] === 'r') { + $this->logger->info('Relay: ' . $tag[1]); // if not already listed - if (!in_array($tag[1], $relays)) { + // is wss: + // not localhost + if (!in_array($tag[1], $relays) + && str_starts_with('wss:',$tag[1]) + && !str_contains('localhost',$tag[1])) { $relays[] = $tag[1]; } } @@ -440,12 +431,9 @@ class NostrClient public function getLongFormContentForPubkey(string $pubkey) { $articles = []; - // get npub relays, then look for articles - $relayList = $this->getNpubRelays($pubkey); + $relaySet = $this->defaultRelaySet; - foreach ($relayList as $r) { - // if (str_starts_with($r, 'wss:')) $relaySet->addRelay(new Relay($r)); - } + // look for last months long-form notes $subscription = new Subscription(); $subscriptionId = $subscription->setId(); @@ -464,14 +452,8 @@ class NostrClient if (is_array($item)) continue; switch ($item->type) { case 'EVENT': - $eventStr = json_encode($item->event); - // remap to the Event class - $encoders = [new JsonEncoder()]; - $normalizers = [new ObjectNormalizer()]; - $serializer = new Serializer($normalizers, $encoders); - /** @var \App\Entity\Event $event */ - $event = $serializer->deserialize($eventStr, \App\Entity\Event::class, 'json'); - $articles[] = $event; + $article = $this->articleFactory->createFromLongFormContentEvent($item->event); + $articles[] = $article; break; case 'AUTH': // throw new UnauthorizedHttpException('', 'Relay requires authentication'); @@ -485,4 +467,37 @@ class NostrClient } return $articles; } + + public function getArticles(array $slugs): array + { + $articles = []; + $subscription = new Subscription(); + $subscriptionId = $subscription->setId(); + $filter = new Filter(); + $filter->setKinds([KindsEnum::LONGFORM]); + $filter->setTag('#d', $slugs); + $requestMessage = new RequestMessage($subscriptionId, [$filter]); + + $request = new Request($this->defaultRelaySet, $requestMessage); + + $response = $request->send(); + // response is an array of arrays + foreach ($response as $value) { + foreach ($value as $item) { + switch ($item->type) { + case 'EVENT': + $articles[] = $item->event; + break; + case 'AUTH': + throw new UnauthorizedHttpException('', 'Relay requires authentication'); + case 'ERROR': + case 'NOTICE': + $this->logger->error('An error while getting articles.', $item); + default: + // nothing to do here + } + } + } + return $articles; + } } diff --git a/src/Twig/Components/Molecules/UserFromNpub.php b/src/Twig/Components/Molecules/UserFromNpub.php index 8465063..769f83d 100644 --- a/src/Twig/Components/Molecules/UserFromNpub.php +++ b/src/Twig/Components/Molecules/UserFromNpub.php @@ -5,6 +5,8 @@ namespace App\Twig\Components\Molecules; use App\Service\NostrClient; use Psr\Cache\InvalidArgumentException; use swentel\nostr\Key\Key; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; #[AsTwigComponent] @@ -14,20 +16,30 @@ final class UserFromNpub public string $npub; public $user = null; - public function __construct(private readonly NostrClient $nostrClient) + public function __construct( + private readonly CacheInterface $redisCache, + private readonly NostrClient $nostrClient) { } public function mount(string $pubkey): void { + $keys = new Key(); $this->pubkey = $pubkey; $this->npub = $keys->convertPublicKeyToBech32($pubkey); try { - $meta = $this->nostrClient->getNpubMetadata($this->pubkey); - $this->user = (array) json_decode($meta->content); - } catch (InvalidArgumentException|\Exception) { + $cacheKey = '0_' . $this->pubkey; + + $this->user = $this->redisCache->get($cacheKey, function (ItemInterface $item) use ($pubkey) { + $item->expiresAfter(3600); // 1 hour, adjust as needed + + $meta = $this->nostrClient->getNpubMetadata($pubkey); + return (array) json_decode($meta->content); + }); + + } catch (InvalidArgumentException | \Exception $e) { // nothing to do } } diff --git a/src/Twig/Components/Organisms/FeaturedList.php b/src/Twig/Components/Organisms/FeaturedList.php index b9a2d28..3979115 100644 --- a/src/Twig/Components/Organisms/FeaturedList.php +++ b/src/Twig/Components/Organisms/FeaturedList.php @@ -2,10 +2,17 @@ namespace App\Twig\Components\Organisms; +use App\Entity\Article; +use App\Factory\ArticleFactory; +use App\Service\NostrClient; use Elastica\Query\MatchQuery; +use Elastica\Query\Terms; use FOS\ElasticaBundle\Finder\FinderInterface; use Psr\Cache\InvalidArgumentException; use swentel\nostr\Event\Event; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Serializer; use Symfony\Contracts\Cache\CacheInterface; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; @@ -16,12 +23,15 @@ final class FeaturedList public string $title; public array $list = []; - public function __construct(private readonly CacheInterface $redisCache, private readonly FinderInterface $finder) + public function __construct( + private readonly CacheInterface $redisCache, + private readonly FinderInterface $finder) { } /** * @throws InvalidArgumentException + * @throws \Exception */ public function mount($category): void { @@ -31,6 +41,7 @@ final class FeaturedList throw new \Exception('Not found'); }); + $slugs = []; foreach ($catIndex->getTags() as $tag) { if ($tag[0] === 'title') { $this->title = $tag[1]; @@ -38,15 +49,13 @@ final class FeaturedList if ($tag[0] === 'a') { $parts = explode(':', $tag[1]); if (count($parts) === 3) { - $fieldQuery = new MatchQuery(); - $fieldQuery->setFieldQuery('slug', $parts[2]); - $res = $this->finder->find($fieldQuery); - $this->list[] = $res[0]; + $slugs[] = $parts[2]; } } - if (count($this->list) > 3) { - break; - } } + + $query = new Terms('slug', array_values($slugs)); + $res = $this->finder->find($query); + $this->list = array_slice($res, 0, 4); } } diff --git a/src/Twig/Filters.php b/src/Twig/Filters.php index bfac2f3..6377ce7 100644 --- a/src/Twig/Filters.php +++ b/src/Twig/Filters.php @@ -14,6 +14,8 @@ class Filters extends AbstractExtension { return [ new TwigFilter('shortenNpub', [$this, 'shortenNpub']), + new TwigFilter('linkify', [$this, 'linkify'], ['is_safe' => ['html']]), + new TwigFilter('mentionify', [$this, 'mentionify'], ['is_safe' => ['html']]) ]; } @@ -21,4 +23,40 @@ class Filters extends AbstractExtension { return substr($npub, 0, 8) . '…' . substr($npub, -4); } + + public function linkify(string $text): string + { + return preg_replace_callback( + '#\b((https?://|www\.)[^\s<]+)#i', + function ($matches) { + $url = $matches[0]; + $href = str_starts_with($url, 'http') ? $url : 'https://' . $url; + + return sprintf( + '%s', + htmlspecialchars($href, ENT_QUOTES, 'UTF-8'), + htmlspecialchars($url, ENT_QUOTES, 'UTF-8') + ); + }, + $text + ); + } + + public function mentionify(string $text): string + { + return preg_replace_callback( + '/@(?npub1[0-9a-z]{10,})/i', + function ($matches) { + $npub = $matches['npub']; + $short = substr($npub, 0, 8) . '...' . substr($npub, -4); + + return sprintf( + '@%s', + htmlspecialchars($npub, ENT_QUOTES, 'UTF-8'), + htmlspecialchars($short, ENT_QUOTES, 'UTF-8') + ); + }, + $text + ); + } } diff --git a/templates/pages/author.html.twig b/templates/pages/author.html.twig index 10a31c6..568b7ab 100644 --- a/templates/pages/author.html.twig +++ b/templates/pages/author.html.twig @@ -9,10 +9,17 @@

{% if author.about is defined %}

- {{ author.about }} + {{ author.about|linkify|mentionify }}

{% endif %} + {% if relays|length > 0 %} + {% for rel in relays %} +

{{ rel }}

+ {% endfor %} + {% endif %} + + {# {% if app.user and app.user.userIdentifier is same as npub %}#} {#
#} {#

Purchase Search Credits

#}