You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
190 lines
6.3 KiB
190 lines
6.3 KiB
<?php |
|
|
|
namespace App\Repository; |
|
|
|
use App\Dto\FeaturedArticleCard; |
|
use App\Entity\Article; |
|
use App\Enum\EventStatusEnum; |
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; |
|
use Doctrine\DBAL\ArrayParameterType; |
|
use Doctrine\DBAL\Exception; |
|
use Doctrine\Persistence\ManagerRegistry; |
|
|
|
class ArticleRepository extends ServiceEntityRepository |
|
{ |
|
public function __construct(ManagerRegistry $registry) |
|
{ |
|
parent::__construct($registry, Article::class); |
|
} |
|
|
|
/** |
|
* Search articles by title, content, and summary using database LIKE queries |
|
*/ |
|
public function searchArticles(string $query, int $limit = 12, int $offset = 0): array |
|
{ |
|
$qb = $this->createQueryBuilder('a'); |
|
|
|
$searchTerms = explode(' ', trim($query)); |
|
$conditions = $qb->expr()->orX(); |
|
|
|
foreach ($searchTerms as $index => $term) { |
|
$term = trim($term); |
|
if (empty($term)) { |
|
continue; |
|
} |
|
|
|
$paramName = 'term' . $index; |
|
$termCondition = $qb->expr()->orX( |
|
$qb->expr()->like('a.title', ':' . $paramName), |
|
$qb->expr()->like('a.content', ':' . $paramName), |
|
$qb->expr()->like('a.summary', ':' . $paramName) |
|
); |
|
$conditions->add($termCondition); |
|
$qb->setParameter($paramName, '%' . $term . '%'); |
|
} |
|
|
|
return $qb |
|
->where($conditions) |
|
->andWhere('a.content IS NOT NULL') |
|
->andWhere('LENGTH(a.content) > 250') // Only articles with substantial content |
|
->orderBy('a.createdAt', 'DESC') |
|
->setFirstResult($offset) |
|
->setMaxResults($limit) |
|
->getQuery() |
|
->getResult(); |
|
} |
|
|
|
/** |
|
* List-card fields only: avoids loading `content` / `raw` (can be very large) for home/category featured rows. |
|
* |
|
* @return list<FeaturedArticleCard> |
|
*/ |
|
public function findFeaturedCardsBySlugs(array $slugs): array |
|
{ |
|
if ($slugs === []) { |
|
return []; |
|
} |
|
|
|
$conn = $this->getEntityManager()->getConnection(); |
|
$qb = $conn->createQueryBuilder(); |
|
$qb |
|
->select('a.id', 'a.slug', 'a.title', 'a.summary', 'a.image', 'a.created_at', 'a.pubkey') |
|
->from('article', 'a') |
|
->where($qb->expr()->in('a.slug', ':slugs')) |
|
->setParameter('slugs', $slugs, ArrayParameterType::STRING) |
|
->orderBy('a.created_at', 'DESC'); |
|
|
|
/** @var list<array<string, mixed>> $rows */ |
|
$rows = $qb->executeQuery()->fetchAllAssociative(); |
|
$out = []; |
|
foreach ($rows as $row) { |
|
$ca = $row['created_at'] ?? null; |
|
$out[] = new FeaturedArticleCard( |
|
isset($row['id']) ? (int) $row['id'] : null, |
|
isset($row['slug']) ? (string) $row['slug'] : null, |
|
isset($row['title']) ? (string) $row['title'] : null, |
|
isset($row['summary']) ? (string) $row['summary'] : null, |
|
isset($row['image']) ? (string) $row['image'] : null, |
|
$ca !== null && $ca !== '' ? new \DateTimeImmutable((string) $ca) : null, |
|
isset($row['pubkey']) ? (string) $row['pubkey'] : null, |
|
); |
|
} |
|
|
|
return $out; |
|
} |
|
|
|
/** |
|
* Resolve NIP-33 `a` tags (kind:pubkey:identifier) to articles without conflating the same |
|
* #d value across different authors. |
|
* |
|
* @param list<array{pubkey: string, slug: string}> $pairs |
|
* @return array<string, Article> key "pubkey\0slug" (lowercase hex pubkey, trimmed slug) |
|
*/ |
|
public function findByAuthorAndSlugIndexed(array $pairs): array |
|
{ |
|
$pairs = array_values(array_filter($pairs, static fn (array $p): bool => $p['pubkey'] !== '' && $p['slug'] !== '')); |
|
if ($pairs === []) { |
|
return []; |
|
} |
|
|
|
$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, $pkQ); |
|
$qb->setParameter('sl'.$i, $p['slug']); |
|
} |
|
$qb->where($orX); |
|
|
|
/** @var list<Article> $rows */ |
|
$rows = $qb->getQuery()->getResult(); |
|
$out = []; |
|
foreach ($rows as $a) { |
|
$pk = strtolower((string) $a->getPubkey()); |
|
$sl = trim((string) $a->getSlug()); |
|
if ($sl !== '') { |
|
$out[$pk."\0".$sl] = $a; |
|
} |
|
} |
|
|
|
return $out; |
|
} |
|
|
|
/** |
|
* Distinct hex pubkeys for prewarming Nostr profile cache. |
|
* |
|
* @return list<string> |
|
*/ |
|
public function findDistinctAuthorPubkeys(): array |
|
{ |
|
return $this->createQueryBuilder('a') |
|
->select('a.pubkey') |
|
->distinct() |
|
->where('a.pubkey IS NOT NULL') |
|
->andWhere("a.pubkey != ''") |
|
->getQuery() |
|
->getSingleColumnResult(); |
|
} |
|
|
|
public function findOneByEventId(string $eventId): ?Article |
|
{ |
|
return $this->findOneBy(['eventId' => $eventId]); |
|
} |
|
|
|
/** |
|
* Find articles by author's public key |
|
*/ |
|
public function findByPubkey(string $pubkey, int $limit = 25): array |
|
{ |
|
return $this->createQueryBuilder('a') |
|
->where('a.pubkey = :pubkey') |
|
->setParameter('pubkey', $pubkey) |
|
->orderBy('a.createdAt', 'DESC') |
|
->setMaxResults($limit) |
|
->getQuery() |
|
->getResult(); |
|
} |
|
|
|
/** |
|
* Published or archived long-form rows for sitemap/Atom (may include multiple rows per slug); |
|
* callers should dedupe by slug if URLs are slug-only. |
|
* |
|
* @return list<Article> |
|
*/ |
|
public function findPublishedForSyndication(int $limit = 5000): array |
|
{ |
|
return $this->createQueryBuilder('a') |
|
->where('a.slug IS NOT NULL') |
|
->andWhere("TRIM(a.slug) != ''") |
|
->andWhere('a.eventStatus IN (:st)') |
|
->setParameter('st', [EventStatusEnum::PUBLISHED, EventStatusEnum::ARCHIVED]) |
|
->orderBy('a.createdAt', 'DESC') |
|
->setMaxResults($limit) |
|
->getQuery() |
|
->getResult(); |
|
} |
|
}
|
|
|