10 changed files with 804 additions and 6 deletions
@ -0,0 +1,90 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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