diff --git a/assets/controllers/highlights_toggle_controller.js b/assets/controllers/highlights_toggle_controller.js new file mode 100644 index 0000000..415935e --- /dev/null +++ b/assets/controllers/highlights_toggle_controller.js @@ -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')); + } + } +} diff --git a/assets/styles/04-pages/highlights.css b/assets/styles/04-pages/highlights.css index ef45338..8098a22 100644 --- a/assets/styles/04-pages/highlights.css +++ b/assets/styles/04-pages/highlights.css @@ -99,6 +99,39 @@ margin-bottom: 1.5rem; } +/* Article inline highlights */ +.article-highlight { + background: transparent; + border-bottom: 2px solid transparent; + padding: 0.1em 0; + transition: all 0.3s ease; + cursor: help; +} + +.article-highlight.visible { + background: linear-gradient(to bottom, + rgba(255, 237, 74, 0.3) 0%, + rgba(255, 237, 74, 0.5) 100%); + border-bottom: 2px solid rgba(255, 200, 0, 0.6); +} + +.article-highlight.visible:hover { + background: linear-gradient(to bottom, + rgba(255, 237, 74, 0.5) 0%, + rgba(255, 237, 74, 0.7) 100%); + border-bottom-color: rgba(255, 200, 0, 0.8); +} + +/* Toggle button active state */ +.btn[aria-pressed="true"] { + background-color: rgba(255, 237, 74, 0.3); + border-color: rgba(255, 200, 0, 0.6); +} + +.btn[aria-pressed="true"]:hover { + background-color: rgba(255, 237, 74, 0.5); +} + @media (max-width: 768px) { .highlights-grid { column-count: 1; diff --git a/migrations/Version20251107102457.php b/migrations/Version20251107102457.php new file mode 100644 index 0000000..f9897c0 --- /dev/null +++ b/migrations/Version20251107102457.php @@ -0,0 +1,37 @@ +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)\''); + } +} diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 8235738..4971310 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -6,6 +6,7 @@ use App\Dto\AdvancedMetadata; use App\Entity\Article; use App\Enum\KindsEnum; use App\Form\EditorType; +use App\Service\HighlightService; use App\Service\NostrClient; use App\Service\Nostr\NostrEventBuilder; use App\Service\Nostr\NostrEventParser; @@ -72,7 +73,8 @@ class ArticleController extends AbstractController EntityManagerInterface $entityManager, RedisCacheService $redisCacheService, CacheItemPoolInterface $articlesCache, - Converter $converter + Converter $converter, + HighlightService $highlightService ): Response { @@ -126,13 +128,27 @@ class ArticleController extends AbstractController $canonical = $this->generateUrl('article-slug', ['slug' => $article->getSlug()], 0); + // Fetch highlights using the caching service + $highlights = []; + try { + $articleCoordinate = '30023:' . $article->getPubkey() . ':' . $article->getSlug(); + error_log('ArticleController: Looking for highlights with coordinate: ' . $articleCoordinate); + $highlights = $highlightService->getHighlightsForArticle($articleCoordinate); + error_log('ArticleController: Found ' . count($highlights) . ' highlights'); + } catch (\Exception $e) { + // Log but don't fail the page if highlights can't be fetched + // Highlights are optional enhancement + error_log('ArticleController: Failed to fetch highlights: ' . $e->getMessage()); + } + return $this->render('pages/article.html.twig', [ 'article' => $article, 'author' => $author, 'npub' => $npub, 'content' => $cacheItem->get(), 'canEdit' => $canEdit, - 'canonical' => $canonical + 'canonical' => $canonical, + 'highlights' => $highlights ]); } diff --git a/src/Controller/HighlightsController.php b/src/Controller/HighlightsController.php index 165c4f7..012ab09 100644 --- a/src/Controller/HighlightsController.php +++ b/src/Controller/HighlightsController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Controller; +use App\Service\HighlightService; use App\Service\NostrClient; use nostriphant\NIP19\Bech32; use Psr\Log\LoggerInterface; @@ -20,6 +21,7 @@ class HighlightsController extends AbstractController public function __construct( private readonly NostrClient $nostrClient, + private readonly HighlightService $highlightService, private readonly LoggerInterface $logger, ) {} @@ -38,7 +40,10 @@ class HighlightsController extends AbstractController // Fetch highlights that reference articles (kind 30023) $events = $this->nostrClient->getArticleHighlights(self::MAX_DISPLAY_HIGHLIGHTS); - // Process and enrich the highlights + // Save raw events to database first (group by article) + $this->saveHighlightsToDatabase($events); + + // Process and enrich the highlights for display return $this->processHighlights($events); } catch (\Exception $e) { $this->logger->error('Failed to fetch highlights', [ @@ -66,6 +71,52 @@ class HighlightsController extends AbstractController } } + /** + * Save highlights to database grouped by article coordinate + */ + private function saveHighlightsToDatabase(array $events): void + { + // Group events by article coordinate + $eventsByArticle = []; + + foreach ($events as $event) { + // Extract article coordinate from tags + foreach ($event->tags ?? [] as $tag) { + if (!is_array($tag) || count($tag) < 2) { + continue; + } + + if (in_array($tag[0], ['a', 'A'])) { + $coordinate = $tag[1] ?? null; + if ($coordinate && str_starts_with($coordinate, '30023:')) { + if (!isset($eventsByArticle[$coordinate])) { + $eventsByArticle[$coordinate] = []; + } + $eventsByArticle[$coordinate][] = $event; + break; + } + } + } + } + + // Save each article's highlights to database + foreach ($eventsByArticle as $coordinate => $articleEvents) { + try { + $this->highlightService->saveEventsToDatabase($coordinate, $articleEvents); + + $this->logger->debug('Saved highlights to database', [ + 'coordinate' => $coordinate, + 'count' => count($articleEvents) + ]); + } catch (\Exception $e) { + $this->logger->warning('Failed to save highlights to database', [ + 'coordinate' => $coordinate, + 'error' => $e->getMessage() + ]); + } + } + } + /** * Process highlights to extract metadata */ diff --git a/src/Entity/Highlight.php b/src/Entity/Highlight.php new file mode 100644 index 0000000..c5a86a8 --- /dev/null +++ b/src/Entity/Highlight.php @@ -0,0 +1,146 @@ +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; + } +} + diff --git a/src/Repository/HighlightRepository.php b/src/Repository/HighlightRepository.php new file mode 100644 index 0000000..63069b8 --- /dev/null +++ b/src/Repository/HighlightRepository.php @@ -0,0 +1,98 @@ +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(); + } +} + diff --git a/src/Service/HighlightService.php b/src/Service/HighlightService.php new file mode 100644 index 0000000..b290ae0 --- /dev/null +++ b/src/Service/HighlightService.php @@ -0,0 +1,268 @@ +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) + ]; + } +} diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index 572f8ea..8784d9e 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -1004,6 +1004,47 @@ class NostrClient return array_values($uniqueEvents); } + /** + * Get highlights for a specific article + * @throws \Exception + */ + public function getHighlightsForArticle(string $articleCoordinate, int $limit = 100): array + { + $this->logger->info('Fetching highlights for article', ['coordinate' => $articleCoordinate]); + + // Use relay pool to send request + $subscription = new Subscription(); + $subscriptionId = $subscription->setId(); + $filter = new Filter(); + $filter->setKinds([9802]); // NIP-84 highlights + $filter->setLimit($limit); + // Add tag filter for the specific article coordinate + $filter->setTags(['a' => [$articleCoordinate]]); + + $requestMessage = new RequestMessage($subscriptionId, [$filter]); + + // Get default relay URLs + $relayUrls = $this->relayPool->getDefaultRelays(); + + // Use the relay pool to send the request + $responses = $this->relayPool->sendToRelays( + $relayUrls, + fn() => $requestMessage, + 30, + $subscriptionId + ); + + // Process the response and deduplicate by eventId + $uniqueEvents = []; + $this->processResponse($responses, function($event) use (&$uniqueEvents) { + $this->logger->debug('Received highlight event for article', ['event_id' => $event->id]); + $uniqueEvents[$event->id] = $event; + return null; + }); + + return array_values($uniqueEvents); + } + private function createNostrRequest(array $kinds, array $filters = [], ?RelaySet $relaySet = null, $stopGap = null ): TweakedRequest { $subscription = new Subscription(); diff --git a/templates/pages/article.html.twig b/templates/pages/article.html.twig index 1f7add0..57716ba 100644 --- a/templates/pages/article.html.twig +++ b/templates/pages/article.html.twig @@ -16,7 +16,7 @@ {% endif %} -
+
@@ -94,7 +109,7 @@ {% else %} {# #} - {{ article.createdAt|date('F j, Y') }}
+ {{ article.createdAt|date('F j, Y') }} {% endif %}
@@ -111,7 +126,10 @@ {% endif %} -
+
0 %} + data-highlights="{{ highlights|json_encode|e('html_attr') }}" + {% endif %}> {{ content|raw }}