Browse Source

bug-fixes

imwald
Silberengel 3 days ago
parent
commit
80d1788af0
  1. 9
      assets/controllers/article_highlight_controller.js
  2. 95
      assets/controllers/nostr_preview_controller.js
  3. 15
      assets/styles/article.css
  4. 1
      src/Controller/ArticleController.php
  5. 14
      src/Entity/ArticleHighlight.php
  6. 231
      src/Service/ArticleBodyHighlightInjector.php
  7. 17
      src/Util/HighlightEventTags.php
  8. 1
      templates/components/Organisms/HomeHighlightsAside.html.twig

9
assets/controllers/article_highlight_controller.js

@ -131,6 +131,9 @@ export default class extends Controller { @@ -131,6 +131,9 @@ export default class extends Controller {
if (!this.hasPopoverTarget) {
return;
}
if (!this._pinnedId && !this._openId) {
return;
}
const t = event.target;
if (this.popoverTarget.contains(t)) {
return;
@ -205,11 +208,7 @@ export default class extends Controller { @@ -205,11 +208,7 @@ export default class extends Controller {
} else {
this._clearHoverLeaveTimer();
}
this.popoverInnerTarget.innerHTML =
(meta.headHtml || '') +
'<div class="user-highlight__body user-highlight__body--popover">' +
(meta.bodyHtml || '') +
'</div>';
this.popoverInnerTarget.innerHTML = meta.headHtml || '';
this._placePopover(mark);
this.popoverTarget.hidden = false;
}

95
assets/controllers/nostr_preview_controller.js

@ -1,74 +1,59 @@ @@ -1,74 +1,59 @@
import { Controller } from '@hotwired/stimulus';
const LOADING_HTML = `<div class="nostr-preview__loading text-center my-2"><span class="nostr-preview__spinner" role="status" aria-label="Loading"></span><span class="nostr-preview__loading-text ms-2">Loading preview…</span></div>`;
const UNAVAILABLE_HTML = `<div class="alert alert-warning my-2" role="status">Preview unavailable.</div>`;
export default class extends Controller {
static values = {
identifier: String,
type: String,
decoded: String,
fullMatch: String
}
fullMatch: String,
};
static targets = ['container']
static targets = ['container'];
async connect() {
await this.fetchPreview();
connect() {
this.fetchPreview();
}
async fetchPreview() {
if (!this.hasContainerTarget) {
return;
}
this.containerTarget.innerHTML = LOADING_HTML;
try {
this.containerTarget.innerHTML = '<div class="nostr-preview__loading text-center my-2"><span class="nostr-preview__spinner" role="status" aria-label="Loading"></span><span class="nostr-preview__loading-text ms-2">Loading preview…</span></div>';
if (this.typeValue === 'url' && this.fullMatchValue) {
// Fetch OG preview for plain URLs
fetch("/og-preview/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ url: this.fullMatchValue })
})
.then(res => {
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
return res.text();
})
.then(data => {
this.containerTarget.innerHTML = data;
})
.catch(error => {
console.error("Error:", error);
this.containerTarget.innerHTML = `<div class="alert alert-warning">Unable to load OG preview for ${this.fullMatchValue}</div>`;
const res = await fetch('/og-preview/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: this.fullMatchValue }),
});
} else {
// Fallback to Nostr preview
const data = {
identifier: this.identifierValue,
type: this.typeValue,
decoded: this.decodedValue
};
fetch("/preview/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(data)
})
.then(res => {
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
return res.text();
})
.then(data => {
this.containerTarget.innerHTML = data;
})
.catch(error => {
console.error("Error:", error);
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
this.containerTarget.innerHTML = await res.text();
return;
}
const res = await fetch('/preview/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identifier: this.identifierValue,
type: this.typeValue,
decoded: this.decodedValue,
}),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
} catch (error) {
console.error('Error fetching Nostr preview:', error);
this.containerTarget.innerHTML = `<div class="alert alert-warning">Unable to load preview for ${this.fullMatchValue}</div>`;
this.containerTarget.innerHTML = await res.text();
} catch (e) {
// NetworkError / offline: avoid console.error noise; one inline fallback per block
console.debug('nostr_preview: fetch failed', e);
this.containerTarget.innerHTML = this.typeValue === 'url' && this.fullMatchValue
? `<div class="alert alert-warning my-2" role="status">Unable to load link preview for ${this.fullMatchValue}.</div>`
: UNAVAILABLE_HTML;
}
}
}

15
assets/styles/article.css

@ -449,11 +449,12 @@ @@ -449,11 +449,12 @@
max-width: min(24rem, calc(100vw - 1.5rem));
margin: 0;
padding: 0.65rem 2rem 0.85rem 0.85rem;
border: 1px solid color-mix(in srgb, var(--color-border) 80%, var(--color-bg) 20%);
border: 1px solid color-mix(in srgb, var(--color-border) 75%, var(--color-bg) 25%);
border-radius: 0.45rem;
color: var(--color-text);
background: var(--color-bg);
box-shadow: 0 0.4rem 1.25rem rgba(0, 0, 0, 0.12);
/* Slightly above page bg so the card reads as a raised surface */
background: color-mix(in srgb, var(--color-bg) 88%, #fff 12%);
box-shadow: 0 0.4rem 1.25rem rgba(0, 0, 0, 0.14);
pointer-events: auto;
}
@ -488,16 +489,10 @@ @@ -488,16 +489,10 @@
align-items: baseline;
justify-content: space-between;
gap: 0.4rem 0.75rem;
margin: 0 0 0.4rem;
margin: 0;
font-size: 0.86rem;
}
.user-highlight__body--popover {
margin-top: 0.3rem;
max-height: 40vh;
overflow: auto;
}
/* Full `context` quote + optional <mark> on the `content` substring (highlighter, not a box) */
.user-highlight__body {
margin: 0.35rem 0 0;

1
src/Controller/ArticleController.php

@ -449,7 +449,6 @@ class ArticleController extends AbstractController @@ -449,7 +449,6 @@ class ArticleController extends AbstractController
'authorPubkey' => $h->getAuthorPubkey(),
'dateLabel' => $this->formatHighlightListDate($h->getEventCreatedAt()),
]),
'bodyHtml' => $h->getBodyHtml(),
];
}
if ($out === []) {

14
src/Entity/ArticleHighlight.php

@ -145,12 +145,16 @@ class ArticleHighlight @@ -145,12 +145,16 @@ class ArticleHighlight
return HighlightEventTags::contextFromTags($this->tags);
}
/** Renders: full `content` in <mark> when `context` is empty; else `context` quote with `content` substring marked. */
/**
* HTML for the home aside and article hover cards: when a `context` tag exists, the full quote is
* shown with `content` marked inside it; otherwise the event `content` only in a <mark>. The
* rendered article body still only wraps the `content` passage (see ArticleBodyHighlightInjector).
*/
public function getBodyHtml(): string
{
$ctx = $this->getContextText();
$body = (string) $this->getContent();
return HighlightEventTags::buildHighlightedBodyHtml($ctx, $body);
return HighlightEventTags::buildHighlightedBodyHtml(
$this->getContextText(),
(string) $this->getContent()
);
}
}

