23 changed files with 1736 additions and 106 deletions
@ -0,0 +1,288 @@
@@ -0,0 +1,288 @@
|
||||
import { Controller } from '@hotwired/stimulus'; |
||||
|
||||
const MARK_SEL = 'mark.article-body-highlight'; |
||||
|
||||
/** |
||||
* In-article NIP-84 marks: show popover on hover / click; #h-<64-hex> scrolls and focuses the mark. |
||||
*/ |
||||
export default class extends Controller { |
||||
static targets = ['article', 'meta', 'popover', 'popoverInner']; |
||||
|
||||
connect() { |
||||
this._metaById = {}; |
||||
if (this.hasMetaTarget) { |
||||
try { |
||||
this._metaById = JSON.parse(this.metaTarget.textContent || '{}'); |
||||
} catch { |
||||
this._metaById = {}; |
||||
} |
||||
} |
||||
this._pinnedId = null; |
||||
this._openId = null; |
||||
this._hoverLeaveTimer = null; |
||||
this._onHash = () => { |
||||
this._scrollToHash(); |
||||
}; |
||||
this._onScrollReposition = () => { |
||||
if (this._openId && this.hasPopoverTarget) { |
||||
const a = this._getMarkByEventId(this._openId); |
||||
if (a) { |
||||
this._placePopover(a); |
||||
} |
||||
} |
||||
}; |
||||
this._onPopoverPointerEnter = () => { |
||||
this._clearHoverLeaveTimer(); |
||||
}; |
||||
this._onPopoverPointerLeave = () => { |
||||
if (!this._pinnedId) { |
||||
this._hoverLeaveTimer = setTimeout(() => { |
||||
this._hoverLeaveTimer = null; |
||||
this._hideUnpinnedPopover(); |
||||
}, 200); |
||||
} |
||||
}; |
||||
|
||||
this._onMarkPointerEnter = (e) => { |
||||
const a = e.target && e.target.closest && e.target.closest(MARK_SEL); |
||||
if (!a) { |
||||
return; |
||||
} |
||||
this._clearHoverLeaveTimer(); |
||||
this._openForMark(a, false); |
||||
}; |
||||
this._onMarkPointerLeave = (e) => { |
||||
const a = e.target && e.target.closest && e.target.closest(MARK_SEL); |
||||
if (!a) { |
||||
return; |
||||
} |
||||
this._onMarkLeave(a); |
||||
}; |
||||
this._onMarkClick = (e) => { |
||||
const a = e.target && e.target.closest && e.target.closest(MARK_SEL); |
||||
if (!a) { |
||||
return; |
||||
} |
||||
e.preventDefault(); |
||||
e.stopPropagation(); |
||||
const id = (a.getAttribute('data-event-id') || '').toLowerCase(); |
||||
if (this._pinnedId === id) { |
||||
this._closePinned(); |
||||
this._hideUnpinnedPopover(); |
||||
return; |
||||
} |
||||
this._openForMark(a, true); |
||||
}; |
||||
this._onMarkFocus = (e) => { |
||||
const t = e.target; |
||||
if (!t || !t.classList || !t.classList.contains('article-body-highlight')) { |
||||
return; |
||||
} |
||||
this._openForMark(t, false); |
||||
}; |
||||
this._onMarkKeydown = (e) => { |
||||
if (e.key !== 'Enter' && e.key !== ' ') { |
||||
return; |
||||
} |
||||
const t = e.target; |
||||
if (!t || !t.classList || !t.classList.contains('article-body-highlight')) { |
||||
return; |
||||
} |
||||
e.preventDefault(); |
||||
this._openForMark(t, true); |
||||
}; |
||||
|
||||
if (this.hasArticleTarget) { |
||||
this.articleTarget.addEventListener('pointerenter', this._onMarkPointerEnter, true); |
||||
this.articleTarget.addEventListener('pointerleave', this._onMarkPointerLeave, true); |
||||
this.articleTarget.addEventListener('click', this._onMarkClick, true); |
||||
this.articleTarget.addEventListener('focusin', this._onMarkFocus, true); |
||||
this.articleTarget.addEventListener('keydown', this._onMarkKeydown, true); |
||||
} |
||||
if (this.hasPopoverTarget) { |
||||
this.popoverTarget.addEventListener('pointerenter', this._onPopoverPointerEnter); |
||||
this.popoverTarget.addEventListener('pointerleave', this._onPopoverPointerLeave); |
||||
} |
||||
window.addEventListener('scroll', this._onScrollReposition, true); |
||||
window.addEventListener('resize', this._onScrollReposition); |
||||
this._scrollToHash(); |
||||
window.addEventListener('hashchange', this._onHash); |
||||
} |
||||
|
||||
disconnect() { |
||||
window.removeEventListener('hashchange', this._onHash); |
||||
window.removeEventListener('scroll', this._onScrollReposition, true); |
||||
window.removeEventListener('resize', this._onScrollReposition); |
||||
if (this.hasArticleTarget) { |
||||
this.articleTarget.removeEventListener('pointerenter', this._onMarkPointerEnter, true); |
||||
this.articleTarget.removeEventListener('pointerleave', this._onMarkPointerLeave, true); |
||||
this.articleTarget.removeEventListener('click', this._onMarkClick, true); |
||||
this.articleTarget.removeEventListener('focusin', this._onMarkFocus, true); |
||||
this.articleTarget.removeEventListener('keydown', this._onMarkKeydown, true); |
||||
} |
||||
if (this.hasPopoverTarget) { |
||||
this.popoverTarget.removeEventListener('pointerenter', this._onPopoverPointerEnter); |
||||
this.popoverTarget.removeEventListener('pointerleave', this._onPopoverPointerLeave); |
||||
} |
||||
this._clearHoverLeaveTimer(); |
||||
} |
||||
|
||||
closeOnOutside(event) { |
||||
if (!this.hasPopoverTarget) { |
||||
return; |
||||
} |
||||
const t = event.target; |
||||
if (this.popoverTarget.contains(t)) { |
||||
return; |
||||
} |
||||
if (t && t.closest && t.closest('mark.article-body-highlight')) { |
||||
return; |
||||
} |
||||
if (this._pinnedId) { |
||||
this._closePinned(); |
||||
} else { |
||||
this._hideUnpinnedPopover(); |
||||
} |
||||
} |
||||
|
||||
onKeydown(event) { |
||||
if (event.key === 'Escape') { |
||||
this._closePinned(); |
||||
this._hideUnpinnedPopover(); |
||||
} |
||||
} |
||||
|
||||
closePopover() { |
||||
this._closePinned(); |
||||
this._hideUnpinnedPopover(); |
||||
} |
||||
|
||||
_onMarkLeave(mark) { |
||||
this._clearHoverLeaveTimer(); |
||||
if (this._pinnedId) { |
||||
return; |
||||
} |
||||
this._hoverLeaveTimer = setTimeout(() => { |
||||
this._hoverLeaveTimer = null; |
||||
if (this._openId === (mark.getAttribute('data-event-id') || '').toLowerCase()) { |
||||
this._hideUnpinnedPopover(); |
||||
} |
||||
}, 200); |
||||
} |
||||
|
||||
_clearHoverLeaveTimer() { |
||||
if (this._hoverLeaveTimer) { |
||||
clearTimeout(this._hoverLeaveTimer); |
||||
this._hoverLeaveTimer = null; |
||||
} |
||||
} |
||||
|
||||
_getMarkByEventId(id) { |
||||
if (!id) { |
||||
return null; |
||||
} |
||||
return ( |
||||
this.element.querySelector(`#highlight-${id}`) || |
||||
this.element.querySelector(`mark.article-body-highlight[data-event-id="${CSS.escape(id)}"]`) |
||||
); |
||||
} |
||||
|
||||
_openForMark(mark, pin) { |
||||
if (!this.hasPopoverTarget || !this.hasPopoverInnerTarget) { |
||||
return; |
||||
} |
||||
const id = (mark.getAttribute('data-event-id') || '').toLowerCase(); |
||||
if (!id || 64 !== id.length) { |
||||
return; |
||||
} |
||||
const meta = this._metaById[id]; |
||||
if (!meta) { |
||||
return; |
||||
} |
||||
this._openId = id; |
||||
if (pin) { |
||||
this._pinnedId = id; |
||||
} else { |
||||
this._clearHoverLeaveTimer(); |
||||
} |
||||
this.popoverInnerTarget.innerHTML = |
||||
(meta.headHtml || '') + |
||||
'<div class="user-highlight__body user-highlight__body--popover">' + |
||||
(meta.bodyHtml || '') + |
||||
'</div>'; |
||||
this._placePopover(mark); |
||||
this.popoverTarget.hidden = false; |
||||
} |
||||
|
||||
_placePopover(anchor) { |
||||
if (!this.hasPopoverTarget) { |
||||
return; |
||||
} |
||||
const p = this.popoverTarget; |
||||
p.style.position = 'fixed'; |
||||
p.style.zIndex = '200'; |
||||
p.style.left = '0'; |
||||
p.style.top = '0'; |
||||
const r = anchor.getBoundingClientRect(); |
||||
p.hidden = false; |
||||
const pr = p.getBoundingClientRect(); |
||||
let left = r.left + r.width / 2 - pr.width / 2; |
||||
let top = r.bottom + 8; |
||||
if (left < 8) { |
||||
left = 8; |
||||
} |
||||
if (left + pr.width > window.innerWidth - 8) { |
||||
left = Math.max(8, window.innerWidth - 8 - pr.width); |
||||
} |
||||
if (top + pr.height > window.innerHeight - 8) { |
||||
top = Math.max(8, r.top - 8 - pr.height); |
||||
} |
||||
p.style.left = `${Math.round(left)}px`; |
||||
p.style.top = `${Math.round(top)}px`; |
||||
} |
||||
|
||||
_hideUnpinnedPopover() { |
||||
this._openId = null; |
||||
this._clearHoverLeaveTimer(); |
||||
if (this._pinnedId) { |
||||
return; |
||||
} |
||||
if (this.hasPopoverTarget) { |
||||
this.popoverTarget.hidden = true; |
||||
} |
||||
if (this.hasPopoverInnerTarget) { |
||||
this.popoverInnerTarget.innerHTML = ''; |
||||
} |
||||
} |
||||
|
||||
_closePinned() { |
||||
this._pinnedId = null; |
||||
if (this.hasPopoverTarget) { |
||||
this.popoverTarget.hidden = true; |
||||
} |
||||
if (this.hasPopoverInnerTarget) { |
||||
this.popoverInnerTarget.innerHTML = ''; |
||||
} |
||||
this._openId = null; |
||||
} |
||||
|
||||
_scrollToHash() { |
||||
const raw = window.location.hash || ''; |
||||
if (!raw.startsWith('#h-')) { |
||||
return; |
||||
} |
||||
const id = raw.slice(3).toLowerCase().replace(/[^0-9a-f]/g, ''); |
||||
if (id.length !== 64) { |
||||
return; |
||||
} |
||||
const el = this.element.querySelector(`#highlight-${id}`) || this._getMarkByEventId(id); |
||||
if (!el) { |
||||
return; |
||||
} |
||||
el.classList.remove('article-body-highlight--target'); |
||||
void el.offsetWidth; |
||||
el.classList.add('article-body-highlight--target'); |
||||
el.scrollIntoView({ block: 'center', behavior: 'smooth' }); |
||||
this._openForMark(el, false); |
||||
} |
||||
} |
||||
@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace DoctrineMigrations; |
||||
|
||||
use Doctrine\DBAL\Schema\Schema; |
||||
use Doctrine\Migrations\AbstractMigration; |
||||
|
||||
/** |
||||
* Kind 9802 (highlights) for long-form articles — stored locally; not part of the relay-only comment thread cache. |
||||
*/ |
||||
final class Version20260425200000 extends AbstractMigration |
||||
{ |
||||
public function getDescription(): string |
||||
{ |
||||
return 'Table article_highlight for Nostr kind-9802 highlights (linked to article rows)'; |
||||
} |
||||
|
||||
public function up(Schema $schema): void |
||||
{ |
||||
$this->addSql('CREATE TABLE article_highlight (id INT AUTO_INCREMENT NOT NULL, event_id VARCHAR(64) NOT NULL, article_id INT NOT NULL, author_pubkey VARCHAR(64) NOT NULL, content LONGTEXT NOT NULL, tags JSON NOT NULL, event_created_at BIGINT NOT NULL, quote_excerpt VARCHAR(512) DEFAULT NULL, INDEX IDX_8F7E8A72946689E (article_id), INDEX IDX_highlight_event_created (event_created_at), UNIQUE INDEX UNIQ_8F7E8A7271F7E88B (event_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); |
||||
$this->addSql('ALTER TABLE article_highlight ADD CONSTRAINT FK_8F7E8A72946689E FOREIGN KEY (article_id) REFERENCES article (id) ON DELETE CASCADE'); |
||||
} |
||||
|
||||
public function down(Schema $schema): void |
||||
{ |
||||
$this->addSql('ALTER TABLE article_highlight DROP FOREIGN KEY FK_8F7E8A72946689E'); |
||||
$this->addSql('DROP TABLE article_highlight'); |
||||
} |
||||
} |
||||
@ -0,0 +1,156 @@
@@ -0,0 +1,156 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Entity; |
||||
|
||||
use App\Repository\ArticleHighlightRepository; |
||||
use App\Util\HighlightEventTags; |
||||
use Doctrine\DBAL\Types\Types; |
||||
use Doctrine\ORM\Mapping as ORM; |
||||
|
||||
/** |
||||
* Nostr kind 9802 (highlight) events that reference a long-form article by `a` / `A` address. |
||||
* Ingested from relays and served from MySQL (not from the comment-thread cache). |
||||
*/ |
||||
#[ORM\Entity(repositoryClass: ArticleHighlightRepository::class)] |
||||
#[ORM\Table(name: 'article_highlight')] |
||||
#[ORM\Index(name: 'IDX_highlight_event_created', columns: ['event_created_at'])] |
||||
class ArticleHighlight |
||||
{ |
||||
#[ORM\Id] |
||||
#[ORM\GeneratedValue(strategy: 'IDENTITY')] |
||||
#[ORM\Column] |
||||
private ?int $id = null; |
||||
|
||||
/** Event id (hex, lowercase) — globally unique. */ |
||||
#[ORM\Column(length: 64, unique: true)] |
||||
private string $eventId = ''; |
||||
|
||||
#[ORM\ManyToOne(targetEntity: Article::class, inversedBy: null)] |
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] |
||||
private ?Article $article = null; |
||||
|
||||
/** Pubkey (hex) of the account that created the highlight. */ |
||||
#[ORM\Column(length: 64)] |
||||
private string $authorPubkey = ''; |
||||
|
||||
#[ORM\Column(type: Types::TEXT)] |
||||
private string $content = ''; |
||||
|
||||
/** Full tag array as returned on the wire (includes textquoteselector, a/A, etc.). */ |
||||
#[ORM\Column(type: Types::JSON)] |
||||
private array $tags = []; |
||||
|
||||
/** Nostr `created_at` (unix seconds). */ |
||||
#[ORM\Column(type: Types::BIGINT)] |
||||
private int $eventCreatedAt = 0; |
||||
|
||||
/** Short quote line for list UI / deep-link hint (from textquoteselector or content). */ |
||||
#[ORM\Column(type: Types::STRING, length: 512, nullable: true)] |
||||
private ?string $quoteExcerpt = null; |
||||
|
||||
public function getId(): ?int |
||||
{ |
||||
return $this->id; |
||||
} |
||||
|
||||
public function getEventId(): string |
||||
{ |
||||
return $this->eventId; |
||||
} |
||||
|
||||
public function setEventId(string $eventId): static |
||||
{ |
||||
$this->eventId = strtolower($eventId); |
||||
|
||||
return $this; |
||||
} |
||||
|
||||
public function getArticle(): ?Article |
||||
{ |
||||
return $this->article; |
||||
} |
||||
|
||||
public function setArticle(?Article $article): static |
||||
{ |
||||
$this->article = $article; |
||||
|
||||
return $this; |
||||
} |
||||
|
||||
public function getAuthorPubkey(): string |
||||
{ |
||||
return $this->authorPubkey; |
||||
} |
||||
|
||||
public function setAuthorPubkey(string $authorPubkey): static |
||||
{ |
||||
$this->authorPubkey = $authorPubkey; |
||||
|
||||
return $this; |
||||
} |
||||
|
||||
public function getContent(): string |
||||
{ |
||||
return $this->content; |
||||
} |
||||
|
||||
public function setContent(string $content): static |
||||
{ |
||||
$this->content = $content; |
||||
|
||||
return $this; |
||||
} |
||||
|
||||
public function getTags(): array |
||||
{ |
||||
return $this->tags; |
||||
} |
||||
|
||||
public function setTags(array $tags): static |
||||
{ |
||||
$this->tags = $tags; |
||||
|
||||
return $this; |
||||
} |
||||
|
||||
public function getEventCreatedAt(): int |
||||
{ |
||||
return $this->eventCreatedAt; |
||||
} |
||||
|
||||
public function setEventCreatedAt(int $eventCreatedAt): static |
||||
{ |
||||
$this->eventCreatedAt = $eventCreatedAt; |
||||
|
||||
return $this; |
||||
} |
||||
|
||||
public function getQuoteExcerpt(): ?string |
||||
{ |
||||
return $this->quoteExcerpt; |
||||
} |
||||
|
||||
public function setQuoteExcerpt(?string $quoteExcerpt): static |
||||
{ |
||||
$this->quoteExcerpt = $quoteExcerpt; |
||||
|
||||
return $this; |
||||
} |
||||
|
||||
/** The full quote from the optional `context` tag. Event `content` is highlighted *inside* this when present. */ |
||||
public function getContextText(): string |
||||
{ |
||||
return HighlightEventTags::contextFromTags($this->tags); |
||||
} |
||||
|
||||
/** Renders: full `content` in <mark> when `context` is empty; else `context` quote with `content` substring marked. */ |
||||
public function getBodyHtml(): string |
||||
{ |
||||
$ctx = $this->getContextText(); |
||||
$body = (string) $this->getContent(); |
||||
|
||||
return HighlightEventTags::buildHighlightedBodyHtml($ctx, $body); |
||||
} |
||||
} |
||||
@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Repository; |
||||
|
||||
use App\Entity\Article; |
||||
use App\Entity\ArticleHighlight; |
||||
use App\Enum\EventStatusEnum; |
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; |
||||
use Doctrine\Persistence\ManagerRegistry; |
||||
|
||||
/** |
||||
* @extends ServiceEntityRepository<ArticleHighlight> |
||||
*/ |
||||
class ArticleHighlightRepository extends ServiceEntityRepository |
||||
{ |
||||
public function __construct(ManagerRegistry $registry) |
||||
{ |
||||
parent::__construct($registry, ArticleHighlight::class); |
||||
} |
||||
|
||||
/** |
||||
* Newest highlights across published/archived long-form, for the home aside. |
||||
* |
||||
* @return list<ArticleHighlight> |
||||
*/ |
||||
public function findRecentForHome(int $limit = 36): array |
||||
{ |
||||
if ($limit <= 0) { |
||||
return []; |
||||
} |
||||
|
||||
$qb = $this->createQueryBuilder('h') |
||||
->innerJoin('h.article', 'a') |
||||
->where('a.eventStatus IN (:st)') |
||||
->setParameter('st', [EventStatusEnum::PUBLISHED, EventStatusEnum::ARCHIVED]) |
||||
->orderBy('h.eventCreatedAt', 'DESC') |
||||
->addOrderBy('h.id', 'DESC') |
||||
->setMaxResults($limit); |
||||
|
||||
/** @var list<ArticleHighlight> $rows */ |
||||
$rows = $qb->getQuery()->getResult(); |
||||
|
||||
return $rows; |
||||
} |
||||
|
||||
/** |
||||
* @return list<ArticleHighlight> |
||||
*/ |
||||
public function findByArticle(Article $article): array |
||||
{ |
||||
$id = $article->getId(); |
||||
if (null === $id || (int) $id < 1) { |
||||
return []; |
||||
} |
||||
|
||||
/** @var list<ArticleHighlight> $out */ |
||||
$out = $this->createQueryBuilder('h') |
||||
->where('h.article = :art') |
||||
->setParameter('art', $article) |
||||
->orderBy('h.eventCreatedAt', 'DESC') |
||||
->getQuery() |
||||
->getResult(); |
||||
|
||||
return $out; |
||||
} |
||||
} |
||||
@ -0,0 +1,317 @@
@@ -0,0 +1,317 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Service; |
||||
|
||||
use App\Entity\ArticleHighlight; |
||||
use DOMDocument; |
||||
use DOMElement; |
||||
use DOMText; |
||||
use DOMXPath; |
||||
|
||||
/** |
||||
* Injects kind-9802 highlight ranges into the rendered article body by finding each event’s |
||||
* {@see ArticleHighlight::getContent} in the visible text. Matches across inline elements |
||||
* (e.g. em, strong) by concatenating text in document order. |
||||
*/ |
||||
final class ArticleBodyHighlightInjector |
||||
{ |
||||
private const ROOT_ID = '_article_hl'; |
||||
|
||||
private DOMDocument $dom; |
||||
|
||||
private ?DOMElement $root = null; |
||||
|
||||
/** |
||||
* @param list<ArticleHighlight> $highlights |
||||
* |
||||
* @return array{html: string, injectedEventIds: list<string>} |
||||
*/ |
||||
public function inject(string $html, array $highlights): array |
||||
{ |
||||
if ($highlights === [] || $html === '') { |
||||
return ['html' => $html, 'injectedEventIds' => []]; |
||||
} |
||||
$sorted = $highlights; |
||||
usort( |
||||
$sorted, |
||||
static fn (ArticleHighlight $a, ArticleHighlight $b) => $a->getEventCreatedAt() <=> $b->getEventCreatedAt() |
||||
); |
||||
|
||||
$this->loadDom($html); |
||||
if (null === $this->root) { |
||||
return ['html' => $html, 'injectedEventIds' => []]; |
||||
} |
||||
|
||||
$injected = []; |
||||
foreach ($sorted as $h) { |
||||
$needle = \trim($h->getContent()); |
||||
if ($needle === '') { |
||||
continue; |
||||
} |
||||
$eid = \strtolower($h->getEventId()); |
||||
if (64 !== \strlen($eid) || !ctype_xdigit($eid)) { |
||||
continue; |
||||
} |
||||
if ($this->tryWrapInDocument($this->root, $needle, $eid)) { |
||||
$injected[] = $eid; |
||||
} |
||||
} |
||||
|
||||
$out = ''; |
||||
foreach ($this->root->childNodes as $child) { |
||||
$out .= (string) $this->dom->saveHTML($child); |
||||
} |
||||
|
||||
return ['html' => $out, 'injectedEventIds' => $injected]; |
||||
} |
||||
|
||||
private function loadDom(string $html): void |
||||
{ |
||||
$this->dom = new DOMDocument('1.0', 'UTF-8'); |
||||
$this->root = null; |
||||
if ($html === '') { |
||||
return; |
||||
} |
||||
$enc = '<?xml encoding="UTF-8"?>'.'<div id="'.self::ROOT_ID.'">'.$html.'</div>';
|
||||
$prev = libxml_use_internal_errors(true); |
||||
try { |
||||
if (false === $this->dom->loadHTML( |
||||
$enc, |
||||
\LIBXML_HTML_NOIMPLIED | \LIBXML_HTML_NODEFDTD |
||||
)) { |
||||
libxml_clear_errors(); |
||||
} |
||||
} finally { |
||||
libxml_use_internal_errors($prev); |
||||
libxml_clear_errors(); |
||||
} |
||||
// getElementById is unreliable for HTML loaded without a DTD; use XPath. |
||||
$xp = new DOMXPath($this->dom); |
||||
$nodes = $xp->query('//div[@id="'.self::ROOT_ID.'"]'); |
||||
if (false === $nodes || 0 === $nodes->length) { |
||||
$this->root = $this->findElementByIdFallback(self::ROOT_ID); |
||||
|
||||
return; |
||||
} |
||||
$first = $nodes->item(0); |
||||
$this->root = $first instanceof DOMElement ? $first : null; |
||||
} |
||||
|
||||
private function findElementByIdFallback(string $id): ?DOMElement |
||||
{ |
||||
if ('' === $id) { |
||||
return null; |
||||
} |
||||
$stack = []; |
||||
if (null === $this->dom->documentElement) { |
||||
return null; |
||||
} |
||||
$stack[] = $this->dom->documentElement; |
||||
while ($stack !== []) { |
||||
$el = \array_pop($stack); |
||||
if (! $el instanceof DOMElement) { |
||||
continue; |
||||
} |
||||
if ($el->getAttribute('id') === $id) { |
||||
return $el; |
||||
} |
||||
for ($c = $el->lastChild; $c; $c = $c->previousSibling) { |
||||
if ($c instanceof DOMElement) { |
||||
$stack[] = $c; |
||||
} |
||||
} |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
|
||||
private function tryWrapInDocument(DOMElement $root, string $needle, string $eventId): bool |
||||
{ |
||||
$textNodes = $this->collectTextNodes($root); |
||||
if ($textNodes === []) { |
||||
return false; |
||||
} |
||||
$cat = ''; |
||||
/** @var list<array{0: DOMText, 1: int, 2: int}> $segments */ |
||||
$segments = []; |
||||
$nl = \mb_strlen($needle, 'UTF-8'); |
||||
if ($nl < 1) { |
||||
return false; |
||||
} |
||||
|
||||
foreach ($textNodes as $tn) { |
||||
$t = (string) $tn->data; |
||||
$len = \mb_strlen($t, 'UTF-8'); |
||||
if ($len === 0) { |
||||
continue; |
||||
} |
||||
$cat .= $t; |
||||
} |
||||
|
||||
$p = \mb_strpos($cat, $needle, 0, 'UTF-8'); |
||||
if (false === $p) { |
||||
return false; |
||||
} |
||||
$pEnd = $p + $nl; |
||||
$cursor = 0; |
||||
foreach ($textNodes as $tn) { |
||||
$t = (string) $tn->data; |
||||
$nodeLen = \mb_strlen($t, 'UTF-8'); |
||||
if ($nodeLen === 0) { |
||||
continue; |
||||
} |
||||
$nStart = $cursor; |
||||
$nEnd = $cursor + $nodeLen; |
||||
if ($pEnd <= $nStart) { |
||||
break; |
||||
} |
||||
if ($p >= $nEnd) { |
||||
$cursor = $nEnd; |
||||
continue; |
||||
} |
||||
$oStart = \max($p, $nStart); |
||||
$oEnd = \min($pEnd, $nEnd); |
||||
if ($oStart < $oEnd) { |
||||
$lStart = $oStart - $nStart; |
||||
$lLen = $oEnd - $oStart; |
||||
$segments[] = [$tn, $lStart, $lLen]; |
||||
} |
||||
$cursor = $nEnd; |
||||
if ($oEnd >= $pEnd) { |
||||
break; |
||||
} |
||||
} |
||||
|
||||
if ($segments === []) { |
||||
return false; |
||||
} |
||||
for ($i = \count($segments) - 1; $i >= 0; --$i) { |
||||
[$n, $off, $nLen] = $segments[$i]; |
||||
if (! $this->wrapTextSlice( |
||||
$n, |
||||
$off, |
||||
$nLen, |
||||
$eventId, |
||||
0 === $i |
||||
)) { |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
return true; |
||||
} |
||||
|
||||
/** |
||||
* @return list<DOMText> |
||||
*/ |
||||
private function collectTextNodes(DOMElement $el): array |
||||
{ |
||||
$out = []; |
||||
for ($c = $el->firstChild; $c; $c = $c->nextSibling) { |
||||
if ($c instanceof DOMText) { |
||||
if ($this->isSafeTextContext($c)) { |
||||
$out[] = $c; |
||||
} |
||||
} elseif ($c instanceof DOMElement) { |
||||
if ($this->shouldNotDescendInto($c)) { |
||||
continue; |
||||
} |
||||
foreach ($this->collectTextNodes($c) as $tn) { |
||||
$out[] = $tn; |
||||
} |
||||
} |
||||
} |
||||
|
||||
return $out; |
||||
} |
||||
|
||||
private function shouldNotDescendInto(DOMElement $c): bool |
||||
{ |
||||
$n = $c->nodeName; |
||||
|
||||
return 'script' === $n |
||||
|| 'style' === $n |
||||
|| 'pre' === $n |
||||
|| 'textarea' === $n |
||||
|| 'code' === $n |
||||
|| 'mark' === $n; |
||||
} |
||||
|
||||
private function isSafeTextContext(DOMText $textNode): bool |
||||
{ |
||||
$p = $textNode->parentNode; |
||||
while (null !== $p && $p->nodeType === XML_ELEMENT_NODE) { |
||||
if (! $p instanceof DOMElement) { |
||||
$p = $p->parentNode; |
||||
continue; |
||||
} |
||||
$n = $p->nodeName; |
||||
if ('script' === $n || 'style' === $n || 'pre' === $n || 'textarea' === $n) { |
||||
return false; |
||||
} |
||||
if ('code' === $n) { |
||||
return false; |
||||
} |
||||
if ('mark' === $n) { |
||||
$cl = (string) $p->getAttribute('class'); |
||||
if (\str_contains($cl, 'article-body-highlight')) { |
||||
return false; |
||||
} |
||||
} |
||||
$p = $p->parentNode; |
||||
} |
||||
|
||||
return true; |
||||
} |
||||
|
||||
private function wrapTextSlice(DOMText $textNode, int $uOffset, int $uLength, string $eventId, bool $firstInReadingOrder): bool |
||||
{ |
||||
if ($uLength < 1) { |
||||
return false; |
||||
} |
||||
$t = (string) $textNode->data; |
||||
$nLen = \mb_strlen($t, 'UTF-8'); |
||||
if ($uOffset < 0 || $uOffset + $uLength > $nLen) { |
||||
return false; |
||||
} |
||||
$before = $uOffset > 0 ? \mb_substr($t, 0, $uOffset, 'UTF-8') : ''; |
||||
$match = \mb_substr($t, $uOffset, $uLength, 'UTF-8'); |
||||
$restStart = $uOffset + $uLength; |
||||
$after = $restStart < $nLen ? \mb_substr($t, $restStart, null, 'UTF-8') : ''; |
||||
|
||||
$parent = $textNode->parentNode; |
||||
if (null === $parent) { |
||||
return false; |
||||
} |
||||
|
||||
$ref = $textNode; |
||||
if ($before !== '') { |
||||
$parent->insertBefore($this->dom->createTextNode($before), $ref); |
||||
} |
||||
$mark = $this->dom->createElement('mark'); |
||||
if (! $mark) { |
||||
return false; |
||||
} |
||||
$mark->setAttribute('class', 'user-highlight__marker article-body-highlight'); |
||||
if ($firstInReadingOrder) { |
||||
$mark->setAttribute('id', 'highlight-'.$eventId); |
||||
$mark->setAttribute('tabindex', '0'); |
||||
} |
||||
$mark->setAttribute('data-event-id', $eventId); |
||||
$mark->setAttribute('data-article-body-highlight', '1'); |
||||
if (! $firstInReadingOrder) { |
||||
$mark->setAttribute('data-article-body-highlight-continuation', '1'); |
||||
} |
||||
$mark->appendChild($this->dom->createTextNode($match)); |
||||
$parent->insertBefore($mark, $ref); |
||||
if ($after === '') { |
||||
$parent->removeChild($ref); |
||||
} else { |
||||
$ref->data = $after; |
||||
} |
||||
|
||||
return true; |
||||
} |
||||
} |
||||
@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Service; |
||||
|
||||
use App\Entity\Article; |
||||
use App\Entity\ArticleHighlight; |
||||
use App\Enum\KindsEnum; |
||||
use App\Repository\ArticleHighlightRepository; |
||||
use App\Util\HighlightEventTags; |
||||
use Doctrine\ORM\EntityManagerInterface; |
||||
use Psr\Log\LoggerInterface; |
||||
|
||||
/** |
||||
* Pulls kind-9802 highlights from relays and upserts into {@see ArticleHighlight}. |
||||
*/ |
||||
final class HighlightSyncService |
||||
{ |
||||
public function __construct( |
||||
private readonly NostrClient $nostrClient, |
||||
private readonly EntityManagerInterface $entityManager, |
||||
private readonly ArticleHighlightRepository $highlightRepository, |
||||
private readonly LoggerInterface $logger, |
||||
) { |
||||
} |
||||
|
||||
/** |
||||
* @return int number of highlight rows written/updated |
||||
*/ |
||||
public function syncForArticle(Article $article): int |
||||
{ |
||||
$id = $article->getId(); |
||||
if (null === $id || (int) $id < 1) { |
||||
return 0; |
||||
} |
||||
|
||||
$slug = trim((string) $article->getSlug()); |
||||
$pubkey = (string) $article->getPubkey(); |
||||
if ($slug === '' || 64 !== \strlen($pubkey) || !ctype_xdigit($pubkey)) { |
||||
return 0; |
||||
} |
||||
|
||||
$kind = $article->getKind()?->value ?? 30023; |
||||
$coordinate = $kind.':'.$pubkey.':'.$slug; |
||||
|
||||
$events = $this->nostrClient->fetchHighlightEventsForArticle($coordinate); |
||||
$n = 0; |
||||
foreach ($events as $ev) { |
||||
if (!\is_object($ev)) { |
||||
continue; |
||||
} |
||||
if ((int) ($ev->kind ?? 0) !== KindsEnum::HIGHLIGHTS->value) { |
||||
continue; |
||||
} |
||||
$eid = strtolower((string) ($ev->id ?? '')); |
||||
if (64 !== \strlen($eid) || !ctype_xdigit($eid)) { |
||||
continue; |
||||
} |
||||
$author = strtolower((string) ($ev->pubkey ?? '')); |
||||
if (64 !== \strlen($author) || !ctype_xdigit($author)) { |
||||
continue; |
||||
} |
||||
$tags = $ev->tags ?? []; |
||||
if (!\is_array($tags)) { |
||||
$tags = []; |
||||
} |
||||
$content = (string) ($ev->content ?? ''); |
||||
$ca = (int) ($ev->created_at ?? 0); |
||||
if ($ca < 0) { |
||||
$ca = 0; |
||||
} |
||||
$excerpt = HighlightEventTags::excerptForFeed($content, $tags); |
||||
if ($excerpt === '') { |
||||
$excerpt = \mb_substr(\trim($content), 0, 240); |
||||
} |
||||
|
||||
$row = $this->highlightRepository->findOneBy(['eventId' => $eid]); |
||||
if ($row === null) { |
||||
$row = new ArticleHighlight(); |
||||
$row->setEventId($eid); |
||||
} |
||||
$row->setArticle($article); |
||||
$row->setAuthorPubkey($author); |
||||
$row->setContent($content); |
||||
$row->setTags($tags); |
||||
$row->setEventCreatedAt($ca); |
||||
$row->setQuoteExcerpt($excerpt !== '' ? $excerpt : null); |
||||
$this->entityManager->persist($row); |
||||
++$n; |
||||
} |
||||
if ($n > 0) { |
||||
$this->entityManager->flush(); |
||||
} |
||||
|
||||
$this->logger->info('highlight_sync.article', [ |
||||
'article_id' => $id, |
||||
'slug' => $slug, |
||||
'ingested' => $n, |
||||
]); |
||||
|
||||
return $n; |
||||
} |
||||
} |
||||
@ -0,0 +1,137 @@
@@ -0,0 +1,137 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Util; |
||||
|
||||
/** |
||||
* NIP-84 (kind 9802) in this app: |
||||
* — Event **`content`**: the highlighted words (a substring to mark when `context` exists, or the whole note when it does not). |
||||
* — Optional **`context` tag**: the **full quote** in which to show that highlight; the event `content` is highlighted **inside** the context. |
||||
* — No / empty `context` → show `content` **entirely** wrapped in the highlighter <mark> (not plain text only). |
||||
* |
||||
* @param list<array<int, string>>|list<array> $tags |
||||
*/ |
||||
final class HighlightEventTags |
||||
{ |
||||
public const HIGHLIGHT_MARK_CLASS = 'user-highlight__marker'; |
||||
|
||||
/** |
||||
* The full passage from the `context` tag (one tag may split across many values in some clients). |
||||
*/ |
||||
public static function contextFromTags(array $tags): string |
||||
{ |
||||
$parts = []; |
||||
foreach ($tags as $t) { |
||||
if (!\is_array($t) || \count($t) < 2) { |
||||
continue; |
||||
} |
||||
if (strtolower((string) ($t[0] ?? '')) !== 'context') { |
||||
continue; |
||||
} |
||||
for ($i = 1, $c = \count($t); $i < $c; ++$i) { |
||||
$p = (string) ($t[$i] ?? ''); |
||||
if ($p !== '') { |
||||
$parts[] = $p; |
||||
} |
||||
} |
||||
} |
||||
if ($parts === []) { |
||||
return ''; |
||||
} |
||||
$joined = \implode(' ', $parts); |
||||
|
||||
return \mb_substr($joined, 0, 8000); |
||||
} |
||||
|
||||
/** |
||||
* Renders the full quote and wraps the `content` substring in <mark> when a context tag is present; |
||||
* otherwise the entire `content` is wrapped in <mark> (no surrounding quote). |
||||
* |
||||
* @param string $contextQuote Text from the `context` tag (the full quote). Empty means “no context”. |
||||
* @param string $contentField The event's `content` field: highlight to find within `contextQuote` when set. |
||||
* |
||||
* @return string safe HTML |
||||
*/ |
||||
public static function buildHighlightedBodyHtml(string $contextQuote, string $contentField): string |
||||
{ |
||||
$q = (string) $contextQuote; |
||||
$hi = (string) $contentField; |
||||
if ($q === '' && $hi === '') { |
||||
return ''; |
||||
} |
||||
if ($q === '') { |
||||
return '<mark class="'.self::HIGHLIGHT_MARK_CLASS.'">'.self::escapeWithNl2br($hi).'</mark>'; |
||||
} |
||||
if ($hi === '') { |
||||
return self::escapeWithNl2br($q); |
||||
} |
||||
$pos = \mb_strpos($q, $hi, 0, 'UTF-8'); |
||||
if ($pos !== false) { |
||||
$len = \mb_strlen($hi, 'UTF-8'); |
||||
$before = \mb_substr($q, 0, $pos, 'UTF-8'); |
||||
$match = \mb_substr($q, $pos, $len, 'UTF-8'); |
||||
$after = \mb_substr($q, $pos + $len, null, 'UTF-8'); |
||||
|
||||
return self::escapeWithNl2br($before).self::markHtml($match).self::escapeWithNl2br($after); |
||||
} |
||||
|
||||
// Substring not found: show the full context quote, then the highlight line so the note is not empty. |
||||
return self::escapeWithNl2br($q).'<p class="user-highlight__marker-orphan">'.self::markHtml($hi).'</p>'; |
||||
} |
||||
|
||||
public static function escapeWithNl2br(string $s): string |
||||
{ |
||||
return \nl2br(\htmlspecialchars($s, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'), false); |
||||
} |
||||
|
||||
private static function markHtml(string $innerText): string |
||||
{ |
||||
$e = \htmlspecialchars($innerText, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'); |
||||
|
||||
return '<mark class="'.self::HIGHLIGHT_MARK_CLASS.'">'.$e.'</mark>'; |
||||
} |
||||
|
||||
/** |
||||
* Text from `textquoteselector` (legacy / client-specific), first non-empty segment. |
||||
* |
||||
* @param list<array<int, string>>|list<array> $tags |
||||
*/ |
||||
public static function excerptFromTextquoteselectorTags(array $tags): string |
||||
{ |
||||
foreach ($tags as $t) { |
||||
if (!\is_array($t) || \count($t) < 2) { |
||||
continue; |
||||
} |
||||
if (strtolower((string) ($t[0] ?? '')) !== 'textquoteselector') { |
||||
continue; |
||||
} |
||||
for ($i = 1, $c = \count($t); $i < $c; ++$i) { |
||||
$p = \trim((string) ($t[$i] ?? '')); |
||||
if ($p !== '') { |
||||
return \mb_substr($p, 0, 400); |
||||
} |
||||
} |
||||
} |
||||
|
||||
return ''; |
||||
} |
||||
|
||||
/** |
||||
* List preview: prefer the event `content` (the highlight / note body), else `context` quote, else tq. |
||||
*/ |
||||
public static function excerptForFeed(string $content, array $tags): string |
||||
{ |
||||
$c = \trim((string) $content); |
||||
if ($c !== '') { |
||||
return \mb_substr($c, 0, 400); |
||||
} |
||||
$ctx = \trim(self::contextFromTags($tags)); |
||||
if ($ctx !== '') { |
||||
return \mb_substr($ctx, 0, 400); |
||||
} |
||||
$tq = \trim(self::excerptFromTextquoteselectorTags($tags)); |
||||
|
||||
return $tq !== '' ? $tq : ''; |
||||
} |
||||
} |
||||
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
<div class="article-body-highlight__head"> |
||||
<div class="article-body-highlight__who"> |
||||
<twig:Molecules:UserFromNpub ident="{{ authorPubkey }}" /> |
||||
</div> |
||||
{% if dateLabel|default('')|trim != '' %} |
||||
<span class="text-subtle article-body-highlight__when">{{ dateLabel }}</span> |
||||
{% endif %} |
||||
</div> |
||||
@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
|
||||
{% if highlights is defined and highlights is not empty %} |
||||
<section class="home-aside-highlights" aria-label="{{ 'sidebar.highlights'|trans }}"> |
||||
<h2 class="home-aside-highlights__title">{{ 'sidebar.highlights'|trans }}</h2> |
||||
<div class="home-aside-highlights__scroller"> |
||||
<ul class="home-aside-highlights__list" role="list"> |
||||
{% for h in highlights %} |
||||
{% set art = h.article %} |
||||
{% if art %} |
||||
{% set _np = npub_from_hex(art.pubkey|default('')) %} |
||||
{% if _np != '' and art.slug|default('') != '' %} |
||||
<li class="home-aside-highlights__item"> |
||||
<div class="home-aside-highlights__item-inner"> |
||||
<a |
||||
class="home-aside-highlights__hit" |
||||
href="{{ path('article', { npub: _np, slug: art.slug }) ~ '#h-' ~ h.eventId }}" |
||||
aria-label="{{ 'sidebar.highlight_view'|trans({ '%title%': art.title|default('') })|e('html_attr') }}" |
||||
> |
||||
<span class="visually-hidden">{{ 'sidebar.highlight_view'|trans({ '%title%': art.title|default('') }) }}</span> |
||||
</a> |
||||
{% set _html = h.bodyHtml|default('')|trim %} |
||||
{% if _html != '' %} |
||||
<div class="home-aside-highlights__quote home-aside-highlights__quote--html user-highlight__body">{{ _html|raw }}</div> |
||||
{% else %} |
||||
{% set _prew = h.content|default('')|trim %} |
||||
{% if _prew == '' %}{% set _prew = h.contextText|default('')|trim %}{% endif %} |
||||
{% if _prew == '' %}{% set _prew = h.quoteExcerpt|default('')|trim %}{% endif %} |
||||
<div class="home-aside-highlights__quote home-aside-highlights__quote--plain">{{ _prew|u.truncate(200, '…') }}</div> |
||||
{% endif %} |
||||
<span class="home-aside-highlights__meta text-subtle">{{ art.title|default('')|u.truncate(52, '…') }}</span> |
||||
</div> |
||||
</li> |
||||
{% endif %} |
||||
{% endif %} |
||||
{% endfor %} |
||||
</ul> |
||||
</div> |
||||
</section> |
||||
{% endif %} |
||||
Loading…
Reference in new issue