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.
398 lines
13 KiB
398 lines
13 KiB
<?php |
|
|
|
namespace App\Repository; |
|
|
|
use App\Dto\FeaturedArticleCard; |
|
use App\Entity\Article; |
|
use App\Enum\EventStatusEnum; |
|
use App\Service\TenantContext; |
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; |
|
use Doctrine\DBAL\ArrayParameterType; |
|
use Doctrine\ORM\QueryBuilder; |
|
use Doctrine\Persistence\ManagerRegistry; |
|
|
|
class ArticleRepository extends ServiceEntityRepository |
|
{ |
|
public function __construct( |
|
ManagerRegistry $registry, |
|
private readonly TenantContext $tenant, |
|
) { |
|
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->tenantQueryBuilder('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(); |
|
} |
|
|
|
public function countSearchArticles(string $query): int |
|
{ |
|
$qb = $this->tenantQueryBuilder('a') |
|
->select('COUNT(a.id)'); |
|
|
|
$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 . '%'); |
|
} |
|
|
|
if (\count($conditions->getParts()) === 0) { |
|
return 0; |
|
} |
|
|
|
return (int) $qb |
|
->where($conditions) |
|
->andWhere('a.content IS NOT NULL') |
|
->andWhere('LENGTH(a.content) > 250') |
|
->getQuery() |
|
->getSingleScalarResult(); |
|
} |
|
|
|
/** |
|
* 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.published_at', 'a.pubkey') |
|
->from('article', 'a') |
|
->innerJoin('a', 'article_magazine', 'am', 'am.article_id = a.id AND am.magazine_slug = :mag') |
|
->where($qb->expr()->in('a.slug', ':slugs')) |
|
->setParameter('mag', $this->tenant->getMagazineSlug()) |
|
->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; |
|
$pa = $row['published_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, |
|
$pa !== null && $pa !== '' ? new \DateTimeImmutable((string) $pa) : 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->tenantQueryBuilder('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 (this magazine tenant only). |
|
* |
|
* @return list<string> |
|
*/ |
|
public function findDistinctAuthorPubkeys(): array |
|
{ |
|
return $this->tenantQueryBuilder('a') |
|
->select('a.pubkey') |
|
->distinct() |
|
->where('a.pubkey IS NOT NULL') |
|
->andWhere("a.pubkey != ''") |
|
->getQuery() |
|
->getSingleColumnResult(); |
|
} |
|
|
|
/** Global lookup by Nostr event id (shared across magazine tenants). */ |
|
public function findOneByEventId(string $eventId): ?Article |
|
{ |
|
return $this->findOneBy(['eventId' => $eventId]); |
|
} |
|
|
|
/** |
|
* Newest row for a NIP-23/24 `d` value linked to this magazine tenant. |
|
*/ |
|
public function findLatestBySlug(string $slug): ?Article |
|
{ |
|
$slug = trim($slug); |
|
if ($slug === '') { |
|
return null; |
|
} |
|
|
|
return $this->tenantQueryBuilder('a') |
|
->where('a.slug = :slug') |
|
->setParameter('slug', $slug) |
|
->orderBy('a.createdAt', 'DESC') |
|
->setMaxResults(1) |
|
->getQuery() |
|
->getOneOrNullResult(); |
|
} |
|
|
|
public function findLatestBySlugForTenant(string $slug, string $npubHex): ?Article |
|
{ |
|
$slug = trim($slug); |
|
if ($slug === '' || $npubHex === '') { |
|
return null; |
|
} |
|
|
|
return $this->tenantQueryBuilder('a') |
|
->where('a.slug = :slug') |
|
->andWhere('LOWER(a.pubkey) = :pk') |
|
->setParameter('slug', $slug) |
|
->setParameter('pk', strtolower($npubHex)) |
|
->orderBy('a.createdAt', 'DESC') |
|
->setMaxResults(1) |
|
->getQuery() |
|
->getOneOrNullResult(); |
|
} |
|
|
|
public function findByPubkeyPaginated(string $pubkey, int $limit, int $offset): array |
|
{ |
|
return $this->tenantQueryBuilder('a') |
|
->where('a.pubkey = :pubkey') |
|
->setParameter('pubkey', $pubkey) |
|
->orderBy('a.createdAt', 'DESC') |
|
->setFirstResult($offset) |
|
->setMaxResults($limit) |
|
->getQuery() |
|
->getResult(); |
|
} |
|
|
|
public function countByPubkey(string $pubkey): int |
|
{ |
|
return (int) $this->tenantQueryBuilder('a') |
|
->select('COUNT(a.id)') |
|
->where('a.pubkey = :pubkey') |
|
->setParameter('pubkey', $pubkey) |
|
->getQuery() |
|
->getSingleScalarResult(); |
|
} |
|
|
|
public function countForMagazine(): int |
|
{ |
|
return (int) $this->tenantQueryBuilder('a') |
|
->select('COUNT(a.id)') |
|
->getQuery() |
|
->getSingleScalarResult(); |
|
} |
|
|
|
/** |
|
* @return list<Article> |
|
*/ |
|
public function findForMagazinePaginated(int $limit, int $offset): array |
|
{ |
|
return $this->tenantQueryBuilder('a') |
|
->orderBy('a.createdAt', 'DESC') |
|
->setFirstResult($offset) |
|
->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->tenantQueryBuilder('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(); |
|
} |
|
|
|
/** |
|
* Published or archived long-form with at least one stored topic, matched case-insensitively. |
|
* Ordered newest first. Uses an in-process filter; suitable for moderate table sizes. |
|
* |
|
* @return list<Article> |
|
*/ |
|
public function findPublishedByTopic(string $topic, int $limit, int $offset): array |
|
{ |
|
$all = $this->articlesMatchingTopicNormalized( |
|
$this->normalizeTopicLabel($topic) |
|
); |
|
|
|
return \array_slice($all, $offset, $limit); |
|
} |
|
|
|
public function countPublishedByTopic(string $topic): int |
|
{ |
|
return \count( |
|
$this->articlesMatchingTopicNormalized( |
|
$this->normalizeTopicLabel($topic) |
|
) |
|
); |
|
} |
|
|
|
/** |
|
* @return list<Article> |
|
*/ |
|
private function articlesMatchingTopicNormalized(string $topicKey): array |
|
{ |
|
if ($topicKey === '') { |
|
return []; |
|
} |
|
$qb = $this->tenantQueryBuilder('a') |
|
->where('a.topics IS NOT NULL') |
|
->andWhere('a.content IS NOT NULL') |
|
->andWhere('LENGTH(a.content) > 250') |
|
->andWhere('a.eventStatus IN (:st)') |
|
->setParameter('st', [EventStatusEnum::PUBLISHED, EventStatusEnum::ARCHIVED]) |
|
->orderBy('a.createdAt', 'DESC'); |
|
|
|
/** @var list<Article> $candidates */ |
|
$candidates = $qb->getQuery()->getResult(); |
|
$out = []; |
|
foreach ($candidates as $a) { |
|
$topics = $a->getTopics(); |
|
if (!\is_array($topics) || $topics === []) { |
|
continue; |
|
} |
|
foreach ($topics as $t) { |
|
if (!\is_string($t)) { |
|
continue; |
|
} |
|
$k = $this->normalizeTopicLabel($t); |
|
if ($k === $topicKey) { |
|
$out[] = $a; |
|
break; |
|
} |
|
} |
|
} |
|
|
|
return $out; |
|
} |
|
|
|
/** |
|
* Public key for {@see findPublishedByTopic} and generating `/topic/…` URLs. |
|
*/ |
|
public function normalizeTopicParam(string $topic): string |
|
{ |
|
return $this->normalizeTopicLabel($topic); |
|
} |
|
|
|
private function normalizeTopicLabel(string $topic): string |
|
{ |
|
$t = \strtolower(\trim($topic)); |
|
if ($t === '') { |
|
return ''; |
|
} |
|
if (\str_starts_with($t, '#')) { |
|
$t = ltrim($t, '#'); |
|
} |
|
|
|
return \trim($t); |
|
} |
|
|
|
private function tenantQueryBuilder(string $alias = 'a'): QueryBuilder |
|
{ |
|
$qb = $this->createQueryBuilder($alias); |
|
$qb->innerJoin( |
|
'App\Entity\ArticleMagazine', |
|
'am', |
|
'WITH', |
|
'am.article = '.$alias.' AND am.magazineSlug = :_magazine_slug' |
|
); |
|
$qb->setParameter('_magazine_slug', $this->tenant->getMagazineSlug()); |
|
|
|
return $qb; |
|
} |
|
}
|
|
|