231
src/Service/ArticleBodyHighlightInjector.php

@ -5,6 +5,7 @@ declare(strict_types=1); @@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service;
use App\Entity\ArticleHighlight;
use App\Util\HighlightEventTags;
use DOMDocument;
use DOMElement;
use DOMText;
@ -12,8 +13,10 @@ use DOMXPath; @@ -12,8 +13,10 @@ 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.
* `content` in the visible text (the `context` tag is ignored; the article is the full context).
* Matches across inline elements (e.g. em, strong) by concatenating text in document order.
* If a literal match fails, compares a normalized form (NBSP→space, strip U+00AD / ZW, etc.),
* then maps the match back to the original HTML text (for e‑book style soft hyphens in 9802 content).
*/
final class ArticleBodyHighlightInjector
{
@ -46,15 +49,11 @@ final class ArticleBodyHighlightInjector @@ -46,15 +49,11 @@ final class ArticleBodyHighlightInjector
$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)) {
if ($this->tryInjectOneHighlight($this->root, $h, $eid)) {
$injected[] = $eid;
}
}
@ -87,16 +86,43 @@ final class ArticleBodyHighlightInjector @@ -87,16 +86,43 @@ final class ArticleBodyHighlightInjector
libxml_use_internal_errors($prev);
libxml_clear_errors();
}
// getElementById is unreliable for HTML loaded without a DTD; use XPath.
// getElementById is unreliable for HTML loaded without a DTD; use XPath, then a div scan, then a tree walk.
$xp = new DOMXPath($this->dom);
$nodes = $xp->query('//div[@id="'.self::ROOT_ID.'"]');
if (false === $nodes || 0 === $nodes->length) {
if (false !== $nodes && $nodes->length > 0) {
$first = $nodes->item(0);
$this->root = $first instanceof DOMElement ? $first : null;
} else {
$this->root = null;
}
if (null === $this->root) {
$de = $this->dom->documentElement;
if ($de instanceof DOMElement && $de->getAttribute('id') === self::ROOT_ID) {
$this->root = $de;
}
}
if (null === $this->root) {
$this->root = $this->findFirstDivById(self::ROOT_ID);
}
if (null === $this->root) {
$this->root = $this->findElementByIdFallback(self::ROOT_ID);
}
}
return;
private function findFirstDivById(string $id): ?DOMElement
{
if ('' === $id) {
return null;
}
$n = $this->dom->getElementsByTagName('div');
for ($i = 0, $L = $n->length; $i < $L; ++$i) {
$d = $n->item($i);
if ($d instanceof DOMElement && $d->getAttribute('id') === $id) {
return $d;
}
}
$first = $nodes->item(0);
$this->root = $first instanceof DOMElement ? $first : null;
return null;
}
private function findElementByIdFallback(string $id): ?DOMElement
@ -127,6 +153,161 @@ final class ArticleBodyHighlightInjector @@ -127,6 +153,161 @@ final class ArticleBodyHighlightInjector
return null;
}
private function tryInjectOneHighlight(DOMElement $root, ArticleHighlight $h, string $eid): bool
{
$resolved = $this->resolveInjectionNeedle($h);
foreach ($this->needleSearchVariants($resolved) as $needle) {
if ($needle === '') {
continue;
}
if ($this->tryWrapInDocument($root, $needle, $eid)) {
return true;
}
}
return false;
}
private function resolveInjectionNeedle(ArticleHighlight $h): string
{
$c = \trim($h->getContent());
if ($c !== '') {
return $c;
}
return \trim(HighlightEventTags::contextFromTags($h->getTags()));
}
/**
* Nostr/Unicode vs rendered HTML: try a few equivalent strings for `mb_strpos` on the flattened text.
*
* @return list<string>
*/
private function needleSearchVariants(string $base): array
{
if ($base === '') {
return [];
}
$candidates = [
$base,
$this->replaceTypographicQuotes($base),
];
if (\class_exists(\Normalizer::class)) {
$c = \Normalizer::normalize($base, \Normalizer::FORM_C);
if (\is_string($c) && $c !== '' && $c !== $base) {
$candidates[] = $c;
}
}
$out = [];
$seen = [];
foreach ($candidates as $n) {
if ($n === '' || isset($seen[$n])) {
continue;
}
$seen[$n] = true;
$out[] = $n;
}
return $out;
}
private function replaceTypographicQuotes(string $s): string
{
return \strtr($s, [
"\xC2\xA0" => ' ', // nbsp
"\xE2\x80\x99" => "'",
"\xE2\x80\x98" => "'",
"\xE2\x80\x9C" => '"',
"\xE2\x80\x9D" => '"',
"\xE2\x80\x93" => '-',
"\xE2\x80\x94" => '-',
]);
}
/**
* Strips/rewrites typographic/linebreak chars so Nostr `content` (e.g. e‑book soft hyphens) can
* match the article’s flat HTML text, then map positions back to the real string for {@see wrapTextSlice}.
*/
private function stringForSearch(string $s): string
{
$L = \mb_strlen($s, 'UTF-8');
$out = '';
for ($i = 0; $i < $L; ++$i) {
$ch = \mb_substr($s, $i, 1, 'UTF-8');
$out .= $this->searchCharacterNormalized($ch);
}
return $out;
}
private function searchCharacterNormalized(string $ch): string
{
if ($ch === "\xC2\xAD") { // U+00AD soft hyphen
return '';
}
if ($ch === "\xE2\x80\x8B" // U+200B
|| $ch === "\xE2\x80\x8C" // U+200C
|| $ch === "\xE2\x80\x8D" // U+200D
|| $ch === "\xEF\xBB\xBF" // U+FEFF
) {
return '';
}
if ($ch === "\xC2\xA0" // U+00A0
|| $ch === "\xE2\x80\xAF" // U+202F narrow no-break
) {
return ' ';
}
return $ch;
}
/**
* @return list<int> length L+1; cuml[i] = "search" string length of prefix s[0..i) after per-char normalization
*/
private function buildCumulativeSearchLens(string $s): array
{
$L = \mb_strlen($s, 'UTF-8');
$cuml = [0];
for ($i = 0; $i < $L; ++$i) {
$ch = \mb_substr($s, $i, 1, 'UTF-8');
$add = $this->searchCharacterNormalized($ch);
$cuml[] = $cuml[$i] + \mb_strlen($add, 'UTF-8');
}
return $cuml;
}
/**
* @return array{0: int, 1: int} half-open [start, end) in mb char indices of $orig
*/
private function mapSearchStringRangeToOrigStringRange(string $orig, int $nStart, int $nEnd): array
{
$L = \mb_strlen($orig, 'UTF-8');
$cuml = $this->buildCumulativeSearchLens($orig);
if (0 > $nStart || $nStart > $cuml[$L] || $nEnd < $nStart || $nEnd > $cuml[$L]) {
return [0, 0];
}
$startO = -1;
for ($i = 0; $i < $L; ++$i) {
if ($cuml[$i + 1] > $nStart) {
$startO = $i;
break;
}
}
if ($startO < 0) {
return [0, 0];
}
$endO = $L;
for ($e = 0; $e <= $L; ++$e) {
if ($cuml[$e] >= $nEnd) {
$endO = $e;
break;
}
}
return [$startO, $endO];
}
private function tryWrapInDocument(DOMElement $root, string $needle, string $eventId): bool
{
$textNodes = $this->collectTextNodes($root);
@ -136,10 +317,6 @@ final class ArticleBodyHighlightInjector @@ -136,10 +317,6 @@ final class ArticleBodyHighlightInjector
$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;
@ -151,10 +328,26 @@ final class ArticleBodyHighlightInjector @@ -151,10 +328,26 @@ final class ArticleBodyHighlightInjector
}
$p = \mb_strpos($cat, $needle, 0, 'UTF-8');
if (false === $p) {
return false;
$pEnd = false;
if (false !== $p) {
$pEnd = $p + \mb_strlen($needle, 'UTF-8');
} else {
// e.g. soft hyphens (U+00AD) or NBSP in the event `content` vs plain text in the article
$catS = $this->stringForSearch($cat);
$needleS = $this->stringForSearch($needle);
if ($needleS === '') {
return false;
}
$pN = \mb_strpos($catS, $needleS, 0, 'UTF-8');
if (false === $pN) {
return false;
}
$nEnd = $pN + \mb_strlen($needleS, 'UTF-8');
[$p, $pEnd] = $this->mapSearchStringRangeToOrigStringRange($cat, $pN, $nEnd);
if ($pEnd <= $p) {
return false;
}
}
$pEnd = $p + $nl;
$cursor = 0;
foreach ($textNodes as $tn) {
$t = (string) $tn->data;

17
src/Util/HighlightEventTags.php

@ -5,12 +5,9 @@ declare(strict_types=1); @@ -5,12 +5,9 @@ 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
* NIP-84 (kind 9802): {@see buildHighlightedBodyHtml} drives list/hover-card HTML (full `context`
* with `content` marked when both exist). In-article marks are applied separately and only wrap
* the `content` substring in the article body ({@see \App\Service\ArticleBodyHighlightInjector}).
*/
final class HighlightEventTags
{
@ -45,11 +42,11 @@ final class HighlightEventTags @@ -45,11 +42,11 @@ final class HighlightEventTags
}
/**
* 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).
* Card / aside body: with `context`, show the full quote and mark the `content` substring; with
* empty `context`, wrap all of `content` in one <mark>.
*
* @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.
* @param string $contextQuote Text from the `context` tag. Empty means no surrounding quote.
* @param string $contentField The event’s `content` (highlighted phrase).
*
* @return string safe HTML
*/

1
templates/components/Organisms/HomeHighlightsAside.html.twig

@ -22,7 +22,6 @@ @@ -22,7 +22,6 @@
<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 %}

Loading…
Cancel
Save