10 changed files with 804 additions and 6 deletions
@ -0,0 +1,90 @@ |
|||||||
|
import { Controller } from '@hotwired/stimulus'; |
||||||
|
export default class extends Controller { |
||||||
|
static targets = ['button']; |
||||||
|
connect() { |
||||||
|
this.articleMain = this.element.querySelector('.article-main'); |
||||||
|
if (!this.articleMain) return; |
||||||
|
const highlightsData = this.articleMain.getAttribute('data-highlights'); |
||||||
|
if (highlightsData) { |
||||||
|
try { |
||||||
|
this.highlights = JSON.parse(highlightsData); |
||||||
|
this.applyHighlights(); |
||||||
|
} catch (e) { |
||||||
|
console.error('Failed to parse highlights data:', e); |
||||||
|
} |
||||||
|
} |
||||||
|
// Check if user has a saved preference
|
||||||
|
const enabled = localStorage.getItem('highlights-enabled') === 'true'; |
||||||
|
this.setHighlightsVisibility(enabled); |
||||||
|
} |
||||||
|
applyHighlights() { |
||||||
|
if (!this.highlights || this.highlights.length === 0) return; |
||||||
|
// Get all text nodes in the article
|
||||||
|
const walker = document.createTreeWalker( |
||||||
|
this.articleMain, |
||||||
|
NodeFilter.SHOW_TEXT, |
||||||
|
null, |
||||||
|
false |
||||||
|
); |
||||||
|
const textNodes = []; |
||||||
|
let node; |
||||||
|
while (node = walker.nextNode()) { |
||||||
|
// Skip if parent is already a highlight mark
|
||||||
|
if (node.parentElement.classList.contains('article-highlight')) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
textNodes.push(node); |
||||||
|
} |
||||||
|
// For each highlight, find and wrap matching text
|
||||||
|
this.highlights.forEach((highlight, index) => { |
||||||
|
const searchText = highlight.content.trim(); |
||||||
|
if (!searchText) return; |
||||||
|
textNodes.forEach(textNode => { |
||||||
|
const text = textNode.textContent; |
||||||
|
const startIndex = text.indexOf(searchText); |
||||||
|
if (startIndex !== -1) { |
||||||
|
// Split the text node and wrap the match
|
||||||
|
const range = document.createRange(); |
||||||
|
range.setStart(textNode, startIndex); |
||||||
|
range.setEnd(textNode, startIndex + searchText.length); |
||||||
|
const mark = document.createElement('mark'); |
||||||
|
mark.className = 'article-highlight'; |
||||||
|
mark.setAttribute('data-highlights-toggle-target', 'highlight'); |
||||||
|
mark.setAttribute('title', 'Highlighted by others (' + new Date(highlight.created_at * 1000).toLocaleDateString() + ')'); |
||||||
|
try { |
||||||
|
range.surroundContents(mark); |
||||||
|
} catch (e) { |
||||||
|
// If surroundContents fails (e.g., spans multiple nodes), try extraction
|
||||||
|
try { |
||||||
|
const extracted = range.extractContents(); |
||||||
|
mark.appendChild(extracted); |
||||||
|
range.insertNode(mark); |
||||||
|
} catch (e2) { |
||||||
|
console.warn('Failed to highlight text:', searchText, e2); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
||||||
|
}); |
||||||
|
} |
||||||
|
toggle() { |
||||||
|
if (!this.hasButtonTarget) return; |
||||||
|
const currentlyEnabled = this.buttonTarget.getAttribute('aria-pressed') === 'true'; |
||||||
|
const newState = !currentlyEnabled; |
||||||
|
this.setHighlightsVisibility(newState); |
||||||
|
// Save preference
|
||||||
|
localStorage.setItem('highlights-enabled', newState.toString()); |
||||||
|
} |
||||||
|
setHighlightsVisibility(enabled) { |
||||||
|
if (!this.hasButtonTarget) return; |
||||||
|
this.buttonTarget.setAttribute('aria-pressed', enabled.toString()); |
||||||
|
const highlightElements = this.element.querySelectorAll('.article-highlight'); |
||||||
|
if (enabled) { |
||||||
|
this.buttonTarget.classList.add('active'); |
||||||
|
highlightElements.forEach(el => el.classList.add('visible')); |
||||||
|
} else { |
||||||
|
this.buttonTarget.classList.remove('active'); |
||||||
|
highlightElements.forEach(el => el.classList.remove('visible')); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,37 @@ |
|||||||
|
<?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 Version20251107102457 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('CREATE TABLE highlight (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, event_id VARCHAR(255) NOT NULL, article_coordinate TEXT NOT NULL, content TEXT NOT NULL, pubkey VARCHAR(255) NOT NULL, created_at INT NOT NULL, context TEXT DEFAULT NULL, cached_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, raw_event JSON DEFAULT NULL, PRIMARY KEY (id))'); |
||||||
|
$this->addSql('CREATE UNIQUE INDEX UNIQ_C998D83471F7E88B ON highlight (event_id)'); |
||||||
|
$this->addSql('CREATE INDEX idx_article_coordinate ON highlight (article_coordinate)'); |
||||||
|
$this->addSql('CREATE INDEX idx_event_id ON highlight (event_id)'); |
||||||
|
$this->addSql('CREATE INDEX idx_created_at ON highlight (created_at)'); |
||||||
|
$this->addSql('COMMENT ON COLUMN nzine.last_fetched_at IS \'\''); |
||||||
|
} |
||||||
|
|
||||||
|
public function down(Schema $schema): void |
||||||
|
{ |
||||||
|
// this down() migration is auto-generated, please modify it to your needs |
||||||
|
$this->addSql('DROP TABLE highlight'); |
||||||
|
$this->addSql('COMMENT ON COLUMN nzine.last_fetched_at IS \'(DC2Type:datetime_immutable)\''); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,146 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Entity; |
||||||
|
|
||||||
|
use App\Repository\HighlightRepository; |
||||||
|
use Doctrine\DBAL\Types\Types; |
||||||
|
use Doctrine\ORM\Mapping as ORM; |
||||||
|
|
||||||
|
/** |
||||||
|
* Entity storing article highlights (NIP-84, kind 9802) |
||||||
|
* Cached from Nostr relays for performance |
||||||
|
*/ |
||||||
|
#[ORM\Entity(repositoryClass: HighlightRepository::class)] |
||||||
|
#[ORM\Index(columns: ['article_coordinate'], name: 'idx_article_coordinate')] |
||||||
|
#[ORM\Index(columns: ['event_id'], name: 'idx_event_id')] |
||||||
|
#[ORM\Index(columns: ['created_at'], name: 'idx_created_at')] |
||||||
|
class Highlight |
||||||
|
{ |
||||||
|
#[ORM\Id] |
||||||
|
#[ORM\GeneratedValue] |
||||||
|
#[ORM\Column] |
||||||
|
private ?int $id = null; |
||||||
|
|
||||||
|
#[ORM\Column(length: 255, unique: true)] |
||||||
|
private ?string $eventId = null; |
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT)] |
||||||
|
private ?string $articleCoordinate = null; |
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT)] |
||||||
|
private ?string $content = null; |
||||||
|
|
||||||
|
#[ORM\Column(length: 255)] |
||||||
|
private ?string $pubkey = null; |
||||||
|
|
||||||
|
#[ORM\Column(type: Types::INTEGER)] |
||||||
|
private ?int $createdAt = null; |
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)] |
||||||
|
private ?string $context = null; |
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)] |
||||||
|
private ?\DateTimeImmutable $cachedAt = null; |
||||||
|
|
||||||
|
#[ORM\Column(type: Types::JSON, nullable: true)] |
||||||
|
private ?array $rawEvent = null; |
||||||
|
|
||||||
|
public function __construct() |
||||||
|
{ |
||||||
|
$this->cachedAt = new \DateTimeImmutable(); |
||||||
|
} |
||||||
|
|
||||||
|
public function getId(): ?int |
||||||
|
{ |
||||||
|
return $this->id; |
||||||
|
} |
||||||
|
|
||||||
|
public function getEventId(): ?string |
||||||
|
{ |
||||||
|
return $this->eventId; |
||||||
|
} |
||||||
|
|
||||||
|
public function setEventId(string $eventId): static |
||||||
|
{ |
||||||
|
$this->eventId = $eventId; |
||||||
|
return $this; |
||||||
|
} |
||||||
|
|
||||||
|
public function getArticleCoordinate(): ?string |
||||||
|
{ |
||||||
|
return $this->articleCoordinate; |
||||||
|
} |
||||||
|
|
||||||
|
public function setArticleCoordinate(string $articleCoordinate): static |
||||||
|
{ |
||||||
|
$this->articleCoordinate = $articleCoordinate; |
||||||
|
return $this; |
||||||
|
} |
||||||
|
|
||||||
|
public function getContent(): ?string |
||||||
|
{ |
||||||
|
return $this->content; |
||||||
|
} |
||||||
|
|
||||||
|
public function setContent(string $content): static |
||||||
|
{ |
||||||
|
$this->content = $content; |
||||||
|
return $this; |
||||||
|
} |
||||||
|
|
||||||
|
public function getPubkey(): ?string |
||||||
|
{ |
||||||
|
return $this->pubkey; |
||||||
|
} |
||||||
|
|
||||||
|
public function setPubkey(string $pubkey): static |
||||||
|
{ |
||||||
|
$this->pubkey = $pubkey; |
||||||
|
return $this; |
||||||
|
} |
||||||
|
|
||||||
|
public function getCreatedAt(): ?int |
||||||
|
{ |
||||||
|
return $this->createdAt; |
||||||
|
} |
||||||
|
|
||||||
|
public function setCreatedAt(int $createdAt): static |
||||||
|
{ |
||||||
|
$this->createdAt = $createdAt; |
||||||
|
return $this; |
||||||
|
} |
||||||
|
|
||||||
|
public function getContext(): ?string |
||||||
|
{ |
||||||
|
return $this->context; |
||||||
|
} |
||||||
|
|
||||||
|
public function setContext(?string $context): static |
||||||
|
{ |
||||||
|
$this->context = $context; |
||||||
|
return $this; |
||||||
|
} |
||||||
|
|
||||||
|
public function getCachedAt(): ?\DateTimeImmutable |
||||||
|
{ |
||||||
|
return $this->cachedAt; |
||||||
|
} |
||||||
|
|
||||||
|
public function setCachedAt(\DateTimeImmutable $cachedAt): static |
||||||
|
{ |
||||||
|
$this->cachedAt = $cachedAt; |
||||||
|
return $this; |
||||||
|
} |
||||||
|
|
||||||
|
public function getRawEvent(): ?array |
||||||
|
{ |
||||||
|
return $this->rawEvent; |
||||||
|
} |
||||||
|
|
||||||
|
public function setRawEvent(?array $rawEvent): static |
||||||
|
{ |
||||||
|
$this->rawEvent = $rawEvent; |
||||||
|
return $this; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,98 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Repository; |
||||||
|
|
||||||
|
use App\Entity\Highlight; |
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; |
||||||
|
use Doctrine\Persistence\ManagerRegistry; |
||||||
|
|
||||||
|
class HighlightRepository extends ServiceEntityRepository |
||||||
|
{ |
||||||
|
public function __construct(ManagerRegistry $registry) |
||||||
|
{ |
||||||
|
parent::__construct($registry, Highlight::class); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Find highlights for a specific article coordinate |
||||||
|
*/ |
||||||
|
public function findByArticleCoordinate(string $articleCoordinate): array |
||||||
|
{ |
||||||
|
$qb = $this->createQueryBuilder('h') |
||||||
|
->where('h.articleCoordinate = :coordinate') |
||||||
|
->setParameter('coordinate', $articleCoordinate) |
||||||
|
->orderBy('h.createdAt', 'DESC'); |
||||||
|
|
||||||
|
// Debug: log the query and parameters |
||||||
|
error_log('Querying highlights for coordinate: ' . $articleCoordinate); |
||||||
|
error_log('SQL: ' . $qb->getQuery()->getSQL()); |
||||||
|
|
||||||
|
return $qb->getQuery()->getResult(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Debug: Get all unique article coordinates in the database |
||||||
|
*/ |
||||||
|
public function getAllArticleCoordinates(): array |
||||||
|
{ |
||||||
|
$results = $this->createQueryBuilder('h') |
||||||
|
->select('DISTINCT h.articleCoordinate') |
||||||
|
->getQuery() |
||||||
|
->getResult(); |
||||||
|
|
||||||
|
return array_map(fn($r) => $r['articleCoordinate'], $results); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if we need to refresh highlights for an article |
||||||
|
* Returns true if cache is older than the specified hours |
||||||
|
*/ |
||||||
|
public function needsRefresh(string $articleCoordinate, int $hoursOld = 24): bool |
||||||
|
{ |
||||||
|
$cutoff = new \DateTimeImmutable("-{$hoursOld} hours"); |
||||||
|
|
||||||
|
$count = $this->createQueryBuilder('h') |
||||||
|
->select('COUNT(h.id)') |
||||||
|
->where('h.articleCoordinate = :coordinate') |
||||||
|
->andWhere('h.cachedAt > :cutoff') |
||||||
|
->setParameter('coordinate', $articleCoordinate) |
||||||
|
->setParameter('cutoff', $cutoff) |
||||||
|
->getQuery() |
||||||
|
->getSingleScalarResult(); |
||||||
|
|
||||||
|
return $count === 0; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get the most recent cache time for an article |
||||||
|
*/ |
||||||
|
public function getLastCacheTime(string $articleCoordinate): ?\DateTimeImmutable |
||||||
|
{ |
||||||
|
$result = $this->createQueryBuilder('h') |
||||||
|
->select('h.cachedAt') |
||||||
|
->where('h.articleCoordinate = :coordinate') |
||||||
|
->setParameter('coordinate', $articleCoordinate) |
||||||
|
->orderBy('h.cachedAt', 'DESC') |
||||||
|
->setMaxResults(1) |
||||||
|
->getQuery() |
||||||
|
->getOneOrNullResult(); |
||||||
|
|
||||||
|
return $result ? $result['cachedAt'] : null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Delete old highlights for an article (before refreshing) |
||||||
|
*/ |
||||||
|
public function deleteOldHighlights(string $articleCoordinate, \DateTimeImmutable $before): int |
||||||
|
{ |
||||||
|
return $this->createQueryBuilder('h') |
||||||
|
->delete() |
||||||
|
->where('h.articleCoordinate = :coordinate') |
||||||
|
->andWhere('h.cachedAt < :before') |
||||||
|
->setParameter('coordinate', $articleCoordinate) |
||||||
|
->setParameter('before', $before) |
||||||
|
->getQuery() |
||||||
|
->execute(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,268 @@ |
|||||||
|
<?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) |
||||||
|
]); |
||||||
|
|
||||||
|
return array_map(function (Highlight $highlight) { |
||||||
|
return [ |
||||||
|
'content' => $highlight->getContent(), |
||||||
|
'created_at' => $highlight->getCreatedAt(), |
||||||
|
'pubkey' => $highlight->getPubkey(), |
||||||
|
'context' => $highlight->getContext(), |
||||||
|
]; |
||||||
|
}, $highlights); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 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) |
||||||
|
]; |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue