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.
290 lines
10 KiB
290 lines
10 KiB
<?php |
|
|
|
namespace App\Service; |
|
|
|
use App\Entity\Highlight; |
|
use App\Repository\HighlightRepository; |
|
use Doctrine\ORM\EntityManagerInterface; |
|
use Psr\Log\LoggerInterface; |
|
|
|
class HighlightService |
|
{ |
|
private const CACHE_DURATION_HOURS = 24; // Refresh every 24 hours |
|
private const TOP_OFF_DURATION_HOURS = 1; // Top off every hour |
|
|
|
public function __construct( |
|
private readonly EntityManagerInterface $entityManager, |
|
private readonly HighlightRepository $highlightRepository, |
|
private readonly NostrClient $nostrClient, |
|
private readonly LoggerInterface $logger |
|
) {} |
|
|
|
/** |
|
* Get highlights for an article, using cache when possible |
|
*/ |
|
public function getHighlightsForArticle(string $articleCoordinate): array |
|
{ |
|
$this->logger->info('Getting highlights for article', [ |
|
'coordinate' => $articleCoordinate, |
|
'coordinate_length' => strlen($articleCoordinate) |
|
]); |
|
|
|
// Check if we need a full refresh (cache is stale) |
|
$needsFullRefresh = $this->highlightRepository->needsRefresh($articleCoordinate, self::CACHE_DURATION_HOURS); |
|
|
|
$this->logger->info('Cache refresh check', [ |
|
'coordinate' => $articleCoordinate, |
|
'needs_refresh' => $needsFullRefresh |
|
]); |
|
|
|
if ($needsFullRefresh) { |
|
$this->logger->info('Full refresh needed for highlights', ['coordinate' => $articleCoordinate]); |
|
return $this->fullRefresh($articleCoordinate); |
|
} |
|
|
|
// Check if we should top off with recent highlights |
|
$lastCacheTime = $this->highlightRepository->getLastCacheTime($articleCoordinate); |
|
$shouldTopOff = false; |
|
|
|
if ($lastCacheTime) { |
|
$hoursSinceLastCache = (new \DateTimeImmutable())->getTimestamp() - $lastCacheTime->getTimestamp(); |
|
$shouldTopOff = ($hoursSinceLastCache / 3600) >= self::TOP_OFF_DURATION_HOURS; |
|
} |
|
|
|
if ($shouldTopOff) { |
|
$this->logger->info('Topping off highlights with recent data', ['coordinate' => $articleCoordinate]); |
|
$this->topOffHighlights($articleCoordinate); |
|
} |
|
|
|
// Return cached highlights |
|
$cached = $this->getCachedHighlights($articleCoordinate); |
|
$this->logger->info('Returning cached highlights', [ |
|
'coordinate' => $articleCoordinate, |
|
'count' => count($cached) |
|
]); |
|
return $cached; |
|
} |
|
|
|
/** |
|
* Get cached highlights from database |
|
*/ |
|
private function getCachedHighlights(string $articleCoordinate): array |
|
{ |
|
$highlights = $this->highlightRepository->findByArticleCoordinate($articleCoordinate); |
|
|
|
$this->logger->info('Found highlights in database', [ |
|
'coordinate' => $articleCoordinate, |
|
'count' => count($highlights) |
|
]); |
|
|
|
$mappedHighlights = array_map(function (Highlight $highlight) { |
|
return [ |
|
'content' => $highlight->getContent(), |
|
'created_at' => $highlight->getCreatedAt(), |
|
'pubkey' => $highlight->getPubkey(), |
|
'context' => $highlight->getContext(), |
|
]; |
|
}, $highlights); |
|
|
|
// Deduplicate highlights based on content and pubkey |
|
// Keep the most recent highlight for each content+pubkey combination |
|
$deduplicated = []; |
|
$seen = []; |
|
|
|
foreach ($mappedHighlights as $highlight) { |
|
$key = md5($highlight['content'] . '|' . $highlight['pubkey']); |
|
|
|
if (!isset($seen[$key])) { |
|
$seen[$key] = true; |
|
$deduplicated[] = $highlight; |
|
} |
|
} |
|
|
|
$this->logger->info('Deduplicated highlights', [ |
|
'coordinate' => $articleCoordinate, |
|
'original_count' => count($mappedHighlights), |
|
'deduplicated_count' => count($deduplicated) |
|
]); |
|
|
|
return $deduplicated; |
|
} |
|
|
|
/** |
|
* Full refresh - fetch all highlights from relays and update cache |
|
*/ |
|
private function fullRefresh(string $articleCoordinate): array |
|
{ |
|
try { |
|
// Fetch from Nostr |
|
$highlightEvents = $this->nostrClient->getHighlightsForArticle($articleCoordinate); |
|
|
|
// Clear old cache |
|
$cutoff = new \DateTimeImmutable('-30 days'); |
|
$this->highlightRepository->deleteOldHighlights($articleCoordinate, $cutoff); |
|
|
|
// Save new highlights |
|
$this->saveHighlights($articleCoordinate, $highlightEvents); |
|
|
|
// Return the fresh data |
|
return $this->getCachedHighlights($articleCoordinate); |
|
|
|
} catch (\Exception $e) { |
|
$this->logger->error('Failed to refresh highlights', [ |
|
'coordinate' => $articleCoordinate, |
|
'error' => $e->getMessage() |
|
]); |
|
|
|
// Return cached data even if stale |
|
return $this->getCachedHighlights($articleCoordinate); |
|
} |
|
} |
|
|
|
/** |
|
* Top off - fetch only recent highlights to supplement cache |
|
*/ |
|
private function topOffHighlights(string $articleCoordinate): void |
|
{ |
|
try { |
|
$lastCacheTime = $this->highlightRepository->getLastCacheTime($articleCoordinate); |
|
|
|
// Fetch recent highlights (since last cache time) |
|
$highlightEvents = $this->nostrClient->getHighlightsForArticle($articleCoordinate, 50); |
|
|
|
// Filter to only new events (created after last cache) |
|
if ($lastCacheTime) { |
|
$cutoffTimestamp = $lastCacheTime->getTimestamp(); |
|
$highlightEvents = array_filter($highlightEvents, function ($event) use ($cutoffTimestamp) { |
|
return ($event->created_at ?? 0) > $cutoffTimestamp; |
|
}); |
|
} |
|
|
|
if (!empty($highlightEvents)) { |
|
$this->logger->info('Adding new highlights to cache', [ |
|
'coordinate' => $articleCoordinate, |
|
'count' => count($highlightEvents) |
|
]); |
|
|
|
$this->saveHighlights($articleCoordinate, $highlightEvents); |
|
} |
|
|
|
} catch (\Exception $e) { |
|
$this->logger->warning('Failed to top off highlights', [ |
|
'coordinate' => $articleCoordinate, |
|
'error' => $e->getMessage() |
|
]); |
|
// Non-critical, just log and continue |
|
} |
|
} |
|
|
|
/** |
|
* Save highlight events to database |
|
*/ |
|
private function saveHighlights(string $articleCoordinate, array $highlightEvents): void |
|
{ |
|
$this->logger->info('Saving highlights to database', [ |
|
'coordinate' => $articleCoordinate, |
|
'event_count' => count($highlightEvents) |
|
]); |
|
|
|
$saved = 0; |
|
$updated = 0; |
|
|
|
foreach ($highlightEvents as $event) { |
|
// Check if this event already exists |
|
$existing = $this->highlightRepository->findOneBy(['eventId' => $event->id]); |
|
if ($existing) { |
|
// Update cached_at timestamp |
|
$existing->setCachedAt(new \DateTimeImmutable()); |
|
$updated++; |
|
continue; |
|
} |
|
|
|
// Create new highlight entity |
|
$highlight = new Highlight(); |
|
$highlight->setEventId($event->id); |
|
$highlight->setArticleCoordinate($articleCoordinate); |
|
$highlight->setContent($event->content ?? ''); |
|
$highlight->setPubkey($event->pubkey ?? ''); |
|
$highlight->setCreatedAt($event->created_at ?? time()); |
|
|
|
// Extract context from tags if available |
|
foreach ($event->tags ?? [] as $tag) { |
|
if (is_array($tag) && count($tag) >= 2 && $tag[0] === 'context') { |
|
$highlight->setContext($tag[1]); |
|
break; |
|
} |
|
} |
|
|
|
// Store raw event for potential future use |
|
$highlight->setRawEvent((array) $event); |
|
|
|
if (!empty($highlight->getContent())) { |
|
$this->entityManager->persist($highlight); |
|
$saved++; |
|
|
|
$this->logger->debug('Persisting highlight', [ |
|
'event_id' => $event->id, |
|
'coordinate' => $articleCoordinate, |
|
'content_length' => strlen($highlight->getContent()) |
|
]); |
|
} |
|
} |
|
|
|
$this->entityManager->flush(); |
|
|
|
$this->logger->info('Highlights saved to database', [ |
|
'coordinate' => $articleCoordinate, |
|
'new_saved' => $saved, |
|
'updated' => $updated |
|
]); |
|
} |
|
|
|
/** |
|
* Force refresh highlights for a specific article (for admin use) |
|
*/ |
|
public function forceRefresh(string $articleCoordinate): array |
|
{ |
|
return $this->fullRefresh($articleCoordinate); |
|
} |
|
|
|
/** |
|
* Save events directly to database (used by HighlightsController) |
|
* This is public so we can save highlights as we discover them |
|
*/ |
|
public function saveEventsToDatabase(string $articleCoordinate, array $events): void |
|
{ |
|
$this->saveHighlights($articleCoordinate, $events); |
|
} |
|
|
|
/** |
|
* Debug method: Get all coordinates in database |
|
*/ |
|
public function getAllStoredCoordinates(): array |
|
{ |
|
return $this->highlightRepository->getAllArticleCoordinates(); |
|
} |
|
|
|
/** |
|
* Debug method: Get raw highlights from database |
|
*/ |
|
public function getDebugInfo(string $articleCoordinate): array |
|
{ |
|
$highlights = $this->highlightRepository->findByArticleCoordinate($articleCoordinate); |
|
$lastCache = $this->highlightRepository->getLastCacheTime($articleCoordinate); |
|
$needsRefresh = $this->highlightRepository->needsRefresh($articleCoordinate); |
|
|
|
return [ |
|
'coordinate' => $articleCoordinate, |
|
'count' => count($highlights), |
|
'last_cache' => $lastCache?->format('Y-m-d H:i:s'), |
|
'needs_refresh' => $needsRefresh, |
|
'highlights' => array_map(fn($h) => [ |
|
'event_id' => $h->getEventId(), |
|
'content' => substr($h->getContent(), 0, 100), |
|
'pubkey' => substr($h->getPubkey(), 0, 16), |
|
'created_at' => $h->getCreatedAt(), |
|
'cached_at' => $h->getCachedAt()->format('Y-m-d H:i:s'), |
|
], $highlights) |
|
]; |
|
} |
|
}
|
|
|