Compare commits
23 Commits
f969bc4a57
...
111cb0df9c
| Author | SHA1 | Date |
|---|---|---|
|
|
111cb0df9c | 3 days ago |
|
|
c4f9435303 | 3 days ago |
|
|
ccf35a1c9a | 3 days ago |
|
|
2276fb5d2b | 3 days ago |
|
|
b440415474 | 3 days ago |
|
|
8f4e123c89 | 3 days ago |
|
|
fedbed5824 | 3 days ago |
|
|
f34c6ed26b | 4 days ago |
|
|
d16da938fa | 4 days ago |
|
|
4904800365 | 4 days ago |
|
|
e9edb98555 | 5 days ago |
|
|
80d1788af0 | 5 days ago |
|
|
0bc43f7488 | 5 days ago |
|
|
ca5045d6a9 | 5 days ago |
|
|
956ee1dbbc | 5 days ago |
|
|
3dd6085a91 | 5 days ago |
|
|
92b0f42be0 | 5 days ago |
|
|
dd680430c6 | 5 days ago |
|
|
4f1173ae67 | 5 days ago |
|
|
cf51a1b4d9 | 5 days ago |
|
|
8b397fb34c | 5 days ago |
|
|
a6e831db20 | 5 days ago |
|
|
f589579378 | 5 days ago |
136 changed files with 11801 additions and 4242 deletions
@ -1,74 +1,122 @@ |
|||||||
import { Controller } from '@hotwired/stimulus'; |
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>`; |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {HTMLElement} el |
||||||
|
* @param {string} type |
||||||
|
* @param {string} decodedStr |
||||||
|
* @returns {boolean} |
||||||
|
*/ |
||||||
|
function isPreviewForSameArticleOnPage(el, type, decodedStr) { |
||||||
|
const root = el.closest('[data-nostr-page-article-coordinate]'); |
||||||
|
if (!root) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
const pageCoord = root.getAttribute('data-nostr-page-article-coordinate') || ''; |
||||||
|
const pageEid = (root.getAttribute('data-nostr-page-article-event-id') || '').toLowerCase(); |
||||||
|
const pagePubHex = (root.getAttribute('data-nostr-page-article-pubkey-hex') || '').toLowerCase(); |
||||||
|
const pageNpub = root.getAttribute('data-nostr-page-article-npub') || ''; |
||||||
|
if (!pageCoord) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
let d; |
||||||
|
try { |
||||||
|
d = JSON.parse(decodedStr); |
||||||
|
} catch { |
||||||
|
return false; |
||||||
|
} |
||||||
|
if (type === 'naddr' && d && d.pubkey != null) { |
||||||
|
const identRaw = d.identifier != null ? d.identifier : (d.specifier != null ? d.specifier : null); |
||||||
|
if (identRaw == null) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
const k = d.kind != null ? parseInt(String(d.kind), 10) : 30023; |
||||||
|
const ident = String(identRaw); |
||||||
|
let pk = String(d.pubkey); |
||||||
|
if (/^[0-9a-fA-F]{64}$/.test(pk)) { |
||||||
|
pk = pk.toLowerCase(); |
||||||
|
} else if (pk.startsWith('npub1') && pageNpub) { |
||||||
|
if (pk !== pageNpub) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
pk = pagePubHex; |
||||||
|
} else { |
||||||
|
return false; |
||||||
|
} |
||||||
|
if (!pk || pk.length !== 64) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
const candidate = `${k}:${pk}:${ident}`; |
||||||
|
|
||||||
|
return candidate === pageCoord; |
||||||
|
} |
||||||
|
if (type === 'nevent' && d && d.id && pageEid) { |
||||||
|
return String(d.id).toLowerCase() === pageEid; |
||||||
|
} |
||||||
|
|
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
export default class extends Controller { |
export default class extends Controller { |
||||||
static values = { |
static values = { |
||||||
identifier: String, |
identifier: String, |
||||||
type: String, |
type: String, |
||||||
decoded: String, |
decoded: String, |
||||||
fullMatch: String |
fullMatch: String, |
||||||
} |
}; |
||||||
|
|
||||||
static targets = ['container'] |
static targets = ['container']; |
||||||
|
|
||||||
async connect() { |
connect() { |
||||||
await this.fetchPreview(); |
if (this.typeValue === 'naddr' || this.typeValue === 'nevent') { |
||||||
|
if (isPreviewForSameArticleOnPage(this.element, this.typeValue, this.decodedValue)) { |
||||||
|
this.element.setAttribute('hidden', ''); |
||||||
|
this.element.setAttribute('data-nostr-preview-suppressed', 'same-page-article'); |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
this.fetchPreview(); |
||||||
} |
} |
||||||
|
|
||||||
async fetchPreview() { |
async fetchPreview() { |
||||||
|
if (!this.hasContainerTarget) { |
||||||
|
return; |
||||||
|
} |
||||||
|
this.containerTarget.innerHTML = LOADING_HTML; |
||||||
try { |
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) { |
if (this.typeValue === 'url' && this.fullMatchValue) { |
||||||
// Fetch OG preview for plain URLs
|
const res = await fetch('/og-preview/', { |
||||||
fetch("/og-preview/", { |
method: 'POST', |
||||||
method: "POST", |
headers: { 'Content-Type': 'application/json' }, |
||||||
headers: { |
body: JSON.stringify({ url: this.fullMatchValue }), |
||||||
"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>`; |
|
||||||
}); |
}); |
||||||
} else { |
if (!res.ok) { |
||||||
// Fallback to Nostr preview
|
throw new Error(`HTTP ${res.status}`); |
||||||
const data = { |
} |
||||||
|
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, |
identifier: this.identifierValue, |
||||||
type: this.typeValue, |
type: this.typeValue, |
||||||
decoded: this.decodedValue |
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}`); |
||||||
} |
} |
||||||
} catch (error) { |
this.containerTarget.innerHTML = await res.text(); |
||||||
console.error('Error fetching Nostr preview:', error); |
} catch (e) { |
||||||
this.containerTarget.innerHTML = `<div class="alert alert-warning">Unable to load preview for ${this.fullMatchValue}</div>`; |
// 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; |
||||||
} |
} |
||||||
} |
} |
||||||
} |
} |
||||||
|
|||||||
@ -0,0 +1,279 @@ |
|||||||
|
import { Controller } from '@hotwired/stimulus'; |
||||||
|
|
||||||
|
const HIDE_MS = 180; |
||||||
|
|
||||||
|
function el(tag, cls, parent) { |
||||||
|
const e = document.createElement(tag); |
||||||
|
if (cls) { |
||||||
|
e.className = cls; |
||||||
|
} |
||||||
|
if (parent) { |
||||||
|
parent.appendChild(e); |
||||||
|
} |
||||||
|
return e; |
||||||
|
} |
||||||
|
|
||||||
|
function shortNpub(n) { |
||||||
|
if (n == null || n.length < 16) { |
||||||
|
return n || ''; |
||||||
|
} |
||||||
|
return `${n.slice(0, 12)}…${n.slice(-6)}`; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* In-article highlight marks: hover/focus to show a tooltip of user-badges for everyone |
||||||
|
* who highlighted the same passage (data-hl JSON from {@see \App\Service\ArticleBodyHighlightInjector}). |
||||||
|
*/ |
||||||
|
export default class extends Controller { |
||||||
|
connect() { |
||||||
|
this.tip = el('div', 'user-highlight__tip-popover', document.body); |
||||||
|
this.tip.setAttribute('role', 'tooltip'); |
||||||
|
this.tip.setAttribute('hidden', ''); |
||||||
|
|
||||||
|
this.activeMark = null; |
||||||
|
this._hideT = 0; |
||||||
|
this._inTip = false; |
||||||
|
|
||||||
|
this._onOver = (e) => { |
||||||
|
if (!(e instanceof MouseEvent)) { |
||||||
|
return; |
||||||
|
} |
||||||
|
const t = e.target; |
||||||
|
if (!(t instanceof Node)) { |
||||||
|
return; |
||||||
|
} |
||||||
|
const m = |
||||||
|
t.nodeType === 1 |
||||||
|
? /** @type {Element} */ (t).closest('mark.user-highlight__marker[data-hl]') |
||||||
|
: t.parentElement?.closest('mark.user-highlight__marker[data-hl]') ?? null; |
||||||
|
if (m) { |
||||||
|
this._cancelHide(); |
||||||
|
this._show(/** @type {HTMLElement} */ (m), e); |
||||||
|
} |
||||||
|
}; |
||||||
|
this._onOut = (e) => { |
||||||
|
if (!(e instanceof MouseEvent)) { |
||||||
|
return; |
||||||
|
} |
||||||
|
const t = e.target; |
||||||
|
if (!(t instanceof Node)) { |
||||||
|
return; |
||||||
|
} |
||||||
|
const m = |
||||||
|
t.nodeType === 1 |
||||||
|
? /** @type {Element} */ (t).closest('mark.user-highlight__marker[data-hl]') |
||||||
|
: null; |
||||||
|
if (m) { |
||||||
|
const to = e.relatedTarget; |
||||||
|
if (to && (m.contains(to) || (to instanceof Node && this.tip.contains(/** @type {Node} */(to))))) { |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
this._scheduleHide(); |
||||||
|
}; |
||||||
|
|
||||||
|
this.tip.addEventListener('mouseenter', () => { |
||||||
|
this._inTip = true; |
||||||
|
this._cancelHide(); |
||||||
|
}); |
||||||
|
this.tip.addEventListener('mouseleave', () => { |
||||||
|
this._inTip = false; |
||||||
|
this._scheduleHide(); |
||||||
|
}); |
||||||
|
|
||||||
|
this._onFocus = (e) => { |
||||||
|
const t = e.target; |
||||||
|
if (!(t instanceof Element)) { |
||||||
|
return; |
||||||
|
} |
||||||
|
const m = t.closest('mark.user-highlight__marker[data-hl]'); |
||||||
|
if (m) { |
||||||
|
this._cancelHide(); |
||||||
|
this._show(/** @type {HTMLElement} */ (m), e); |
||||||
|
} |
||||||
|
}; |
||||||
|
this._onBlur = (e) => { |
||||||
|
const t = e.target; |
||||||
|
if (!(t instanceof Node)) { |
||||||
|
return; |
||||||
|
} |
||||||
|
const m = t.nodeType === 1 ? t.closest('mark.user-highlight__marker[data-hl]') : null; |
||||||
|
if (m) { |
||||||
|
const to = e.relatedTarget; |
||||||
|
if (to && (m.contains(to) || (to instanceof Node && this.tip.contains(/** @type {Node} */ (to))))) { |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
this._scheduleHide(); |
||||||
|
}; |
||||||
|
|
||||||
|
this.element.addEventListener('mouseover', this._onOver); |
||||||
|
this.element.addEventListener('mouseout', this._onOut); |
||||||
|
this.element.addEventListener('focusin', this._onFocus); |
||||||
|
this.element.addEventListener('focusout', this._onBlur); |
||||||
|
|
||||||
|
this._onResize = () => { |
||||||
|
if (this.activeMark) { |
||||||
|
this._place(this.activeMark); |
||||||
|
} |
||||||
|
}; |
||||||
|
window.addEventListener('resize', this._onResize); |
||||||
|
|
||||||
|
this._onHashChange = () => { |
||||||
|
this._scrollToHashHighlight(); |
||||||
|
}; |
||||||
|
window.addEventListener('hashchange', this._onHashChange); |
||||||
|
this._scrollToHashHighlight(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Browsers are inconsistent about scrolling to #highlight-<event id> (inline marks, alias spans, |
||||||
|
* late layout). Mirror native intent after paint. |
||||||
|
*/ |
||||||
|
_scrollToHashHighlight() { |
||||||
|
const hash = window.location.hash; |
||||||
|
if (!hash?.startsWith('#highlight-')) { |
||||||
|
return; |
||||||
|
} |
||||||
|
const id = decodeURIComponent(hash.slice(1)); |
||||||
|
if (!/^highlight-[a-f0-9]{64}$/i.test(id)) { |
||||||
|
return; |
||||||
|
} |
||||||
|
const run = () => { |
||||||
|
const node = document.getElementById(id); |
||||||
|
if (!(node instanceof HTMLElement)) { |
||||||
|
return; |
||||||
|
} |
||||||
|
const next = node.nextElementSibling; |
||||||
|
const target = |
||||||
|
node.classList.contains('user-highlight__fragment-target') && |
||||||
|
next?.classList?.contains('user-highlight__marker') |
||||||
|
? next |
||||||
|
: node; |
||||||
|
target.scrollIntoView({ block: 'start', inline: 'nearest' }); |
||||||
|
}; |
||||||
|
requestAnimationFrame(() => { |
||||||
|
requestAnimationFrame(run); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
disconnect() { |
||||||
|
this.element.removeEventListener('mouseover', this._onOver); |
||||||
|
this.element.removeEventListener('mouseout', this._onOut); |
||||||
|
this.element.removeEventListener('focusin', this._onFocus); |
||||||
|
this.element.removeEventListener('focusout', this._onBlur); |
||||||
|
window.removeEventListener('resize', this._onResize); |
||||||
|
if (this._onHashChange) { |
||||||
|
window.removeEventListener('hashchange', this._onHashChange); |
||||||
|
} |
||||||
|
this._cancelHide(); |
||||||
|
this.tip.remove(); |
||||||
|
} |
||||||
|
|
||||||
|
_cancelHide() { |
||||||
|
if (this._hideT) { |
||||||
|
clearTimeout(this._hideT); |
||||||
|
this._hideT = 0; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
_scheduleHide() { |
||||||
|
this._cancelHide(); |
||||||
|
this._hideT = window.setTimeout(() => { |
||||||
|
this._hideT = 0; |
||||||
|
if (this._inTip) { |
||||||
|
return; |
||||||
|
} |
||||||
|
this._doHide(); |
||||||
|
}, HIDE_MS); |
||||||
|
} |
||||||
|
|
||||||
|
_doHide() { |
||||||
|
this.tip.setAttribute('hidden', ''); |
||||||
|
this.tip.replaceChildren(); |
||||||
|
this.activeMark = null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {HTMLElement} mark |
||||||
|
* @param {UIEvent} _e |
||||||
|
*/ |
||||||
|
_show(mark, _e) { |
||||||
|
this.activeMark = mark; |
||||||
|
const raw = mark.getAttribute('data-hl'); |
||||||
|
if (raw == null || raw === '') { |
||||||
|
this._doHide(); |
||||||
|
return; |
||||||
|
} |
||||||
|
/** @type {Array<{e?: string, n: string, a?: string, p?: string}>} */ |
||||||
|
let rows; |
||||||
|
try { |
||||||
|
rows = JSON.parse(raw); |
||||||
|
} catch { |
||||||
|
this._doHide(); |
||||||
|
return; |
||||||
|
} |
||||||
|
if (!Array.isArray(rows) || rows.length === 0) { |
||||||
|
this._doHide(); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
this.tip.removeAttribute('hidden'); |
||||||
|
this.tip.replaceChildren(); |
||||||
|
|
||||||
|
const head = el('div', 'user-highlight__tip-head', this.tip); |
||||||
|
head.textContent = 'Highlighted by'; |
||||||
|
|
||||||
|
const list = el('ul', 'user-highlight__tip-list', this.tip); |
||||||
|
for (const row of rows) { |
||||||
|
if (!row || typeof row.n !== 'string' || !row.n.startsWith('npub1')) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
const li = el('li', 'user-highlight__tip-item', list); |
||||||
|
const a = el('a', 'user-badge user-badge--in-tip', li); |
||||||
|
a.setAttribute('href', `/p/${encodeURIComponent(row.n)}`); |
||||||
|
const label = (row.a && String(row.a).trim() !== '' ? String(row.a) : shortNpub(row.n)) || shortNpub(row.n); |
||||||
|
|
||||||
|
const av = el('span', 'user-badge__avatar user-badge__avatar--in-tip', a); |
||||||
|
if (row.p && typeof row.p === 'string' && row.p.length > 0) { |
||||||
|
const im = el('img', 'user-badge__avatar-img', av); |
||||||
|
im.setAttribute('src', row.p); |
||||||
|
im.setAttribute('alt', ''); |
||||||
|
im.setAttribute('loading', 'lazy'); |
||||||
|
im.addEventListener('error', () => { |
||||||
|
im.remove(); |
||||||
|
av.setAttribute('aria-hidden', 'true'); |
||||||
|
const dot = el('span', 'user-badge__avatar-fallback--dot', av); |
||||||
|
dot.textContent = label.charAt(0).toUpperCase() || '…'; |
||||||
|
}); |
||||||
|
} else { |
||||||
|
av.setAttribute('aria-hidden', 'true'); |
||||||
|
const dot = el('span', 'user-badge__avatar-fallback--dot', av); |
||||||
|
dot.textContent = label.charAt(0).toUpperCase() || '…'; |
||||||
|
} |
||||||
|
const nm = el('span', 'user-badge__name', a); |
||||||
|
nm.appendChild(document.createTextNode(label)); |
||||||
|
} |
||||||
|
|
||||||
|
requestAnimationFrame(() => { |
||||||
|
this._place(mark); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {HTMLElement} mark |
||||||
|
*/ |
||||||
|
_place(mark) { |
||||||
|
const r = mark.getBoundingClientRect(); |
||||||
|
const pad = 8; |
||||||
|
this.tip.style.position = 'fixed'; |
||||||
|
this.tip.style.zIndex = '2000'; |
||||||
|
this.tip.style.top = `${Math.round(r.bottom + pad)}px`; |
||||||
|
let left = Math.round(r.left); |
||||||
|
const w = this.tip.getBoundingClientRect().width || 300; |
||||||
|
if (left + w + 12 > window.innerWidth) { |
||||||
|
left = Math.max(8, window.innerWidth - w - 8); |
||||||
|
} |
||||||
|
this.tip.style.left = `${left}px`; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,11 @@ |
|||||||
|
# Enable stateless CSRF protection for forms and logins/logouts |
||||||
|
framework: |
||||||
|
form: |
||||||
|
csrf_protection: |
||||||
|
token_id: submit |
||||||
|
|
||||||
|
csrf_protection: |
||||||
|
stateless_token_ids: |
||||||
|
- submit |
||||||
|
- authenticate |
||||||
|
- logout |
||||||
@ -0,0 +1,11 @@ |
|||||||
|
services: |
||||||
|
# Register nyholm/psr7 services for autowiring with PSR-17 (HTTP factories) |
||||||
|
Psr\Http\Message\RequestFactoryInterface: '@nyholm.psr7.psr17_factory' |
||||||
|
Psr\Http\Message\ResponseFactoryInterface: '@nyholm.psr7.psr17_factory' |
||||||
|
Psr\Http\Message\ServerRequestFactoryInterface: '@nyholm.psr7.psr17_factory' |
||||||
|
Psr\Http\Message\StreamFactoryInterface: '@nyholm.psr7.psr17_factory' |
||||||
|
Psr\Http\Message\UploadedFileFactoryInterface: '@nyholm.psr7.psr17_factory' |
||||||
|
Psr\Http\Message\UriFactoryInterface: '@nyholm.psr7.psr17_factory' |
||||||
|
|
||||||
|
nyholm.psr7.psr17_factory: |
||||||
|
class: Nyholm\Psr7\Factory\Psr17Factory |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
framework: |
||||||
|
property_info: |
||||||
|
with_constructor_extractor: true |
||||||
@ -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,22 @@ |
|||||||
|
--- a/src/EventInterface.php 2026-04-27 18:53:54.019977813 +0200
|
||||||
|
+++ b/src/EventInterface.php 2026-04-27 18:53:54.021843273 +0200
|
||||||
|
@@ -109,7 +109,7 @@
|
||||||
|
/**
|
||||||
|
* Set the event tags with values.
|
||||||
|
*
|
||||||
|
- * @param array $tags[]
|
||||||
|
+ * @param array $tags
|
||||||
|
* [
|
||||||
|
* ["e", "..."],
|
||||||
|
* ["p", "...", "..."],
|
||||||
|
--- a/src/RelaySetInterface.php 2026-04-27 18:53:54.021655232 +0200
|
||||||
|
+++ b/src/RelaySetInterface.php 2026-04-27 18:53:54.023843373 +0200
|
||||||
|
@@ -54,7 +54,7 @@
|
||||||
|
/**
|
||||||
|
* Connect to all relays in this set.
|
||||||
|
*
|
||||||
|
- * @param bool $throwOnErrorx
|
||||||
|
+ * @param bool $throwOnError
|
||||||
|
* If true, throw an exception if any relay fails to connect.
|
||||||
|
* If false, return false if any relay fails to connect.
|
||||||
|
* @return bool
|
||||||
@ -0,0 +1,673 @@ |
|||||||
|
parameters: |
||||||
|
ignoreErrors: |
||||||
|
- |
||||||
|
message: '#^Instanceof between App\\Entity\\ArticleHighlight and App\\Entity\\ArticleHighlight will always evaluate to true\.$#' |
||||||
|
identifier: instanceof.alwaysTrue |
||||||
|
count: 1 |
||||||
|
path: src/Command/ArticleHighlightsAuditCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Using nullsafe property access "\?\-\>value" on left side of \?\? is unnecessary\. Use \-\> instead\.$#' |
||||||
|
identifier: nullsafe.neverNull |
||||||
|
count: 1 |
||||||
|
path: src/Command/ArticleHighlightsAuditCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to an undefined method Symfony\\Contracts\\Cache\\CacheInterface\:\:getItem\(\)\.$#' |
||||||
|
identifier: method.notFound |
||||||
|
count: 1 |
||||||
|
path: src/Command/NostrEventFromYamlDefinitionCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to an undefined method Symfony\\Contracts\\Cache\\CacheInterface\:\:save\(\)\.$#' |
||||||
|
identifier: method.notFound |
||||||
|
count: 1 |
||||||
|
path: src/Command/NostrEventFromYamlDefinitionCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to function is_array\(\) with bool\|int\|string\|null will always evaluate to false\.$#' |
||||||
|
identifier: function.impossibleType |
||||||
|
count: 1 |
||||||
|
path: src/Command/PrewarmCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#' |
||||||
|
identifier: function.alreadyNarrowedType |
||||||
|
count: 2 |
||||||
|
path: src/Command/PrewarmCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to function method_exists\(\) with Symfony\\Component\\Console\\Helper\\ProgressBar and ''clear'' will always evaluate to true\.$#' |
||||||
|
identifier: function.alreadyNarrowedType |
||||||
|
count: 1 |
||||||
|
path: src/Command/PrewarmCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to function method_exists\(\) with Symfony\\Component\\Console\\Helper\\ProgressBar and ''setMinSecondsBetwee…'' will always evaluate to false\.$#' |
||||||
|
identifier: function.impossibleType |
||||||
|
count: 1 |
||||||
|
path: src/Command/PrewarmCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''categories'' on array\{categories\: int, listed\: int, resolved\: int, missing\: int\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Command/PrewarmCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''categories'' on array\{categories\: list\<array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\}\>, totals\: array\{categories\: int, listed\: int, resolved\: int, missing\: int\}\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Command/PrewarmCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''coordinate'' on array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Command/PrewarmCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''entries'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Command/PrewarmCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''event_id'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Command/PrewarmCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''kind0_tags'' on array\{content\: stdClass, kind0_tags\: list\<list\<string\>\>\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Command/PrewarmCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''label'' on array\{label\: string, href\: string\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Command/PrewarmCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''listed'' on array\{categories\: int, listed\: int, resolved\: int, missing\: int\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Command/PrewarmCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''listed_total'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Command/PrewarmCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''missing'' on array\{categories\: int, listed\: int, resolved\: int, missing\: int\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Command/PrewarmCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''missing_total'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Command/PrewarmCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''reason'' on array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Command/PrewarmCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''resolved'' on array\{categories\: int, listed\: int, resolved\: int, missing\: int\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Command/PrewarmCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''resolved_total'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Command/PrewarmCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''slug'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Command/PrewarmCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''status'' on array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Command/PrewarmCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''title'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Command/PrewarmCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''totals'' on array\{categories\: list\<array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\}\>, totals\: array\{categories\: int, listed\: int, resolved\: int, missing\: int\}\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Command/PrewarmCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset 0 on non\-empty\-list\<non\-falsy\-string\> on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Command/PrewarmCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Strict comparison using \=\=\= between \*NEVER\* and 1 will always evaluate to false\.$#' |
||||||
|
identifier: identical.alwaysFalse |
||||||
|
count: 1 |
||||||
|
path: src/Command/PrewarmCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Strict comparison using \=\=\= between array\{\} and array\{\} will always evaluate to true\.$#' |
||||||
|
identifier: identical.alwaysTrue |
||||||
|
count: 1 |
||||||
|
path: src/Command/PrewarmCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Using nullsafe property access "\?\-\>value" on left side of \?\? is unnecessary\. Use \-\> instead\.$#' |
||||||
|
identifier: nullsafe.neverNull |
||||||
|
count: 1 |
||||||
|
path: src/Command/PrewarmCommand.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to an undefined method Symfony\\Component\\Form\\FormInterface\<mixed\>\:\:getClickedButton\(\)\.$#' |
||||||
|
identifier: method.notFound |
||||||
|
count: 3 |
||||||
|
path: src/Controller/ArticleController.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to an undefined method Symfony\\Component\\Security\\Core\\User\\UserInterface\:\:getMetadata\(\)\.$#' |
||||||
|
identifier: method.notFound |
||||||
|
count: 1 |
||||||
|
path: src/Controller/ArticleController.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#' |
||||||
|
identifier: function.alreadyNarrowedType |
||||||
|
count: 1 |
||||||
|
path: src/Controller/ArticleController.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to function is_object\(\) with stdClass will always evaluate to true\.$#' |
||||||
|
identifier: function.alreadyNarrowedType |
||||||
|
count: 1 |
||||||
|
path: src/Controller/ArticleController.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''comment_reply…'' on array\{list\: array\<int, object\>, quotes\: array\<int, object\>, commentLinks\: array\<string, array\<int, mixed\>\>, quoteLinks\: array\<string, array\<int, mixed\>\>, processedContent\: array\<string, string\>, comment_reply_context\: array\{can_publish\: bool, coordinate\: string, article_event_id\: string\|null, parent_kind\: int, rows\: array\<int, array\<string, mixed\>\>, fragment_url\: string\}\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Controller/ArticleController.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''list'' on array\{list\: array\<int, object\>, quotes\: array\<int, object\>, commentLinks\: array\<string, array\<int, mixed\>\>, quoteLinks\: array\<string, array\<int, mixed\>\>, processedContent\: array\<string, string\>\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Controller/ArticleController.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset 0 on non\-empty\-list\<string\> in isset\(\) always exists and is not nullable\.$#' |
||||||
|
identifier: isset.offset |
||||||
|
count: 1 |
||||||
|
path: src/Controller/ArticleController.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Using nullsafe property access "\?\-\>value" on left side of \?\? is unnecessary\. Use \-\> instead\.$#' |
||||||
|
identifier: nullsafe.neverNull |
||||||
|
count: 1 |
||||||
|
path: src/Controller/ArticleController.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''ok_relays'' on array\{ok\: true, id\: string, relays\: array\<string, mixed\>, ok_relays\: int, total_relays\: int\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Controller/CommentReplyController.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''total_relays'' on array\{ok\: true, id\: string, relays\: array\<string, mixed\>, ok_relays\: int, total_relays\: int\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Controller/CommentReplyController.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Negated boolean expression is always false\.$#' |
||||||
|
identifier: booleanNot.alwaysFalse |
||||||
|
count: 1 |
||||||
|
path: src/Controller/DefaultController.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to function is_array\(\) with array\<int, string\> will always evaluate to true\.$#' |
||||||
|
identifier: function.alreadyNarrowedType |
||||||
|
count: 1 |
||||||
|
path: src/Controller/SeoController.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#' |
||||||
|
identifier: function.alreadyNarrowedType |
||||||
|
count: 1 |
||||||
|
path: src/Controller/SeoController.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''list'' on array\{list\: list\<App\\Entity\\Article\>, category\: array\{title\: string, summary\: string\}, pagination\: array\{page\: int, per_page\: int, total\: int, last_page\: int\}\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Controller/SeoController.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''summary'' on array\{title\: string, summary\: string\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Controller/SeoController.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''title'' on array\{title\: string, summary\: string\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Controller/SeoController.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to function is_array\(\) with non\-empty\-array will always evaluate to true\.$#' |
||||||
|
identifier: function.alreadyNarrowedType |
||||||
|
count: 1 |
||||||
|
path: src/Form/DataTransformer/CommaSeparatedToArrayTransformer.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to function is_string\(\) with non\-empty\-string will always evaluate to true\.$#' |
||||||
|
identifier: function.alreadyNarrowedType |
||||||
|
count: 1 |
||||||
|
path: src/Form/DataTransformer/CommaSeparatedToArrayTransformer.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#' |
||||||
|
identifier: function.alreadyNarrowedType |
||||||
|
count: 1 |
||||||
|
path: src/Nostr/MagazineEventKeys.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to function is_array\(\) with array will always evaluate to true\.$#' |
||||||
|
identifier: function.alreadyNarrowedType |
||||||
|
count: 3 |
||||||
|
path: src/Nostr/Nip19Codec.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to function is_array\(\) with array\<mixed\> will always evaluate to true\.$#' |
||||||
|
identifier: function.alreadyNarrowedType |
||||||
|
count: 1 |
||||||
|
path: src/Nostr/Nip22CommentTags.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Comparison operation "\>\=" between int\<1, max\> and 1 is always true\.$#' |
||||||
|
identifier: greaterOrEqual.alwaysTrue |
||||||
|
count: 1 |
||||||
|
path: src/Nostr/Nip22CommentTags.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Instanceof between App\\Entity\\ArticleHighlight and App\\Entity\\ArticleHighlight will always evaluate to true\.$#' |
||||||
|
identifier: instanceof.alwaysTrue |
||||||
|
count: 2 |
||||||
|
path: src/Service/ArticleBodyHighlightInjector.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Instanceof between DOMElement and DOMElement will always evaluate to true\.$#' |
||||||
|
identifier: instanceof.alwaysTrue |
||||||
|
count: 1 |
||||||
|
path: src/Service/ArticleBodyHighlightInjector.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Negated boolean expression is always false\.$#' |
||||||
|
identifier: booleanNot.alwaysFalse |
||||||
|
count: 1 |
||||||
|
path: src/Service/ArticleBodyHighlightInjector.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Strict comparison using \=\=\= between false and DOMElement will always evaluate to false\.$#' |
||||||
|
identifier: identical.alwaysFalse |
||||||
|
count: 1 |
||||||
|
path: src/Service/ArticleBodyHighlightInjector.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#' |
||||||
|
identifier: function.alreadyNarrowedType |
||||||
|
count: 2 |
||||||
|
path: src/Service/ArticleCommentThreadLoader.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''partial'' on array\{thread\: array\<int, object\>, quotes\: array\<int, object\>\} on left side of \?\? does not exist\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Service/ArticleCommentThreadLoader.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''quotes'' on array\{thread\: array\<int, object\>, quotes\: array\<int, object\>, partial\?\: bool\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Service/ArticleCommentThreadLoader.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''quotes'' on array\{thread\: array\<int, object\>, quotes\: array\<int, object\>\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Service/ArticleCommentThreadLoader.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''thread'' on array\{thread\: array\<int, object\>, quotes\: array\<int, object\>, partial\?\: bool\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Service/ArticleCommentThreadLoader.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''thread'' on array\{thread\: array\<int, object\>, quotes\: array\<int, object\>\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Service/ArticleCommentThreadLoader.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset 0 on non\-empty\-list\<string\> on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Service/ArticleCommentThreadLoader.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to function is_object\(\) with stdClass will always evaluate to true\.$#' |
||||||
|
identifier: function.alreadyNarrowedType |
||||||
|
count: 2 |
||||||
|
path: src/Service/CacheService.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Negated boolean expression is always false\.$#' |
||||||
|
identifier: booleanNot.alwaysFalse |
||||||
|
count: 1 |
||||||
|
path: src/Service/CacheService.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset 0 on non\-empty\-list\<string\> on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Service/CacheService.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Parameter \#1 \$array \(non\-empty\-list\<string\>\) of array_values is already a list, call has no effect\.$#' |
||||||
|
identifier: arrayValues.list |
||||||
|
count: 1 |
||||||
|
path: src/Service/CacheService.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Strict comparison using \!\=\= between non\-empty\-list\<string\> and array\{\} will always evaluate to true\.$#' |
||||||
|
identifier: notIdentical.alwaysTrue |
||||||
|
count: 1 |
||||||
|
path: src/Service/CacheService.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Comparison operation "\>\=" between 3 and 2 is always true\.$#' |
||||||
|
identifier: greaterOrEqual.alwaysTrue |
||||||
|
count: 1 |
||||||
|
path: src/Service/CommentReplyService.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#' |
||||||
|
identifier: function.alreadyNarrowedType |
||||||
|
count: 1 |
||||||
|
path: src/Service/HighlightSyncService.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Using nullsafe property access "\?\-\>value" on left side of \?\? is unnecessary\. Use \-\> instead\.$#' |
||||||
|
identifier: nullsafe.neverNull |
||||||
|
count: 1 |
||||||
|
path: src/Service/HighlightSyncService.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#' |
||||||
|
identifier: function.alreadyNarrowedType |
||||||
|
count: 3 |
||||||
|
path: src/Service/MagazineContentService.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to function method_exists\(\) with App\\Entity\\Event and ''getTags'' will always evaluate to true\.$#' |
||||||
|
identifier: function.alreadyNarrowedType |
||||||
|
count: 1 |
||||||
|
path: src/Service/MagazineContentService.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''categories'' on array\{categories\: list\<array\{entries\: list\<array\{coordinate\: string, status\: string, reason\: string\}\>\}\>\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Service/MagazineContentService.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''coordinate'' on array\{coordinate\: string, status\: ''missing'', reason\: ''article_not_in_db''\} in isset\(\) always exists and is not nullable\.$#' |
||||||
|
identifier: isset.offset |
||||||
|
count: 1 |
||||||
|
path: src/Service/MagazineContentService.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''entries'' on array\{entries\: list\<array\{coordinate\: string, status\: string, reason\: string\}\>\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Service/MagazineContentService.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''reason'' on array\{coordinate\: string, status\: ''missing'', reason\: string\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Service/MagazineContentService.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''status'' on array\{coordinate\: string, status\: string, reason\: string\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Service/MagazineContentService.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset 0 on non\-empty\-list\<string\> on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Service/MagazineContentService.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Cannot call method __invoke\(\) on callable\.$#' |
||||||
|
identifier: method.nonObject |
||||||
|
count: 4 |
||||||
|
path: src/Service/MagazineRefresher.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''label'' on array\{label\: string, href\: string, verified\?\: bool\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Service/Nip05VerificationService.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#' |
||||||
|
identifier: function.alreadyNarrowedType |
||||||
|
count: 1 |
||||||
|
path: src/Service/Nip09DeletionApplier.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to an undefined method Symfony\\Component\\Security\\Core\\User\\UserInterface\:\:getRelays\(\)\.$#' |
||||||
|
identifier: method.notFound |
||||||
|
count: 2 |
||||||
|
path: src/Service/NostrClient.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to function is_array\(\) with array will always evaluate to true\.$#' |
||||||
|
identifier: function.alreadyNarrowedType |
||||||
|
count: 1 |
||||||
|
path: src/Service/NostrClient.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#' |
||||||
|
identifier: function.alreadyNarrowedType |
||||||
|
count: 6 |
||||||
|
path: src/Service/NostrClient.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#' |
||||||
|
identifier: function.alreadyNarrowedType |
||||||
|
count: 6 |
||||||
|
path: src/Service/NostrClient.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Method App\\Service\\NostrClient\:\:fetchKind5DeletionEventsForAuthors\(\) has invalid return type App\\Service\\stdClass\.$#' |
||||||
|
identifier: class.notFound |
||||||
|
count: 1 |
||||||
|
path: src/Service/NostrClient.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''dTags'' on array\{pubkey\: string, kind\: int\<1, max\>, dTags\: non\-empty\-list\<non\-empty\-string\>\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Service/NostrClient.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''kind'' on array\{pubkey\: string, kind\: int\<1, max\>, dTags\: non\-empty\-list\<non\-empty\-string\>\} in isset\(\) always exists and is not nullable\.$#' |
||||||
|
identifier: isset.offset |
||||||
|
count: 1 |
||||||
|
path: src/Service/NostrClient.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''pubkey'' on array\{pubkey\: string, kind\: int\<1, max\>, dTags\: non\-empty\-list\<non\-empty\-string\>\} in isset\(\) always exists and is not nullable\.$#' |
||||||
|
identifier: isset.offset |
||||||
|
count: 1 |
||||||
|
path: src/Service/NostrClient.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''pubkey'' on array\{pubkey\: string, kind\: int\<1, max\>, dTags\: non\-empty\-list\<non\-empty\-string\>\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Service/NostrClient.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^PHPDoc tag @return with type swentel\\nostr\\Event\\Event\|null is not subtype of native type stdClass\|null\.$#' |
||||||
|
identifier: return.phpDocType |
||||||
|
count: 1 |
||||||
|
path: src/Service/NostrClient.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Parameter \#1 \$array \(non\-empty\-list\<string\>\) of array_values is already a list, call has no effect\.$#' |
||||||
|
identifier: arrayValues.list |
||||||
|
count: 1 |
||||||
|
path: src/Service/NostrClient.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Result of \|\| is always false\.$#' |
||||||
|
identifier: booleanOr.alwaysFalse |
||||||
|
count: 1 |
||||||
|
path: src/Service/NostrClient.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Strict comparison using \=\=\= between non\-empty\-list\<non\-empty\-string\> and array\{\} will always evaluate to false\.$#' |
||||||
|
identifier: identical.alwaysFalse |
||||||
|
count: 1 |
||||||
|
path: src/Service/NostrClient.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^PHPDoc tag @param for parameter \$event with type ArrayObject\<int, mixed\>\|list\<mixed\> is not subtype of native type object\.$#' |
||||||
|
identifier: parameter.phpDocType |
||||||
|
count: 1 |
||||||
|
path: src/Service/NostrShareMenuBuilder.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Using nullsafe property access "\?\-\>value" on left side of \?\? is unnecessary\. Use \-\> instead\.$#' |
||||||
|
identifier: nullsafe.neverNull |
||||||
|
count: 1 |
||||||
|
path: src/Service/NostrShareMenuBuilder.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset ''label'' on array\{label\: string, href\: string\} on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Service/ProfileIdentityLinksBuilder.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset 0 on non\-empty\-list\<string\> on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 2 |
||||||
|
path: src/Service/ProfileIdentityLinksBuilder.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#' |
||||||
|
identifier: function.alreadyNarrowedType |
||||||
|
count: 1 |
||||||
|
path: src/Service/ProfilePaymentLinksBuilder.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset non\-falsy\-string on array\{\} in isset\(\) does not exist\.$#' |
||||||
|
identifier: isset.offset |
||||||
|
count: 1 |
||||||
|
path: src/Service/ProfilePaymentLinksBuilder.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Parameter \#1 \$array \(non\-empty\-list\<string\>\) of array_values is already a list, call has no effect\.$#' |
||||||
|
identifier: arrayValues.list |
||||||
|
count: 1 |
||||||
|
path: src/Service/ProfilePaymentLinksBuilder.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Strict comparison using \!\=\= between non\-empty\-list\<string\> and array\{\} will always evaluate to true\.$#' |
||||||
|
identifier: notIdentical.alwaysTrue |
||||||
|
count: 1 |
||||||
|
path: src/Service/ProfilePaymentLinksBuilder.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to protected method getEntityManager\(\) of class Doctrine\\ORM\\EntityRepository\<object\>\.$#' |
||||||
|
identifier: method.protected |
||||||
|
count: 1 |
||||||
|
path: src/Service/TopicIndexService.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Property App\\Twig\\Components\\IndexTabs\:\:\$index is never read, only written\.$#' |
||||||
|
identifier: property.onlyWritten |
||||||
|
count: 1 |
||||||
|
path: src/Twig/Components/IndexTabs.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to function method_exists\(\) with App\\Entity\\Event and ''getTags'' will always evaluate to true\.$#' |
||||||
|
identifier: function.alreadyNarrowedType |
||||||
|
count: 1 |
||||||
|
path: src/Twig/Components/Molecules/CategoryLink.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to function method_exists\(\) with App\\Entity\\Event and ''getTags'' will always evaluate to true\.$#' |
||||||
|
identifier: function.alreadyNarrowedType |
||||||
|
count: 1 |
||||||
|
path: src/Twig/Components/Organisms/FeaturedList.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Offset 0 on non\-empty\-list\<string\> on left side of \?\? always exists and is not nullable\.$#' |
||||||
|
identifier: nullCoalesce.offset |
||||||
|
count: 1 |
||||||
|
path: src/Util/NostrEventTags.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^PHPDoc tag @param references unknown parameter\: \$eventIdsLowerOrMixed$#' |
||||||
|
identifier: parameter.notFound |
||||||
|
count: 1 |
||||||
|
path: tests/Service/ArticleBodyHighlightInjectorTest.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Negated boolean expression is always true\.$#' |
||||||
|
identifier: booleanNot.alwaysTrue |
||||||
|
count: 1 |
||||||
|
path: tests/Service/ArticleHighlightCommonMarkPipelineTest.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Unreachable statement \- code above always terminates\.$#' |
||||||
|
identifier: deadCode.unreachable |
||||||
|
count: 1 |
||||||
|
path: tests/Service/ArticleHighlightCommonMarkPipelineTest.php |
||||||
|
|
||||||
|
- |
||||||
|
message: '#^Call to function method_exists\(\) with ''Symfony\\\\Component\\\\Dotenv\\\\Dotenv'' and ''bootEnv'' will always evaluate to true\.$#' |
||||||
|
identifier: function.alreadyNarrowedType |
||||||
|
count: 1 |
||||||
|
path: tests/bootstrap.php |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
# Dependency notes (composer why): |
||||||
|
# - phpdocumentor/reflection-docblock: required directly; symfony/* constrain <6 |
||||||
|
# - phpstan/phpdoc-parser: direct + reflection-docblock / type-resolver |
||||||
|
includes: |
||||||
|
- vendor/phpstan/phpstan-symfony/extension.neon |
||||||
|
- vendor/phpstan/phpstan-doctrine/extension.neon |
||||||
|
- phpstan-baseline.neon |
||||||
|
|
||||||
|
parameters: |
||||||
|
level: 5 |
||||||
|
paths: |
||||||
|
- src |
||||||
|
- tests |
||||||
|
symfony: |
||||||
|
containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml |
||||||
|
doctrine: |
||||||
|
objectManagerLoader: tests/phpstan-doctrine-object-manager.php |
||||||
@ -0,0 +1,160 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Command; |
||||||
|
|
||||||
|
use App\Entity\ArticleHighlight; |
||||||
|
use App\Repository\ArticleHighlightRepository; |
||||||
|
use App\Repository\ArticleRepository; |
||||||
|
use App\Service\ArticleBodyHighlightInjector; |
||||||
|
use App\Util\CommonMark\Converter; |
||||||
|
use App\Service\NostrKeyHelper; |
||||||
|
use League\CommonMark\Exception\CommonMarkException; |
||||||
|
use Symfony\Component\Console\Attribute\AsCommand; |
||||||
|
use Symfony\Component\Console\Command\Command; |
||||||
|
use Symfony\Component\Console\Input\InputArgument; |
||||||
|
use Symfony\Component\Console\Input\InputInterface; |
||||||
|
use Symfony\Component\Console\Input\InputOption; |
||||||
|
use Symfony\Component\Console\Output\OutputInterface; |
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle; |
||||||
|
|
||||||
|
/** |
||||||
|
* Run inside the app container, e.g.: |
||||||
|
* `php bin/console app:article-highlights-audit bitcoin-is-time --npub=npub1…` |
||||||
|
*/ |
||||||
|
#[AsCommand( |
||||||
|
name: 'app:article-highlights-audit', |
||||||
|
description: 'Show how many kind-9802 rows match the article and how many <mark> injections succeed (debugging)', |
||||||
|
)] |
||||||
|
final class ArticleHighlightsAuditCommand extends Command |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
private readonly ArticleRepository $articleRepository, |
||||||
|
private readonly ArticleHighlightRepository $articleHighlightRepository, |
||||||
|
private readonly Converter $converter, |
||||||
|
private readonly ArticleBodyHighlightInjector $articleBodyHighlightInjector, |
||||||
|
private readonly NostrKeyHelper $nostrKeyHelper, |
||||||
|
) { |
||||||
|
parent::__construct(); |
||||||
|
} |
||||||
|
|
||||||
|
protected function configure(): void |
||||||
|
{ |
||||||
|
$this |
||||||
|
->addArgument('slug', InputArgument::REQUIRED, 'Article d-identifier (slug), e.g. bitcoin-is-time') |
||||||
|
->addOption('npub', null, InputOption::VALUE_OPTIONAL, 'If set, must match the article author (npub1…)'); |
||||||
|
} |
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int |
||||||
|
{ |
||||||
|
$io = new SymfonyStyle($input, $output); |
||||||
|
$slug = trim((string) $input->getArgument('slug')); |
||||||
|
if ($slug === '') { |
||||||
|
$io->error('Empty slug.'); |
||||||
|
|
||||||
|
return Command::FAILURE; |
||||||
|
} |
||||||
|
|
||||||
|
$article = $this->articleRepository->findLatestBySlug($slug); |
||||||
|
if (null === $article) { |
||||||
|
$io->error('No article row for this slug.'); |
||||||
|
|
||||||
|
return Command::FAILURE; |
||||||
|
} |
||||||
|
|
||||||
|
$expectedNpub = $this->nostrKeyHelper->convertPublicKeyToBech32((string) $article->getPubkey()); |
||||||
|
$optNpub = $input->getOption('npub'); |
||||||
|
if (\is_string($optNpub) && $optNpub !== '') { |
||||||
|
if ($this->nostrKeyHelper->convertToHex($optNpub) !== strtolower((string) $article->getPubkey())) { |
||||||
|
$io->error('npub does not match this article’s author (expected: '.$expectedNpub.').'); |
||||||
|
|
||||||
|
return Command::FAILURE; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
$io->title('Article highlights audit: '.$slug); |
||||||
|
$io->writeln('Author npub: <info>'.$expectedNpub.'</info>'); |
||||||
|
$io->writeln('Article id: <info>'.(string) $article->getId().'</info> · kind: <info>'. |
||||||
|
($article->getKind()?->value ?? 'null').'</info>'); |
||||||
|
|
||||||
|
$highlights = $this->articleHighlightRepository->findByArticle($article); |
||||||
|
$io->writeln('Rows from <comment>findByArticle</comment>: <info>'.\count($highlights).'</info>'); |
||||||
|
|
||||||
|
try { |
||||||
|
$html = $this->converter->convertToHTML((string) $article->getContent()); |
||||||
|
} catch (CommonMarkException $e) { |
||||||
|
$io->error('CommonMark: '.$e->getMessage()); |
||||||
|
|
||||||
|
return Command::FAILURE; |
||||||
|
} |
||||||
|
|
||||||
|
$out = $this->articleBodyHighlightInjector->inject($html, $highlights); |
||||||
|
$injected = $out['injectedEventIds']; |
||||||
|
$markCount = \substr_count($out['html'], 'user-highlight__marker'); |
||||||
|
$io->writeln('Injected event ids with <comment>all highlights together</comment> (duplicates = same passage): <info>'.\count($injected).'</info>'); |
||||||
|
$io->writeln('<mark class="user-highlight__marker"> count in body: <info>'.$markCount.'</info>'); |
||||||
|
|
||||||
|
$io->section('Each highlight in isolation (same HTML, one 9802 at a time)'); |
||||||
|
$rows = []; |
||||||
|
$isolatedOk = 0; |
||||||
|
foreach ($highlights as $h) { |
||||||
|
if (! $h instanceof ArticleHighlight) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$eid = \strtolower($h->getEventId()); |
||||||
|
$one = $this->articleBodyHighlightInjector->inject($html, [$h]); |
||||||
|
$found = 1 === \preg_match( |
||||||
|
'/\bid=([\'"])highlight-'.preg_quote($eid, '/').'\1/i', |
||||||
|
$one['html'] |
||||||
|
); |
||||||
|
if ($found) { |
||||||
|
++$isolatedOk; |
||||||
|
} |
||||||
|
$snippet = $this->excerptOneLine((string) $h->getContent(), 72); |
||||||
|
$rows[] = [ |
||||||
|
$found ? 'yes' : 'no', |
||||||
|
$eid, |
||||||
|
$snippet, |
||||||
|
]; |
||||||
|
} |
||||||
|
$io->table(['Match', 'event id', 'stored `content` (excerpt)'], $rows); |
||||||
|
if ($isolatedOk < \count($highlights)) { |
||||||
|
$io->writeln( |
||||||
|
'‘Match: no’ means the stored passage is absent from the flattened body text, or it diverges '. |
||||||
|
'(soft hyphens, smart quotes, edits, footnotes, etc.). Re-sync kind 9802 from relays, or adjust matching in ArticleBodyHighlightInjector.' |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
if ($markCount < 1 && \count($highlights) > 0) { |
||||||
|
$io->warning('With all highlights together, nothing was injected. Per-row check above still shows if any row matches in isolation.'); |
||||||
|
} elseif (\count($highlights) < 1) { |
||||||
|
$io->note('No article_highlight rows for this slug+author. Run prewarm highlight sync or check MySQL.'); |
||||||
|
} elseif ($markCount > 0) { |
||||||
|
$io->success('At least one <mark> was produced when all rows were passed to the injector together.'); |
||||||
|
} |
||||||
|
|
||||||
|
if ($io->isVerbose() && $injected !== []) { |
||||||
|
$io->section('Injected event ids (batch, may include several per passage)'); |
||||||
|
$io->listing($injected); |
||||||
|
} |
||||||
|
|
||||||
|
return Command::SUCCESS; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* One line for the table: reflect {@see ArticleHighlight::getContent()} bytes faithfully. |
||||||
|
* Only line breaks are folded to a space so the row stays one line — we do not collapse |
||||||
|
* {@see \p{Z}} or remove U+00AD (soft hyphen); doing that made passages look like they |
||||||
|
* contained ASCII spaces the Nostr `content` never had. |
||||||
|
*/ |
||||||
|
private function excerptOneLine(string $s, int $max): string |
||||||
|
{ |
||||||
|
$s = (string) \preg_replace('/\R/u', ' ', $s); |
||||||
|
if (\mb_strlen($s, 'UTF-8') > $max) { |
||||||
|
$s = \mb_substr($s, 0, $max - 1, 'UTF-8').'…'; |
||||||
|
} |
||||||
|
|
||||||
|
return $s; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,49 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Controller; |
||||||
|
|
||||||
|
use App\Repository\ArticleRepository; |
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
||||||
|
use Symfony\Component\HttpFoundation\Request; |
||||||
|
use Symfony\Component\HttpFoundation\Response; |
||||||
|
use Symfony\Component\Routing\Attribute\Route; |
||||||
|
|
||||||
|
final class TopicController extends AbstractController |
||||||
|
{ |
||||||
|
#[Route( |
||||||
|
path: '/topic/{topic}', |
||||||
|
name: 'topic', |
||||||
|
methods: ['GET'], |
||||||
|
requirements: ['topic' => '[^/]+'], |
||||||
|
)] |
||||||
|
public function byTopic( |
||||||
|
string $topic, |
||||||
|
Request $request, |
||||||
|
ArticleRepository $articleRepository, |
||||||
|
): Response { |
||||||
|
$perPage = 25; |
||||||
|
$page = max(1, $request->query->getInt('page', 1)); |
||||||
|
$total = $articleRepository->countPublishedByTopic($topic); |
||||||
|
$lastPage = max(1, (int) ceil($total / $perPage)); |
||||||
|
if ($page > $lastPage) { |
||||||
|
$page = $lastPage; |
||||||
|
} |
||||||
|
$offset = ($page - 1) * $perPage; |
||||||
|
$list = $articleRepository->findPublishedByTopic($topic, $perPage, $offset); |
||||||
|
$topicParam = $articleRepository->normalizeTopicParam(rawurldecode($topic)); |
||||||
|
|
||||||
|
return $this->render('pages/topic.html.twig', [ |
||||||
|
'topic_param' => $topicParam, |
||||||
|
'topic_label' => $topicParam, |
||||||
|
'list' => $list, |
||||||
|
'pagination' => [ |
||||||
|
'page' => $page, |
||||||
|
'per_page' => $perPage, |
||||||
|
'total' => $total, |
||||||
|
'last_page' => $lastPage, |
||||||
|
], |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,163 @@ |
|||||||
|
<?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 `context` tag (empty if absent). */ |
||||||
|
public function getContextText(): string |
||||||
|
{ |
||||||
|
return HighlightEventTags::contextFromTags($this->tags); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Card body HTML (home aside, line-clamp): `context` = full quote, `content` = highlighted part. |
||||||
|
* If there is no `context` (or it is empty), the passage is the same as `content`. The passage |
||||||
|
* is aligned so the clamped block starts at the highlight, not with long unmarked lead-in text. |
||||||
|
*/ |
||||||
|
public function getBodyHtml(): string |
||||||
|
{ |
||||||
|
$c = (string) $this->getContent(); |
||||||
|
|
||||||
|
return HighlightEventTags::buildHighlightedBodyHtmlForNarrowList( |
||||||
|
HighlightEventTags::fullPassageForHighlightDisplay($c, $this->tags), |
||||||
|
$c, |
||||||
|
0 |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,120 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Nostr; |
||||||
|
|
||||||
|
use swentel\nostr\Event\Event; |
||||||
|
use swentel\nostr\Nip19\Nip19Helper; |
||||||
|
|
||||||
|
/** |
||||||
|
* NIP-19 encode/decode using swentel/nostr-php, output-shaped for code that previously used nostriphant\NIP19\Bech32 |
||||||
|
* (objects with {@see $type} and {@see $data} for decoded entities). |
||||||
|
*/ |
||||||
|
final class Nip19Codec |
||||||
|
{ |
||||||
|
private Nip19Helper $nip19Helper; |
||||||
|
|
||||||
|
public function __construct(?Nip19Helper $nip19Helper = null) |
||||||
|
{ |
||||||
|
$this->nip19Helper = $nip19Helper ?? new Nip19Helper(); |
||||||
|
} |
||||||
|
|
||||||
|
public function decode(string $bech32): object |
||||||
|
{ |
||||||
|
$pos = strrpos($bech32, '1'); |
||||||
|
if (false === $pos || $pos < 1) { |
||||||
|
throw new \InvalidArgumentException('Invalid bech32 string'); |
||||||
|
} |
||||||
|
$hrp = substr($bech32, 0, $pos); |
||||||
|
$raw = $this->nip19Helper->decode($bech32); |
||||||
|
|
||||||
|
$out = new \stdClass(); |
||||||
|
|
||||||
|
if ($hrp === 'npub' || $hrp === 'nsec') { |
||||||
|
if (!\is_array($raw) || !isset($raw[1]) || !\is_array($raw[1])) { |
||||||
|
throw new \RuntimeException('Unexpected npub/nsec decode shape'); |
||||||
|
} |
||||||
|
$out->type = $hrp; |
||||||
|
$d = new \stdClass(); |
||||||
|
$hex = ''; |
||||||
|
foreach ($raw[1] as $byte) { |
||||||
|
$hex .= str_pad(\dechex($byte & 0xff), 2, '0', STR_PAD_LEFT); |
||||||
|
} |
||||||
|
$d->data = $hex; |
||||||
|
$out->data = $d; |
||||||
|
|
||||||
|
return $out; |
||||||
|
} |
||||||
|
|
||||||
|
if ($hrp === 'note') { |
||||||
|
if (!\is_array($raw) || !isset($raw['event_id']) || !\is_string($raw['event_id'])) { |
||||||
|
throw new \RuntimeException('Unexpected note decode shape'); |
||||||
|
} |
||||||
|
$out->type = 'note'; |
||||||
|
$d = new \stdClass(); |
||||||
|
$d->data = $raw['event_id']; |
||||||
|
$d->relays = []; |
||||||
|
$out->data = $d; |
||||||
|
|
||||||
|
return $out; |
||||||
|
} |
||||||
|
|
||||||
|
if (!\is_array($raw)) { |
||||||
|
throw new \RuntimeException('Unexpected NIP-19 decode shape'); |
||||||
|
} |
||||||
|
|
||||||
|
$out->type = $hrp; |
||||||
|
$d = new \stdClass(); |
||||||
|
if ($hrp === 'nprofile') { |
||||||
|
$d->pubkey = $raw['pubkey'] ?? ''; |
||||||
|
$d->relays = isset($raw['relays']) && \is_array($raw['relays']) ? $raw['relays'] : []; |
||||||
|
} elseif ($hrp === 'nevent') { |
||||||
|
$d->id = (string) ($raw['event_id'] ?? ''); |
||||||
|
$d->relays = isset($raw['relays']) && \is_array($raw['relays']) ? $raw['relays'] : []; |
||||||
|
$d->author = \array_key_exists('author', $raw) ? (string) $raw['author'] : null; |
||||||
|
if ($d->author === '') { |
||||||
|
$d->author = null; |
||||||
|
} |
||||||
|
$d->pubkey = $d->author; |
||||||
|
$d->kind = \array_key_exists('kind', $raw) && $raw['kind'] !== null && $raw['kind'] !== '' ? (int) $raw['kind'] : null; |
||||||
|
} elseif ($hrp === 'naddr') { |
||||||
|
$d->identifier = (string) ($raw['identifier'] ?? ''); |
||||||
|
$d->pubkey = (string) ($raw['author'] ?? ''); |
||||||
|
$d->relays = isset($raw['relays']) && \is_array($raw['relays']) ? $raw['relays'] : []; |
||||||
|
$d->kind = \array_key_exists('kind', $raw) && $raw['kind'] !== null && $raw['kind'] !== '' ? (int) $raw['kind'] : 0; |
||||||
|
} else { |
||||||
|
throw new \InvalidArgumentException('Unsupported NIP-19 prefix: '.$hrp); |
||||||
|
} |
||||||
|
$out->data = $d; |
||||||
|
|
||||||
|
return $out; |
||||||
|
} |
||||||
|
|
||||||
|
public function encodeNevent(string $eventIdHex, array $relays, string $authorHex, int $kind): string |
||||||
|
{ |
||||||
|
$e = new Event(); |
||||||
|
$e->setId(strtolower($eventIdHex)); |
||||||
|
$e->setPublicKey(strtolower($authorHex)); |
||||||
|
$e->setKind($kind); |
||||||
|
|
||||||
|
return $this->nip19Helper->encodeEvent($e, $relays, $authorHex, $kind); |
||||||
|
} |
||||||
|
|
||||||
|
public function encodeNaddr(int $kind, string $pubkeyHex, string $dTag, array $relays = []): string |
||||||
|
{ |
||||||
|
$pk = strtolower($pubkeyHex); |
||||||
|
if (64 !== \strlen($pk) || !ctype_xdigit($pk)) { |
||||||
|
throw new \InvalidArgumentException('Invalid pubkey hex for naddr.'); |
||||||
|
} |
||||||
|
if ($dTag === '') { |
||||||
|
throw new \InvalidArgumentException('d tag required for naddr'); |
||||||
|
} |
||||||
|
$e = new Event(); |
||||||
|
$e->setPublicKey($pk); |
||||||
|
$e->setKind($kind); |
||||||
|
$e->setId(str_repeat('0', 64)); |
||||||
|
|
||||||
|
return $this->nip19Helper->encodeAddr($e, $dTag, $kind, $pk, $relays); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,88 @@ |
|||||||
|
<?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; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Returns highlights for this NIP-33 address (kind + pubkey + slug), not only the current |
||||||
|
* {@see Article} row id — `article` can have multiple DB rows for the same slug (revisions). |
||||||
|
* |
||||||
|
* @return list<ArticleHighlight> |
||||||
|
*/ |
||||||
|
public function findByArticle(Article $article): array |
||||||
|
{ |
||||||
|
$id = $article->getId(); |
||||||
|
if (null === $id || (int) $id < 1) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
$pubkey = (string) $article->getPubkey(); |
||||||
|
if ('' === $pubkey) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
$slug = trim((string) $article->getSlug()); |
||||||
|
if ('' === $slug) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
$qb = $this->createQueryBuilder('h') |
||||||
|
->innerJoin('h.article', 'a') |
||||||
|
// Hex pubkeys are case-insensitive; utf8mb4_bin would otherwise miss rows. |
||||||
|
->where('LOWER(a.pubkey) = LOWER(:pubkey)') |
||||||
|
->andWhere('a.slug = :slug') |
||||||
|
->setParameter('pubkey', $pubkey) |
||||||
|
->setParameter('slug', $slug) |
||||||
|
->orderBy('h.eventCreatedAt', 'DESC'); |
||||||
|
|
||||||
|
// Do not filter on `a.kind`: replaceable long-form can leave several `article` rows per slug |
||||||
|
// with different kind (or NULL vs 30023). Highlights are still tied to the same NIP-33 |
||||||
|
// address; filtering by the *current* row's kind dropped rows synced to an older revision. |
||||||
|
|
||||||
|
/** @var list<ArticleHighlight> $out */ |
||||||
|
$out = $qb->getQuery()->getResult(); |
||||||
|
|
||||||
|
return $out; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,712 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Service; |
||||||
|
|
||||||
|
use App\Entity\ArticleHighlight; |
||||||
|
use App\Util\HighlightEventTags; |
||||||
|
use DOMDocument; |
||||||
|
use DOMElement; |
||||||
|
use DOMText; |
||||||
|
use DOMXPath; |
||||||
|
/** |
||||||
|
* Injects kind-9802 highlight marks into the rendered article body by searching the visible text |
||||||
|
* in NIP-84 order: event `content` (highlighted span) first, then the `context` tag when set, then |
||||||
|
* the full passage ({@see HighlightEventTags::fullPassageForHighlightDisplay}, same as `content` |
||||||
|
* when `context` is missing), then `textquoteselector`. The first string that matches the body wins. |
||||||
|
* Matches across inline elements (e.g. em, strong) by concatenating text in document order. Text |
||||||
|
* inside a prior `mark.user-highlight__marker` is still considered so a narrower 9802 can |
||||||
|
* be nested and receive its own fragment id (deep link from the landing aside). |
||||||
|
* If a literal match fails, compares a normalized form (NBSP→space, strip U+00AD / ZW, line breaks, |
||||||
|
* etc.) via {@see HighlightEventTags::stringForSearch}, then maps the match back to the original |
||||||
|
* HTML text (for e‑book style soft hyphens in 9802 content). CommonMark footnote callouts |
||||||
|
* (League CommonMark `sup#fnref…`) are ignored for matching so “realm 1 always” in the DOM does not |
||||||
|
* block a NIP-84 passage that says “realm always”. |
||||||
|
*/ |
||||||
|
final class ArticleBodyHighlightInjector |
||||||
|
{ |
||||||
|
private const ROOT_ID = '_article_hl'; |
||||||
|
|
||||||
|
private DOMDocument $dom; |
||||||
|
|
||||||
|
private ?DOMElement $root = null; |
||||||
|
|
||||||
|
public function __construct( |
||||||
|
private readonly HighlightAuthorMetadataProvider $highlightAuthorMetadata, |
||||||
|
private readonly NostrKeyHelper $nostrKeyHelper, |
||||||
|
) { |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @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 = []; |
||||||
|
$groups = $this->groupHighlightsForInjection($sorted); |
||||||
|
foreach ($groups as $group) { |
||||||
|
if ($group === []) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$added = $this->tryInjectHighlightGroup($this->root, $group); |
||||||
|
foreach ($added as $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(); |
||||||
|
} |
||||||
|
$this->root = $this->resolveRootWrapperElement(); |
||||||
|
if (null === $this->root) { |
||||||
|
// Some libxml/fragment combinations drop the root with HTML_NOIMPLIED; parse a plain wrapper |
||||||
|
$this->dom = new DOMDocument('1.0', 'UTF-8'); |
||||||
|
$prevInner = libxml_use_internal_errors(true); |
||||||
|
try { |
||||||
|
$this->dom->loadHTML( |
||||||
|
'<?xml encoding="UTF-8"?>'.'<div id="'.self::ROOT_ID.'">'.$html.'</div>',
|
||||||
|
\LIBXML_HTML_NODEFDTD |
||||||
|
); |
||||||
|
$this->root = $this->resolveRootWrapperElement(); |
||||||
|
} finally { |
||||||
|
libxml_use_internal_errors($prevInner); |
||||||
|
libxml_clear_errors(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private function resolveRootWrapperElement(): ?DOMElement |
||||||
|
{ |
||||||
|
$xp = new DOMXPath($this->dom); |
||||||
|
$nodes = $xp->query('//div[@id="'.self::ROOT_ID.'"]'); |
||||||
|
if (false !== $nodes && $nodes->length > 0) { |
||||||
|
$first = $nodes->item(0); |
||||||
|
|
||||||
|
return $first instanceof DOMElement ? $first : null; |
||||||
|
} |
||||||
|
$de = $this->dom->documentElement; |
||||||
|
if ($de instanceof DOMElement && $de->getAttribute('id') === self::ROOT_ID) { |
||||||
|
return $de; |
||||||
|
} |
||||||
|
$d = $this->findFirstDivById(self::ROOT_ID); |
||||||
|
if (null !== $d) { |
||||||
|
return $d; |
||||||
|
} |
||||||
|
$el = $this->findElementByIdFallback(self::ROOT_ID); |
||||||
|
|
||||||
|
return $el instanceof DOMElement ? $el : null; |
||||||
|
} |
||||||
|
|
||||||
|
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; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return 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; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param list<ArticleHighlight> $group same highlight text; oldest first |
||||||
|
* |
||||||
|
* @return list<string> event ids that were applied |
||||||
|
*/ |
||||||
|
private function tryInjectHighlightGroup(DOMElement $root, array $group): array |
||||||
|
{ |
||||||
|
if ($group === []) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
$first = $group[0]; |
||||||
|
$eid = \strtolower($first->getEventId()); |
||||||
|
if (64 !== \strlen($eid) || !ctype_xdigit($eid)) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
$outEids = []; |
||||||
|
foreach ($group as $h) { |
||||||
|
$id = \strtolower($h->getEventId()); |
||||||
|
if (64 === \strlen($id) && ctype_xdigit($id)) { |
||||||
|
$outEids[] = $id; |
||||||
|
} |
||||||
|
} |
||||||
|
if ($outEids === []) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
$authorJson = $this->buildHighlightAuthorsJson($group); |
||||||
|
$bases = $this->injectionNeedleBasesInPriority($first); |
||||||
|
if ($bases === []) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
foreach ($bases as $base) { |
||||||
|
foreach ($this->needleSearchVariants($base) as $needle) { |
||||||
|
if ($needle === '') { |
||||||
|
continue; |
||||||
|
} |
||||||
|
if ($this->tryWrapInDocument($root, $needle, $eid, $authorJson)) { |
||||||
|
$this->addFragmentIdAliasesForHighlightGroup($eid, $outEids); |
||||||
|
|
||||||
|
return $outEids; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* One <mark> per passage group, with id highlight-{oldest eid}. The landing aside links each |
||||||
|
* 9802 by that row's event id, so we add zero-footprint #highlight-{id} spans for every other |
||||||
|
* event in the same group (same place in the text as the mark). |
||||||
|
* |
||||||
|
* @param list<string> $outEids lowercase 64-hex, includes $canonicalEid; first is the oldest |
||||||
|
*/ |
||||||
|
private function addFragmentIdAliasesForHighlightGroup(string $canonicalEid, array $outEids): void |
||||||
|
{ |
||||||
|
if (\count($outEids) < 2) { |
||||||
|
return; |
||||||
|
} |
||||||
|
$mark = $this->getHighlightMarkElementById('highlight-'.$canonicalEid); |
||||||
|
if (null === $mark) { |
||||||
|
return; |
||||||
|
} |
||||||
|
$parent = $mark->parentNode; |
||||||
|
if (null === $parent) { |
||||||
|
return; |
||||||
|
} |
||||||
|
foreach ($outEids as $other) { |
||||||
|
if ($other === $canonicalEid) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
if (64 !== \strlen($other) || !ctype_xdigit($other)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
if ($this->getHighlightMarkElementById('highlight-'.$other) !== null) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$span = $this->dom->createElement('span'); |
||||||
|
if (false === $span) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$span->setAttribute('id', 'highlight-'.$other); |
||||||
|
$span->setAttribute('class', 'user-highlight__fragment-target'); |
||||||
|
$span->setAttribute('aria-hidden', 'true'); |
||||||
|
$span->appendChild($this->dom->createTextNode("\u{200B}")); |
||||||
|
$parent->insertBefore($span, $mark); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private function getHighlightMarkElementById(string $id): ?DOMElement |
||||||
|
{ |
||||||
|
if (null === $this->root || $id === '') { |
||||||
|
return null; |
||||||
|
} |
||||||
|
$el = $this->dom->getElementById($id); |
||||||
|
if ($el instanceof DOMElement) { |
||||||
|
return $el; |
||||||
|
} |
||||||
|
if (! \preg_match('/^highlight-[a-f0-9]{64}$/D', $id)) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
$xp = new DOMXPath($this->dom); |
||||||
|
$q = '//*[@id="'.(string) $id.'"]'; |
||||||
|
$nodes = $xp->query($q, $this->root); |
||||||
|
if (false === $nodes || 0 === $nodes->length) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
$n = $nodes->item(0); |
||||||
|
|
||||||
|
return $n instanceof DOMElement ? $n : null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param list<ArticleHighlight> $sorted by created_at asc |
||||||
|
* |
||||||
|
* @return list<list<ArticleHighlight>> |
||||||
|
*/ |
||||||
|
private function groupHighlightsForInjection(array $sorted): array |
||||||
|
{ |
||||||
|
$buckets = []; |
||||||
|
foreach ($sorted as $h) { |
||||||
|
$primary = $this->primaryNeedleForGrouping($h); |
||||||
|
if ($primary === '') { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$key = HighlightEventTags::stringForSearch($primary); |
||||||
|
if ($key === '') { |
||||||
|
$key = 'x'.\md5($primary); |
||||||
|
} |
||||||
|
if (!isset($buckets[$key])) { |
||||||
|
$buckets[$key] = []; |
||||||
|
} |
||||||
|
$buckets[$key][] = $h; |
||||||
|
} |
||||||
|
$groups = \array_values($buckets); |
||||||
|
\usort( |
||||||
|
$groups, |
||||||
|
static function (array $a, array $b): int { |
||||||
|
$ta = $a[0] instanceof ArticleHighlight ? $a[0]->getEventCreatedAt() : 0; |
||||||
|
$tb = $b[0] instanceof ArticleHighlight ? $b[0]->getEventCreatedAt() : 0; |
||||||
|
|
||||||
|
return $ta <=> $tb; |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
return $groups; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* NIP-84: same highlighted passage → one mark, dedupe authors by npub, profile from cache. |
||||||
|
* |
||||||
|
* @param list<ArticleHighlight> $group |
||||||
|
*/ |
||||||
|
private function buildHighlightAuthorsJson(array $group): string |
||||||
|
{ |
||||||
|
$byNpub = []; |
||||||
|
foreach ($group as $h) { |
||||||
|
$eidH = $h->getEventId(); |
||||||
|
if (64 !== \strlen($eidH) || !ctype_xdigit($eidH)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$pk = $h->getAuthorPubkey(); |
||||||
|
if (64 !== \strlen($pk) || !ctype_xdigit($pk)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
try { |
||||||
|
$npub = $this->nostrKeyHelper->convertPublicKeyToBech32($pk); |
||||||
|
} catch (\Throwable) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
if (isset($byNpub[$npub])) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$name = ''; |
||||||
|
$pic = ''; |
||||||
|
try { |
||||||
|
$meta = $this->highlightAuthorMetadata->getMetadata($npub); |
||||||
|
if (isset($meta->display_name) && \is_string($meta->display_name) && $meta->display_name !== '') { |
||||||
|
$name = $meta->display_name; |
||||||
|
} elseif (isset($meta->name) && \is_string($meta->name) && $meta->name !== '') { |
||||||
|
$name = $meta->name; |
||||||
|
} |
||||||
|
if (isset($meta->picture) && \is_string($meta->picture) && $meta->picture !== '') { |
||||||
|
$pic = $meta->picture; |
||||||
|
} elseif (isset($meta->image) && \is_string($meta->image) && $meta->image !== '') { |
||||||
|
$pic = $meta->image; |
||||||
|
} |
||||||
|
} catch (\Throwable) { |
||||||
|
} |
||||||
|
$byNpub[$npub] = [ |
||||||
|
'e' => \strtolower($eidH), |
||||||
|
'n' => $npub, |
||||||
|
'a' => $name, |
||||||
|
'p' => $pic, |
||||||
|
]; |
||||||
|
} |
||||||
|
|
||||||
|
return \json_encode(\array_values($byNpub), \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Same priority as the card: event `content` (NIP-84 sub-span) first, then the `context` tag when |
||||||
|
* set, then {@see HighlightEventTags::fullPassageForHighlightDisplay} (so missing/empty `context` |
||||||
|
* is treated as “passage = `content`” before `textquoteselector`). Tries each in order until one |
||||||
|
* matches the rendered body. |
||||||
|
*/ |
||||||
|
private function primaryNeedleForGrouping(ArticleHighlight $h): string |
||||||
|
{ |
||||||
|
$b = $this->injectionNeedleBasesInPriority($h); |
||||||
|
|
||||||
|
return $b[0] ?? ''; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @return list<string> unique non-empty strings, highest priority first |
||||||
|
*/ |
||||||
|
private function injectionNeedleBasesInPriority(ArticleHighlight $h): array |
||||||
|
{ |
||||||
|
$rawContent = (string) $h->getContent(); |
||||||
|
$tags = $h->getTags(); |
||||||
|
$c = HighlightEventTags::trimNostrText($rawContent); |
||||||
|
$ctx = HighlightEventTags::trimNostrText(HighlightEventTags::contextFromTags($tags)); |
||||||
|
$fullPassage = HighlightEventTags::trimNostrText( |
||||||
|
HighlightEventTags::fullPassageForHighlightDisplay($rawContent, $tags) |
||||||
|
); |
||||||
|
$tq = HighlightEventTags::trimNostrText(HighlightEventTags::textquoteselectorPassageFromTags($tags)); |
||||||
|
$out = []; |
||||||
|
$seen = []; |
||||||
|
// NIP-84: `context` = full quote; `content` = highlighted span. Missing/empty `context` is |
||||||
|
// the same as “full passage = `content`” (entirely highlighted) — see fullPassageForHighlightDisplay. |
||||||
|
foreach ([$c, $ctx, $fullPassage, $tq] as $s) { |
||||||
|
if ($s === '' || isset($seen[$s])) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$seen[$s] = true; |
||||||
|
$out[] = $s; |
||||||
|
} |
||||||
|
|
||||||
|
return $out; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 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), |
||||||
|
]; |
||||||
|
$noLineBreaks = (string) \preg_replace('/\R/u', '', $base); |
||||||
|
if ($noLineBreaks !== $base && $noLineBreaks !== '') { |
||||||
|
$candidates[] = $noLineBreaks; |
||||||
|
} |
||||||
|
$nEnd = (string) \preg_replace('/[.!?…,;:]+$/u', '', $base); |
||||||
|
if ($nEnd !== $base && $nEnd !== '') { |
||||||
|
$candidates[] = $nEnd; |
||||||
|
} |
||||||
|
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" => "\x22", |
||||||
|
"\xE2\x80\x9D" => "\x22", |
||||||
|
"\xE2\x80\x93" => '-', |
||||||
|
"\xE2\x80\x94" => '-', |
||||||
|
]); |
||||||
|
} |
||||||
|
|
||||||
|
private function tryWrapInDocument(DOMElement $root, string $needle, string $eventId, string $authorJson = ''): bool |
||||||
|
{ |
||||||
|
$textNodes = $this->collectTextNodes($root); |
||||||
|
if ($textNodes === []) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
$cat = ''; |
||||||
|
/** @var list<array{0: DOMText, 1: int, 2: int}> $segments */ |
||||||
|
$segments = []; |
||||||
|
|
||||||
|
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'); |
||||||
|
$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 = HighlightEventTags::stringForSearch($cat); |
||||||
|
$needleS = HighlightEventTags::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] = HighlightEventTags::mapSearchStringRangeToOrigStringRange($cat, $pN, $nEnd); |
||||||
|
if ($pEnd <= $p) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
$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, |
||||||
|
$authorJson |
||||||
|
)) { |
||||||
|
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; |
||||||
|
if ('script' === $n |
||||||
|
|| 'style' === $n |
||||||
|
|| 'pre' === $n |
||||||
|
|| 'textarea' === $n |
||||||
|
|| 'code' === $n) { |
||||||
|
return true; |
||||||
|
} |
||||||
|
if ('div' === $n && $this->isFootnotesOrEndnotesElement($c)) { |
||||||
|
// End-of-article footnote list (League CommonMark): must not mix into the body search string |
||||||
|
// or after main content, which would desync “flat text” from NIP-84 passages. |
||||||
|
return true; |
||||||
|
} |
||||||
|
if ('sup' === $n && $this->isFootnoteCalloutElement($c)) { |
||||||
|
// Inline [^ref] callouts: skip the superscript so "realm" + "1" + " always" does not |
||||||
|
// break matching "realm always" from kind-9802 `content` (cards use raw Nostr, not the DOM). |
||||||
|
return true; |
||||||
|
} |
||||||
|
if ('mark' === $n) { |
||||||
|
$cl = (string) $c->getAttribute('class'); |
||||||
|
|
||||||
|
return ! \str_contains($cl, 'user-highlight__marker'); |
||||||
|
} |
||||||
|
|
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
private function isFootnoteCalloutElement(DOMElement $c): bool |
||||||
|
{ |
||||||
|
$id = (string) $c->getAttribute('id'); |
||||||
|
|
||||||
|
return $id !== '' && \str_starts_with($id, 'fnref'); |
||||||
|
} |
||||||
|
|
||||||
|
private function isFootnotesOrEndnotesElement(DOMElement $c): bool |
||||||
|
{ |
||||||
|
if (\str_contains((string) $c->getAttribute('class'), 'footnotes') |
||||||
|
|| $c->getAttribute('role') === 'doc-endnotes') { |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
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 (('div' === $n && $this->isFootnotesOrEndnotesElement($p)) |
||||||
|
|| ('sup' === $n && $this->isFootnoteCalloutElement($p))) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
if ('a' === $n && \str_contains((string) $p->getAttribute('class'), 'footnote-ref')) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
$p = $p->parentNode; |
||||||
|
} |
||||||
|
|
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
private function wrapTextSlice(DOMText $textNode, int $uOffset, int $uLength, string $eventId, bool $firstInReadingOrder, string $authorJson = ''): 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'); |
||||||
|
if ($firstInReadingOrder) { |
||||||
|
$mark->setAttribute('id', 'highlight-'.$eventId); |
||||||
|
} |
||||||
|
if ($authorJson !== '') { |
||||||
|
$mark->setAttribute('data-hl', $authorJson); |
||||||
|
} |
||||||
|
$mark->appendChild($this->dom->createTextNode($match)); |
||||||
|
$parent->insertBefore($mark, $ref); |
||||||
|
if ($after === '') { |
||||||
|
$parent->removeChild($ref); |
||||||
|
} else { |
||||||
|
$ref->data = $after; |
||||||
|
} |
||||||
|
|
||||||
|
return true; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,86 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Service; |
||||||
|
|
||||||
|
use App\Repository\FeaturedAuthorRepository; |
||||||
|
|
||||||
|
/** |
||||||
|
* NIP-05 / listed featured author rows (same shape as {@see \App\Controller\FeaturedAuthorsController}). |
||||||
|
* Sidebar: falls back to magazine index pubkeys when the `featured_author` list is still empty |
||||||
|
* (e.g. prewarm has not yet synced the table). |
||||||
|
*/ |
||||||
|
final class FeaturedAuthorListedRows |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
private readonly FeaturedAuthorRepository $featuredAuthorRepository, |
||||||
|
private readonly CacheService $cacheService, |
||||||
|
private readonly MagazineContentService $magazineContent, |
||||||
|
private readonly NostrKeyHelper $nostrKeyHelper, |
||||||
|
) { |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Rows for the left nav: NIP-05–listed authors first, otherwise authors from the magazine index store. |
||||||
|
* |
||||||
|
* @return list<array{npub: string, pubkey: string, display_name: string, picture: string, local_part: string}> |
||||||
|
*/ |
||||||
|
public function buildSidebarRows(int $limit = 12): array |
||||||
|
{ |
||||||
|
$fromDb = $this->buildListedByLocalPartPage($limit, 0); |
||||||
|
if ($fromDb !== []) { |
||||||
|
return $fromDb; |
||||||
|
} |
||||||
|
|
||||||
|
$authors = []; |
||||||
|
$hexes = $this->magazineContent->getAllDistinctCategoryAuthorPubkeyHexes(); |
||||||
|
foreach (\array_slice($hexes, 0, $limit) as $hex) { |
||||||
|
try { |
||||||
|
$npub = $this->nostrKeyHelper->convertPublicKeyToBech32($hex); |
||||||
|
} catch (\Throwable) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$authors[] = $this->rowFromNpub($npub, $hex, ''); |
||||||
|
} |
||||||
|
|
||||||
|
return $authors; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @return list<array{npub: string, pubkey: string, display_name: string, picture: string, local_part: string}> |
||||||
|
*/ |
||||||
|
public function buildListedByLocalPartPage(int $limit, int $offset = 0): array |
||||||
|
{ |
||||||
|
$authors = []; |
||||||
|
foreach ($this->featuredAuthorRepository->findListedOrderByLocalPartPaginated($limit, $offset) as $fa) { |
||||||
|
try { |
||||||
|
$npub = $this->nostrKeyHelper->convertPublicKeyToBech32($fa->getPubkeyHex()); |
||||||
|
} catch (\Throwable) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$authors[] = $this->rowFromNpub($npub, $fa->getPubkeyHex(), $fa->getLocalPart()); |
||||||
|
} |
||||||
|
|
||||||
|
return $authors; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @return array{npub: string, pubkey: string, display_name: string, picture: string, local_part: string} |
||||||
|
*/ |
||||||
|
private function rowFromNpub(string $npub, string $pubkeyHex, string $localPart): array |
||||||
|
{ |
||||||
|
$bundle = $this->cacheService->getMetadataBundle($npub); |
||||||
|
$author = $bundle['content']; |
||||||
|
$displayName = trim((string) ($author->display_name ?? $author->name ?? '')); |
||||||
|
$picture = trim((string) ($author->picture ?? '')); |
||||||
|
|
||||||
|
return [ |
||||||
|
'npub' => $npub, |
||||||
|
'pubkey' => strtolower($pubkeyHex), |
||||||
|
'display_name' => $displayName, |
||||||
|
'picture' => $picture, |
||||||
|
'local_part' => $localPart, |
||||||
|
]; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,13 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Service; |
||||||
|
|
||||||
|
/** |
||||||
|
* Subset of {@see CacheService} for {@see ArticleBodyHighlightInjector} (mockable; readonly services cannot be doubled in PHPUnit). |
||||||
|
*/ |
||||||
|
interface HighlightAuthorMetadataProvider |
||||||
|
{ |
||||||
|
public function getMetadata(string $npub): \stdClass; |
||||||
|
} |
||||||
@ -0,0 +1,106 @@ |
|||||||
|
<?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 = []; |
||||||
|
} |
||||||
|
$tags = HighlightEventTags::normalizeTagsForStorage($tags); |
||||||
|
$content = (string) ($ev->content ?? ''); |
||||||
|
$ca = (int) ($ev->created_at ?? 0); |
||||||
|
if ($ca < 0) { |
||||||
|
$ca = 0; |
||||||
|
} |
||||||
|
$excerpt = HighlightEventTags::excerptForFeed($content, $tags); |
||||||
|
if ($excerpt === '') { |
||||||
|
$t = HighlightEventTags::trimNostrText($content); |
||||||
|
$excerpt = $t !== '' ? \mb_substr($t, 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,169 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Service; |
||||||
|
|
||||||
|
use App\Enum\KindsEnum; |
||||||
|
use swentel\nostr\Filter\Filter; |
||||||
|
|
||||||
|
/** |
||||||
|
* REQ {@link Filter}s and tag-matching rules for long-form article discussion (NIP-22 kind 1111, legacy kind 1, quotes). |
||||||
|
* Used by {@see NostrClient::getArticleDiscussion()}. |
||||||
|
*/ |
||||||
|
final class NostrArticleDiscussionSupport |
||||||
|
{ |
||||||
|
/** |
||||||
|
* @return array<int, Filter> |
||||||
|
*/ |
||||||
|
public function createArticleDiscussionFilters(string $coordinate, ?string $rootEventHexId): array |
||||||
|
{ |
||||||
|
$limThread = 100; |
||||||
|
$limQuote = 80; |
||||||
|
|
||||||
|
$filters = []; |
||||||
|
|
||||||
|
$k1111 = KindsEnum::COMMENTS->value; |
||||||
|
$f = new Filter(); |
||||||
|
$f->setKinds([$k1111]); |
||||||
|
$f->setTag('#A', [$coordinate]); |
||||||
|
$f->setLimit($limThread); |
||||||
|
$filters[] = $f; |
||||||
|
$f = new Filter(); |
||||||
|
$f->setKinds([$k1111]); |
||||||
|
$f->setTag('#a', [$coordinate]); |
||||||
|
$f->setLimit($limThread); |
||||||
|
$filters[] = $f; |
||||||
|
|
||||||
|
$k1 = KindsEnum::TEXT_NOTE->value; |
||||||
|
$f = new Filter(); |
||||||
|
$f->setKinds([$k1]); |
||||||
|
$f->setTag('#A', [$coordinate]); |
||||||
|
$f->setLimit($limThread); |
||||||
|
$filters[] = $f; |
||||||
|
$f = new Filter(); |
||||||
|
$f->setKinds([$k1]); |
||||||
|
$f->setTag('#a', [$coordinate]); |
||||||
|
$f->setLimit($limThread); |
||||||
|
$filters[] = $f; |
||||||
|
|
||||||
|
if ($rootEventHexId !== null && $rootEventHexId !== '') { |
||||||
|
$f = new Filter(); |
||||||
|
$f->setKinds([$k1]); |
||||||
|
$f->setTag('#e', [$rootEventHexId]); |
||||||
|
$f->setLimit($limThread); |
||||||
|
$filters[] = $f; |
||||||
|
} |
||||||
|
|
||||||
|
$qKinds = [ |
||||||
|
KindsEnum::TEXT_NOTE->value, |
||||||
|
KindsEnum::REPOST->value, |
||||||
|
KindsEnum::GENERIC_REPOST->value, |
||||||
|
KindsEnum::COMMENTS->value, |
||||||
|
]; |
||||||
|
$qVals = [$coordinate]; |
||||||
|
if ($rootEventHexId !== null && $rootEventHexId !== '') { |
||||||
|
$qVals[] = $rootEventHexId; |
||||||
|
} |
||||||
|
$f = new Filter(); |
||||||
|
$f->setKinds($qKinds); |
||||||
|
$f->setTag('#q', $qVals); |
||||||
|
$f->setLimit($limQuote); |
||||||
|
$filters[] = $f; |
||||||
|
|
||||||
|
$f = new Filter(); |
||||||
|
$f->setKinds([KindsEnum::GENERIC_REPOST->value]); |
||||||
|
$f->setTag('#a', [$coordinate]); |
||||||
|
$f->setLimit(50); |
||||||
|
$filters[] = $f; |
||||||
|
|
||||||
|
return $filters; |
||||||
|
} |
||||||
|
|
||||||
|
public function eventIsNip22ArticleThreadReply(object $event, string $coordinate): bool |
||||||
|
{ |
||||||
|
if ((int) ($event->kind ?? 0) !== KindsEnum::COMMENTS->value) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
foreach ($event->tags ?? [] as $tag) { |
||||||
|
if (!\is_array($tag) || \count($tag) < 2) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$name = (string) ($tag[0] ?? ''); |
||||||
|
if (($name === 'a' || $name === 'A') && (string) ($tag[1] ?? '') === $coordinate) { |
||||||
|
return true; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
public function eventIsLegacyThreadReply(object $event, string $coordinate, ?string $rootEventHexId): bool |
||||||
|
{ |
||||||
|
if ((int) ($event->kind ?? 0) !== KindsEnum::TEXT_NOTE->value) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
foreach ($event->tags ?? [] as $tag) { |
||||||
|
if (!\is_array($tag) || \count($tag) < 2) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$name = (string) ($tag[0] ?? ''); |
||||||
|
$val = (string) ($tag[1] ?? ''); |
||||||
|
if (($name === 'a' || $name === 'A') && $val === $coordinate) { |
||||||
|
return true; |
||||||
|
} |
||||||
|
if ($rootEventHexId !== null && $rootEventHexId !== '' && $name === 'e' && $val === $rootEventHexId) { |
||||||
|
return true; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
public function eventIsArticleQuote(object $event, string $coordinate, ?string $rootEventHexId): bool |
||||||
|
{ |
||||||
|
$kind = (int) ($event->kind ?? 0); |
||||||
|
if ($kind === KindsEnum::HIGHLIGHTS->value) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
if ($kind === KindsEnum::COMMENTS->value) { |
||||||
|
foreach ($event->tags ?? [] as $tag) { |
||||||
|
if (!\is_array($tag) || \count($tag) < 2) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
if (($tag[0] ?? '') === 'q') { |
||||||
|
$val = (string) ($tag[1] ?? ''); |
||||||
|
if ($val === $coordinate || ($rootEventHexId !== null && $val === $rootEventHexId)) { |
||||||
|
return true; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return false; |
||||||
|
} |
||||||
|
foreach ($event->tags ?? [] as $tag) { |
||||||
|
if (!\is_array($tag) || \count($tag) < 2) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$name = (string) ($tag[0] ?? ''); |
||||||
|
$val = (string) ($tag[1] ?? ''); |
||||||
|
if ($name === 'q') { |
||||||
|
if ($val === $coordinate || ($rootEventHexId !== null && $val === $rootEventHexId)) { |
||||||
|
return true; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
if ($kind === KindsEnum::GENERIC_REPOST->value) { |
||||||
|
foreach ($event->tags ?? [] as $tag) { |
||||||
|
if (!\is_array($tag) || \count($tag) < 2) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
if (($tag[0] ?? '') === 'a' && (string) ($tag[1] ?? '') === $coordinate) { |
||||||
|
return true; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,89 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Service; |
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
use Symfony\Contracts\Cache\CacheInterface; |
||||||
|
use Symfony\Contracts\Cache\ItemInterface; |
||||||
|
|
||||||
|
/** |
||||||
|
* Kind-10002 (NIP-65) wss:// lists per author hex pubkey, cached. Fetches wire data via |
||||||
|
* {@see NostrClient::getNpubRelays()}; {@see NostrClient} is injected lazily to avoid a container cycle. |
||||||
|
* |
||||||
|
* Intentionally not `final` so Symfony can generate a lazy proxy for this service. |
||||||
|
*/ |
||||||
|
class NostrAuthorRelayCache |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
private readonly CacheInterface $relayQueryCache, |
||||||
|
private readonly LoggerInterface $logger, |
||||||
|
private readonly NostrRelayListFactory $relayListFactory, |
||||||
|
private readonly NostrClient $nostrClient, |
||||||
|
) { |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Full NIP-65 wss:// list for a hex pubkey, cached. Prefer {@see getTopReputableRelaysForAuthor} when |
||||||
|
* only a few relays are needed. |
||||||
|
* |
||||||
|
* @return list<string> |
||||||
|
*/ |
||||||
|
public function getAuthorNip65RelaysList(string $pubkey): array |
||||||
|
{ |
||||||
|
$cacheKey = 'nostr_kind10002_relays_v1_'.hash('sha256', $pubkey); |
||||||
|
|
||||||
|
return $this->relayQueryCache->get($cacheKey, function (ItemInterface $item) use ($pubkey): array { |
||||||
|
$item->expiresAfter(3600); |
||||||
|
try { |
||||||
|
$authorRelays = $this->nostrClient->getNpubRelays($pubkey); |
||||||
|
} catch (\Exception $e) { |
||||||
|
$this->logger->error('Error getting author NIP-65 relay list', [ |
||||||
|
'pubkey' => $pubkey, |
||||||
|
'error' => $e->getMessage(), |
||||||
|
]); |
||||||
|
$authorRelays = []; |
||||||
|
} |
||||||
|
$authorRelays = array_values(array_filter( |
||||||
|
$authorRelays, |
||||||
|
static function (string $relay): bool { |
||||||
|
return str_starts_with($relay, 'wss:') |
||||||
|
&& !str_contains($relay, 'localhost'); |
||||||
|
} |
||||||
|
)); |
||||||
|
if ($authorRelays === []) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
$seen = []; |
||||||
|
$out = []; |
||||||
|
foreach ($authorRelays as $u) { |
||||||
|
if (isset($seen[$u])) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$seen[$u] = true; |
||||||
|
$out[] = $u; |
||||||
|
} |
||||||
|
|
||||||
|
return $out; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* A short prefix of the author NIP-65 list (or default site relay) for queries that do not need every home relay. |
||||||
|
* |
||||||
|
* @return list<string> |
||||||
|
*/ |
||||||
|
public function getTopReputableRelaysForAuthor(string $pubkey, int $limit = 3): array |
||||||
|
{ |
||||||
|
$all = $this->getAuthorNip65RelaysList($pubkey); |
||||||
|
if ($all === []) { |
||||||
|
return [$this->relayListFactory->getDefaultRelayUrl()]; |
||||||
|
} |
||||||
|
if ($limit < 1) { |
||||||
|
$limit = 1; |
||||||
|
} |
||||||
|
|
||||||
|
return \array_slice($all, 0, $limit); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,41 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Service; |
||||||
|
|
||||||
|
use swentel\nostr\Key\Key; |
||||||
|
|
||||||
|
/** |
||||||
|
* Shared {@link Key} wrapper for npub/nsec/hex conversions. Prefer injecting this service instead of |
||||||
|
* instantiating {@see Key} in controllers, commands, and services. |
||||||
|
*/ |
||||||
|
final readonly class NostrKeyHelper |
||||||
|
{ |
||||||
|
private Key $key; |
||||||
|
|
||||||
|
public function __construct() |
||||||
|
{ |
||||||
|
$this->key = new Key(); |
||||||
|
} |
||||||
|
|
||||||
|
public function convertToHex(string $key): string |
||||||
|
{ |
||||||
|
return $this->key->convertToHex($key); |
||||||
|
} |
||||||
|
|
||||||
|
public function convertPublicKeyToBech32(string $key): string |
||||||
|
{ |
||||||
|
return $this->key->convertPublicKeyToBech32($key); |
||||||
|
} |
||||||
|
|
||||||
|
public function convertPrivateKeyToBech32(string $key): string |
||||||
|
{ |
||||||
|
return $this->key->convertPrivateKeyToBech32($key); |
||||||
|
} |
||||||
|
|
||||||
|
public function generatePrivateKey(): string |
||||||
|
{ |
||||||
|
return $this->key->generatePrivateKey(); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,54 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Service; |
||||||
|
|
||||||
|
use App\Enum\KindsEnum; |
||||||
|
|
||||||
|
/** |
||||||
|
* NIP-09 kind-5: keep only deletion events that target kinds persisted in MySQL (profile, relay list, payto, |
||||||
|
* long-form, magazine). Skips thread/reply deletions to reduce relay payload. |
||||||
|
*/ |
||||||
|
final class NostrKind5DeletionFilter |
||||||
|
{ |
||||||
|
public function isRelevantToStoredDbData(object $ev): bool |
||||||
|
{ |
||||||
|
$kinds = $this->storedKindValues(); |
||||||
|
foreach ($ev->tags ?? [] as $tag) { |
||||||
|
if (!\is_array($tag) && !\is_object($tag)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$r = \is_object($tag) ? array_values((array) $tag) : $tag; |
||||||
|
if (!isset($r[0], $r[1])) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
if ((string) $r[0] === 'k' && \in_array((int) $r[1], $kinds, true)) { |
||||||
|
return true; |
||||||
|
} |
||||||
|
if ((string) $r[0] === 'a') { |
||||||
|
$parts = explode(':', (string) $r[1], 3); |
||||||
|
if (\in_array((int) $parts[0], $kinds, true)) { |
||||||
|
return true; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @return list<int> |
||||||
|
*/ |
||||||
|
private function storedKindValues(): array |
||||||
|
{ |
||||||
|
return [ |
||||||
|
KindsEnum::METADATA->value, |
||||||
|
KindsEnum::RELAY_LIST->value, |
||||||
|
KindsEnum::PAYMENT_TARGETS->value, |
||||||
|
KindsEnum::LONGFORM->value, |
||||||
|
KindsEnum::LONGFORM_DRAFT->value, |
||||||
|
KindsEnum::PUBLICATION_INDEX->value, |
||||||
|
]; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,116 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Service; |
||||||
|
|
||||||
|
use App\Entity\Article; |
||||||
|
use App\Enum\EventStatusEnum; |
||||||
|
use App\Enum\KindsEnum; |
||||||
|
use Doctrine\ORM\EntityManagerInterface; |
||||||
|
use Doctrine\Persistence\ManagerRegistry; |
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
|
||||||
|
/** |
||||||
|
* MySQL `article` row updates for NIP-23 / long-form ingest: find-by-(pubkey,slug), merge wire onto row, |
||||||
|
* persist. Orchestrated by {@see NostrClient::saveEachArticleToTheDatabase()}. |
||||||
|
*/ |
||||||
|
final class NostrLongformArticleStore |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
private readonly EntityManagerInterface $entityManager, |
||||||
|
private readonly ManagerRegistry $managerRegistry, |
||||||
|
private readonly LoggerInterface $logger, |
||||||
|
private readonly NostrWireEventMerge $wireMerge, |
||||||
|
) { |
||||||
|
} |
||||||
|
|
||||||
|
public function isEventIdAlreadyStored(string $eventId): bool |
||||||
|
{ |
||||||
|
if ($eventId === '') { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
return $this->entityManager->getRepository(Article::class)->findOneBy(['eventId' => $eventId]) !== null; |
||||||
|
} |
||||||
|
|
||||||
|
public function findLatestByAuthorAndSlug(string $pubkey, string $slug): ?Article |
||||||
|
{ |
||||||
|
$pubkey = strtolower($pubkey); |
||||||
|
/** @var ?Article $row */ |
||||||
|
$row = $this->entityManager->getRepository(Article::class)->createQueryBuilder('a') |
||||||
|
->where('LOWER(a.pubkey) = :pk') |
||||||
|
->andWhere('a.slug = :sl') |
||||||
|
->setParameter('pk', $pubkey) |
||||||
|
->setParameter('sl', $slug) |
||||||
|
->orderBy('a.createdAt', 'DESC') |
||||||
|
->setMaxResults(1) |
||||||
|
->getQuery() |
||||||
|
->getOneOrNullResult(); |
||||||
|
|
||||||
|
return $row; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Minimal Nostr event shape for {@see NostrWireEventMerge::wireEventSupersedes()} when `raw` is not a full wire object. |
||||||
|
*/ |
||||||
|
public function longFormWireStubFromArticle(Article $a): object |
||||||
|
{ |
||||||
|
$raw = $a->getRaw(); |
||||||
|
if (\is_object($raw) && isset($raw->id) && (isset($raw->created_at) || isset($raw->createdAt))) { |
||||||
|
return $raw; |
||||||
|
} |
||||||
|
$o = new \stdClass(); |
||||||
|
$o->id = (string) ($a->getEventId() ?? ''); |
||||||
|
$ca = $a->getCreatedAt(); |
||||||
|
$o->created_at = $ca !== null ? $ca->getTimestamp() : 0; |
||||||
|
$o->pubkey = (string) ($a->getPubkey() ?? ''); |
||||||
|
$k = $a->getKind(); |
||||||
|
|
||||||
|
$o->kind = $k !== null ? $k->value : KindsEnum::LONGFORM->value; |
||||||
|
|
||||||
|
return $o; |
||||||
|
} |
||||||
|
|
||||||
|
public function applySourceOntoTarget(Article $source, Article $target): void |
||||||
|
{ |
||||||
|
$target->setEventId((string) $source->getEventId()); |
||||||
|
$target->setContent($source->getContent()); |
||||||
|
$target->setTitle($source->getTitle()); |
||||||
|
$target->setSummary($source->getSummary()); |
||||||
|
$target->setImage($source->getImage()); |
||||||
|
if ($source->getCreatedAt() !== null) { |
||||||
|
$target->setCreatedAt($source->getCreatedAt()); |
||||||
|
} |
||||||
|
$target->setSig($source->getSig()); |
||||||
|
if ($source->getPublishedAt() !== null) { |
||||||
|
$target->setPublishedAt($source->getPublishedAt()); |
||||||
|
} |
||||||
|
$target->setTopics($source->getTopics()); |
||||||
|
if ($source->getKind() !== null) { |
||||||
|
$target->setKind($source->getKind()); |
||||||
|
} |
||||||
|
$es = $source->getEventStatus(); |
||||||
|
$target->setEventStatus($es ?? EventStatusEnum::PUBLISHED); |
||||||
|
$target->setRaw($source->getRaw()); |
||||||
|
} |
||||||
|
|
||||||
|
public function persistNew(Article $article, string $reason = 'unspecified'): void |
||||||
|
{ |
||||||
|
try { |
||||||
|
$this->logger->info('[longform_ingest] persistNewArticle', [ |
||||||
|
'reason' => $reason, |
||||||
|
'eventId' => $article->getEventId(), |
||||||
|
'slug' => $this->wireMerge->longformIngestShortSlug((string) ($article->getSlug() ?? '')), |
||||||
|
]); |
||||||
|
$this->entityManager->persist($article); |
||||||
|
$this->entityManager->flush(); |
||||||
|
} catch (\Exception $e) { |
||||||
|
$this->logger->error('[longform_ingest] persistNewArticle failed: '.$e->getMessage(), [ |
||||||
|
'reason' => $reason, |
||||||
|
'eventId' => $article->getEventId(), |
||||||
|
]); |
||||||
|
$this->managerRegistry->resetManager(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,36 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Service; |
||||||
|
|
||||||
|
/** |
||||||
|
* NIP-65 kind-10002: collect `r` tag values as relay URLs (wss, excluding localhost). Used for author |
||||||
|
* relay lists from wire and from {@see NostrClient::getNpubRelays()}. |
||||||
|
*/ |
||||||
|
final class NostrNip65RelayUrls |
||||||
|
{ |
||||||
|
/** |
||||||
|
* @return list<string> |
||||||
|
*/ |
||||||
|
public function wssListFromKind10002Wire(object $wire): array |
||||||
|
{ |
||||||
|
$relays = []; |
||||||
|
foreach ($wire->tags ?? [] as $tag) { |
||||||
|
if (!\is_array($tag) && !\is_object($tag)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$r = \is_object($tag) ? array_values((array) $tag) : $tag; |
||||||
|
if (!isset($r[0], $r[1])) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
if ((string) $r[0] === 'r') { |
||||||
|
$relays[] = (string) $r[1]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return array_values(array_filter(array_unique($relays), static function (string $relay) { |
||||||
|
return str_starts_with($relay, 'wss:') && !str_contains($relay, 'localhost'); |
||||||
|
})); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,230 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Service; |
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
use swentel\nostr\Message\RequestMessage; |
||||||
|
use swentel\nostr\Relay\RelaySet; |
||||||
|
use Symfony\Component\Process\PhpExecutableFinder; |
||||||
|
use Symfony\Component\Process\Process; |
||||||
|
|
||||||
|
/** |
||||||
|
* Multi-relay REQ fan-out: one in-process sequential {@see Request::send()} vs. one CLI worker per wss |
||||||
|
* ({@see bin/nostr_relay_request_worker.php}). Used for article discussion and kind-9802 highlight fetches. |
||||||
|
*/ |
||||||
|
final readonly class NostrRelayFanoutTransport |
||||||
|
{ |
||||||
|
/** Extra wall time for {@see bin/nostr_relay_request_worker.php} vs. WebSocket timeout. */ |
||||||
|
private const DISCUSSION_WORKER_GRACE_SEC = 5.0; |
||||||
|
|
||||||
|
/** Soft wall-time before stopping still-running parallel workers. */ |
||||||
|
private const DISCUSSION_PARALLEL_SOFT_DEADLINE_SEC = 3.5; |
||||||
|
|
||||||
|
/** |
||||||
|
* {@see Request::send()} visits relays sequentially; cap how many wss URLs we chain in one process |
||||||
|
* so HTTP /fragment/comments do not hit long proxy timeouts. |
||||||
|
*/ |
||||||
|
private const MAX_SEQUENTIAL_RELAY_URLS = 3; |
||||||
|
|
||||||
|
public function __construct( |
||||||
|
private LoggerInterface $logger, |
||||||
|
private NostrRelayRequestFactory $relayRequestFactory, |
||||||
|
private string $projectDir, |
||||||
|
) { |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param list<string> $relayUrls |
||||||
|
* |
||||||
|
* @return list<string> |
||||||
|
*/ |
||||||
|
public function capUrlsForSequential(array $relayUrls): array |
||||||
|
{ |
||||||
|
if (\count($relayUrls) <= self::MAX_SEQUENTIAL_RELAY_URLS) { |
||||||
|
return $relayUrls; |
||||||
|
} |
||||||
|
$this->logger->notice('nostr.article_discussion.sequential_relay_cap', [ |
||||||
|
'used' => self::MAX_SEQUENTIAL_RELAY_URLS, |
||||||
|
'had' => \count($relayUrls), |
||||||
|
]); |
||||||
|
|
||||||
|
return \array_slice($relayUrls, 0, self::MAX_SEQUENTIAL_RELAY_URLS); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* One {@see Request} over all relays in the set (library visits each wss:// in series). |
||||||
|
* |
||||||
|
* @return array<string, mixed> Same shape as {@see Request::send()} |
||||||
|
*/ |
||||||
|
public function sendSequential(RelaySet $relaySet, RequestMessage $requestMessage): array |
||||||
|
{ |
||||||
|
$request = $this->relayRequestFactory->createTimedRequest($relaySet, $requestMessage); |
||||||
|
|
||||||
|
return $request->send(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* One short-lived CLI worker per relay URL (parallel WebSocket I/O). |
||||||
|
* |
||||||
|
* @param list<string> $relayUrls |
||||||
|
* |
||||||
|
* @return array<string, mixed> Same shape as {@see Request::send()} |
||||||
|
*/ |
||||||
|
public function sendParallelWorkers(array $relayUrls, RequestMessage $requestMessage): array |
||||||
|
{ |
||||||
|
$worker = $this->projectDir.'/bin/nostr_relay_request_worker.php'; |
||||||
|
$phpBinary = (new PhpExecutableFinder())->find() ?: 'php'; |
||||||
|
$timeout = $this->relayRequestFactory->getRelayRequestTimeoutSec() + (int) self::DISCUSSION_WORKER_GRACE_SEC; |
||||||
|
$workerTimeoutEnv = ['NOSTR_RELAY_REQUEST_TIMEOUT' => (string) $this->relayRequestFactory->getRelayRequestTimeoutSec()]; |
||||||
|
|
||||||
|
$rawPayload = serialize($requestMessage); |
||||||
|
$tmp = tempnam(sys_get_temp_dir(), 'nrq_'); |
||||||
|
if ($tmp === false) { |
||||||
|
throw new \RuntimeException('tempnam failed for Nostr discussion payload'); |
||||||
|
} |
||||||
|
try { |
||||||
|
if (file_put_contents($tmp, $rawPayload) === false) { |
||||||
|
throw new \RuntimeException('Could not write Nostr discussion temp payload'); |
||||||
|
} |
||||||
|
|
||||||
|
/** @var array<string, Process> $procs */ |
||||||
|
$procs = []; |
||||||
|
foreach ($relayUrls as $wss) { |
||||||
|
if ($wss === '') { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$p = new Process( |
||||||
|
[$phpBinary, $worker, $wss, $tmp], |
||||||
|
$this->projectDir, |
||||||
|
null, |
||||||
|
null, |
||||||
|
(float) $timeout |
||||||
|
); |
||||||
|
$p->start(null, $workerTimeoutEnv); |
||||||
|
$procs[$wss] = $p; |
||||||
|
} |
||||||
|
|
||||||
|
$merged = []; |
||||||
|
$pending = $procs; |
||||||
|
$deadlineAt = microtime(true) + self::DISCUSSION_PARALLEL_SOFT_DEADLINE_SEC; |
||||||
|
while ($pending !== []) { |
||||||
|
foreach ($pending as $wss => $p) { |
||||||
|
if ($p->isRunning()) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
unset($pending[$wss]); |
||||||
|
if (!$p->isSuccessful()) { |
||||||
|
$err = $p->getErrorOutput(); |
||||||
|
$this->logger->warning('nostr.article_discussion.relay_worker_failed', [ |
||||||
|
'relay' => $wss, |
||||||
|
'exit_code' => $p->getExitCode(), |
||||||
|
'stderr' => $err !== '' ? $err : null, |
||||||
|
]); |
||||||
|
|
||||||
|
continue; |
||||||
|
} |
||||||
|
$out = trim($p->getOutput()); |
||||||
|
if ($out === '') { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$decoded = base64_decode($out, true); |
||||||
|
if ($decoded === false || $decoded === '') { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$chunk = unserialize($decoded, ['allowed_classes' => true]); |
||||||
|
if (!\is_array($chunk)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$merged = array_replace($merged, $chunk); |
||||||
|
} |
||||||
|
if ($pending === []) { |
||||||
|
break; |
||||||
|
} |
||||||
|
if (microtime(true) >= $deadlineAt) { |
||||||
|
foreach ($pending as $wss => $p) { |
||||||
|
$this->logger->warning('nostr.article_discussion.relay_worker_soft_timeout', [ |
||||||
|
'relay' => $wss, |
||||||
|
'soft_deadline_sec' => self::DISCUSSION_PARALLEL_SOFT_DEADLINE_SEC, |
||||||
|
]); |
||||||
|
$p->stop(0.2); |
||||||
|
} |
||||||
|
break; |
||||||
|
} |
||||||
|
usleep(100_000); |
||||||
|
} |
||||||
|
|
||||||
|
return $merged; |
||||||
|
} finally { |
||||||
|
if (\is_file($tmp)) { |
||||||
|
@unlink($tmp); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* One line per relay after {@see Request::send()}: errors vs message-type counts (EVENT, EOSE, …). |
||||||
|
* |
||||||
|
* @param array<string, mixed> $response |
||||||
|
*/ |
||||||
|
public function logWireResponseSummary(string $context, array $response): void |
||||||
|
{ |
||||||
|
foreach ($response as $relayUrl => $relayRes) { |
||||||
|
if ($relayRes instanceof \Throwable) { |
||||||
|
$this->logger->warning(sprintf( |
||||||
|
'nostr.wire.relay_throwable [%s]: %s', |
||||||
|
NostrRelayQuery::relayLogLabel($relayUrl), |
||||||
|
$relayRes->getMessage() |
||||||
|
), [ |
||||||
|
'context' => $context, |
||||||
|
'relay' => $relayUrl, |
||||||
|
'message' => $relayRes->getMessage(), |
||||||
|
'class' => \get_class($relayRes), |
||||||
|
]); |
||||||
|
|
||||||
|
continue; |
||||||
|
} |
||||||
|
if (!\is_iterable($relayRes)) { |
||||||
|
$this->logger->warning(sprintf( |
||||||
|
'nostr.wire.relay_not_iterable [%s]: %s', |
||||||
|
NostrRelayQuery::relayLogLabel($relayUrl), |
||||||
|
\get_debug_type($relayRes) |
||||||
|
), [ |
||||||
|
'context' => $context, |
||||||
|
'relay' => $relayUrl, |
||||||
|
'php_type' => \get_debug_type($relayRes), |
||||||
|
]); |
||||||
|
|
||||||
|
continue; |
||||||
|
} |
||||||
|
$counts = [ |
||||||
|
'EVENT' => 0, |
||||||
|
'EOSE' => 0, |
||||||
|
'NOTICE' => 0, |
||||||
|
'ERROR' => 0, |
||||||
|
'AUTH' => 0, |
||||||
|
'CLOSED' => 0, |
||||||
|
'other' => 0, |
||||||
|
]; |
||||||
|
foreach ($relayRes as $item) { |
||||||
|
if (!\is_object($item)) { |
||||||
|
++$counts['other']; |
||||||
|
|
||||||
|
continue; |
||||||
|
} |
||||||
|
$t = (string) ($item->type ?? 'other'); |
||||||
|
if (\array_key_exists($t, $counts)) { |
||||||
|
++$counts[$t]; |
||||||
|
} else { |
||||||
|
++$counts['other']; |
||||||
|
} |
||||||
|
} |
||||||
|
$this->logger->info(sprintf('nostr.wire.relay_messages [%s]', NostrRelayQuery::relayLogLabel($relayUrl)), [ |
||||||
|
'context' => $context, |
||||||
|
'relay' => $relayUrl, |
||||||
|
'counts' => $counts, |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,317 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Service; |
||||||
|
|
||||||
|
use App\Entity\User; |
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
use swentel\nostr\Relay\Relay; |
||||||
|
use swentel\nostr\Relay\RelaySet; |
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; |
||||||
|
|
||||||
|
/** |
||||||
|
* Config-driven relay URL lists and {@link RelaySet} construction: default + article + profile URLs, |
||||||
|
* profile fetch ordering, sequential cap for slow in-process {@see \swentel\nostr\Request\Request::send()}, |
||||||
|
* and Nostr Land → aggr.nostr.land for logged-in readers who list the former. |
||||||
|
*/ |
||||||
|
final readonly class NostrRelayListFactory |
||||||
|
{ |
||||||
|
/** When a logged-in user lists this relay, also use {@see self::AGGR_NOSTR_LAND} for comment + profile reads. */ |
||||||
|
private const NOSTR_LAND = 'wss://nostr.land'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Aggregated / subscription relay (not for anonymous visitors). Only added when the session user |
||||||
|
* has {@see self::NOSTR_LAND} in their NIP-65-style relay list. |
||||||
|
*/ |
||||||
|
private const AGGR_NOSTR_LAND = 'wss://aggr.nostr.land'; |
||||||
|
|
||||||
|
/** |
||||||
|
* {@see \swentel\nostr\Request\Request::send()} hits relays sequentially; profile pages (metadata, long-form list, 10133) used |
||||||
|
* the full default+article+profile list (~8–9 wss) → 2 slow relays can exceed PHP’s 30s default max_execution_time. |
||||||
|
*/ |
||||||
|
private const MAX_PROFILE_SEQUENTIAL_RELAY_URLS = 3; |
||||||
|
|
||||||
|
/** |
||||||
|
* @param list<string> $articleRelayUrls |
||||||
|
* @param list<string> $profileRelayUrls kind-0 / profile; merged for metadata (see {@see getProfileMetadataQueryRelayUrlList()}) |
||||||
|
*/ |
||||||
|
public function __construct( |
||||||
|
private string $defaultRelayUrl, |
||||||
|
private array $articleRelayUrls, |
||||||
|
private array $profileRelayUrls, |
||||||
|
private TokenStorageInterface $tokenStorage, |
||||||
|
private LoggerInterface $logger, |
||||||
|
) { |
||||||
|
} |
||||||
|
|
||||||
|
public function getDefaultRelayUrl(): string |
||||||
|
{ |
||||||
|
return $this->defaultRelayUrl; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* default_relay + article_relays from config, in order, deduplicated. Used for the static |
||||||
|
* default set and as the base when merging author/extra relay URLs in {@see createRelaySetMergedWithArticleList()}. |
||||||
|
* |
||||||
|
* @return list<string> |
||||||
|
*/ |
||||||
|
public function getConfiguredArticleRelayUrlList(): array |
||||||
|
{ |
||||||
|
$seen = []; |
||||||
|
$out = []; |
||||||
|
if ($this->defaultRelayUrl !== '') { |
||||||
|
$seen[$this->defaultRelayUrl] = true; |
||||||
|
$out[] = $this->defaultRelayUrl; |
||||||
|
} |
||||||
|
foreach ($this->articleRelayUrls as $url) { |
||||||
|
if ($url === '' || isset($seen[$url])) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$seen[$url] = true; |
||||||
|
$out[] = $url; |
||||||
|
} |
||||||
|
if ($out === []) { |
||||||
|
$out[] = $this->defaultRelayUrl; |
||||||
|
} |
||||||
|
|
||||||
|
return $out; |
||||||
|
} |
||||||
|
|
||||||
|
public function getDefaultArticleRelaySet(): RelaySet |
||||||
|
{ |
||||||
|
$relaySet = new RelaySet(); |
||||||
|
foreach ($this->getConfiguredArticleRelayUrlList() as $url) { |
||||||
|
$relaySet->addRelay(new Relay($url)); |
||||||
|
} |
||||||
|
|
||||||
|
return $relaySet; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Configured profile relays (kind-0 / NIP-05 hints) that are not already in the article relay list. |
||||||
|
* Used as a second pass for magazine 30040 and category long-form ingest when article relays return nothing. |
||||||
|
* Intentionally excludes merging article URLs again — {@see createRelaySetMergedWithArticleList()} prepends article relays. |
||||||
|
* |
||||||
|
* @return list<string> |
||||||
|
*/ |
||||||
|
public function getProfileRelayUrlsExcludedFromArticleRelays(): array |
||||||
|
{ |
||||||
|
$article = array_fill_keys($this->getConfiguredArticleRelayUrlList(), true); |
||||||
|
$out = []; |
||||||
|
foreach ($this->getProfileRelayUrlList() as $u) { |
||||||
|
if (!isset($article[$u])) { |
||||||
|
$out[] = $u; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return $out; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Relay set built only from the given URLs (no implicit article-relay merge). |
||||||
|
*/ |
||||||
|
public function createRelaySetFromUrlsOnly(array $relayUrls): RelaySet |
||||||
|
{ |
||||||
|
$relaySet = new RelaySet(); |
||||||
|
$seen = []; |
||||||
|
foreach ($relayUrls as $relayUrl) { |
||||||
|
if (!\is_string($relayUrl) || $relayUrl === '' || isset($seen[$relayUrl])) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$seen[$relayUrl] = true; |
||||||
|
$relaySet->addRelay(new Relay($relayUrl)); |
||||||
|
} |
||||||
|
|
||||||
|
return $relaySet; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Merges all configured article relays (default + article_relays) with the given URLs in order, deduped. |
||||||
|
* Used for comment threads, per-author fetches, etc. |
||||||
|
*/ |
||||||
|
public function createRelaySetMergedWithArticleList(array $relayUrls): RelaySet |
||||||
|
{ |
||||||
|
$relaySet = new RelaySet(); |
||||||
|
$seen = []; |
||||||
|
foreach (array_merge($this->getConfiguredArticleRelayUrlList(), $relayUrls) as $relayUrl) { |
||||||
|
if (!\is_string($relayUrl) || $relayUrl === '' || isset($seen[$relayUrl])) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$seen[$relayUrl] = true; |
||||||
|
$relaySet->addRelay(new Relay($relayUrl)); |
||||||
|
} |
||||||
|
|
||||||
|
return $relaySet; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Suffix to segregate HTTP caches: aggr is only used for some logged-in readers, so results differ. |
||||||
|
* |
||||||
|
* @return string empty when aggr is not used, else a short token |
||||||
|
*/ |
||||||
|
public function getNostrLandAggrReaderCacheSuffix(): string |
||||||
|
{ |
||||||
|
return $this->loggedInUserHasNostrLandInRelayList() ? 'a1' : ''; |
||||||
|
} |
||||||
|
|
||||||
|
public function loggedInUserHasNostrLandInRelayList(): bool |
||||||
|
{ |
||||||
|
$token = $this->tokenStorage->getToken(); |
||||||
|
if ($token === null) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
$user = $token->getUser(); |
||||||
|
if (!$user instanceof User) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
return $this->userRelayListContainsNostrLand($user->getRelays()); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param list<array{0?: string, 1?: string, 2?: string}>|array<array-key, mixed>|null $relays |
||||||
|
*/ |
||||||
|
private function userRelayListContainsNostrLand(?array $relays): bool |
||||||
|
{ |
||||||
|
if ($relays === null || $relays === []) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
$target = $this->normalizeWssUrlForNostrLandMatch(self::NOSTR_LAND); |
||||||
|
foreach ($relays as $row) { |
||||||
|
if (!\is_array($row) || !isset($row[1]) || !\is_string($row[1])) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
if ($this->normalizeWssUrlForNostrLandMatch($row[1]) === $target) { |
||||||
|
return true; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
private function normalizeWssUrlForNostrLandMatch(string $url): string |
||||||
|
{ |
||||||
|
return rtrim(trim($url), '/'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Appends wss://aggr.nostr.land when the current user listed wss://nostr.land (session). |
||||||
|
* |
||||||
|
* @param list<string> $urls |
||||||
|
* |
||||||
|
* @return list<string> |
||||||
|
*/ |
||||||
|
public function withAggrNostrLandIfUserSubscribesNostrLand(array $urls): array |
||||||
|
{ |
||||||
|
if (!$this->loggedInUserHasNostrLandInRelayList()) { |
||||||
|
return $urls; |
||||||
|
} |
||||||
|
$seen = array_fill_keys($urls, true); |
||||||
|
if (isset($seen[self::AGGR_NOSTR_LAND])) { |
||||||
|
return $urls; |
||||||
|
} |
||||||
|
$this->logger->debug('nostr.relay.append_aggr_nostr_land', [ |
||||||
|
'user_has_nostr_land' => true, |
||||||
|
]); |
||||||
|
$out = $urls; |
||||||
|
$out[] = self::AGGR_NOSTR_LAND; |
||||||
|
|
||||||
|
return $out; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param list<string> $urls |
||||||
|
*/ |
||||||
|
public function relaySetFromDistinctUrlList(array $urls): RelaySet |
||||||
|
{ |
||||||
|
$relaySet = new RelaySet(); |
||||||
|
$seen = []; |
||||||
|
foreach ($urls as $relayUrl) { |
||||||
|
if ($relayUrl === '' || isset($seen[$relayUrl])) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$seen[$relayUrl] = true; |
||||||
|
$relaySet->addRelay(new Relay($relayUrl)); |
||||||
|
} |
||||||
|
|
||||||
|
return $relaySet; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param list<string> $urls |
||||||
|
* |
||||||
|
* @return list<string> |
||||||
|
*/ |
||||||
|
public function capSequentialRelaysForProfileFetches(array $urls): array |
||||||
|
{ |
||||||
|
if (\count($urls) <= self::MAX_PROFILE_SEQUENTIAL_RELAY_URLS) { |
||||||
|
return $urls; |
||||||
|
} |
||||||
|
$this->logger->notice('nostr.relay_list_capped', [ |
||||||
|
'context' => 'profile_sequential', |
||||||
|
'max' => self::MAX_PROFILE_SEQUENTIAL_RELAY_URLS, |
||||||
|
'had' => \count($urls), |
||||||
|
]); |
||||||
|
|
||||||
|
return \array_slice($urls, 0, self::MAX_PROFILE_SEQUENTIAL_RELAY_URLS); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @return list<string> Deduplicated profile relay URLs from config |
||||||
|
*/ |
||||||
|
public function getProfileRelayUrlList(): array |
||||||
|
{ |
||||||
|
$seen = []; |
||||||
|
$out = []; |
||||||
|
foreach ($this->profileRelayUrls as $url) { |
||||||
|
if ($url === '' || isset($seen[$url])) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
if (!str_starts_with($url, 'wss:')) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$seen[$url] = true; |
||||||
|
$out[] = $url; |
||||||
|
} |
||||||
|
|
||||||
|
return $out; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Profile (kind-0) queries: {@see getProfileRelayUrlList()} first (Damus, nos.lol, …), then default + article set. |
||||||
|
* Order matters: {@see \swentel\nostr\Request\Request::send()} walks relays sequentially. |
||||||
|
* |
||||||
|
* @return list<string> |
||||||
|
*/ |
||||||
|
public function getProfileMetadataQueryRelayUrlList(): array |
||||||
|
{ |
||||||
|
$seen = []; |
||||||
|
$ordered = []; |
||||||
|
foreach (array_merge($this->getProfileRelayUrlList(), $this->getConfiguredArticleRelayUrlList()) as $u) { |
||||||
|
if ($u === '' || isset($seen[$u])) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$seen[$u] = true; |
||||||
|
$ordered[] = $u; |
||||||
|
} |
||||||
|
if ($ordered === []) { |
||||||
|
$ordered[] = $this->defaultRelayUrl; |
||||||
|
} |
||||||
|
|
||||||
|
return $this->withAggrNostrLandIfUserSubscribesNostrLand($ordered); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Same relays for kind-0 metadata, without mutating the default article relay set from {@see NostrClient}. |
||||||
|
*/ |
||||||
|
public function getRelaySetForProfileMetadataFetch(): RelaySet |
||||||
|
{ |
||||||
|
$relaySet = new RelaySet(); |
||||||
|
foreach ($this->getProfileMetadataQueryRelayUrlList() as $url) { |
||||||
|
$relaySet->addRelay(new Relay($url)); |
||||||
|
} |
||||||
|
|
||||||
|
return $relaySet; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,165 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Service; |
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
use swentel\nostr\Filter\Filter; |
||||||
|
use swentel\nostr\Message\RequestMessage; |
||||||
|
use swentel\nostr\Relay\RelaySet; |
||||||
|
use swentel\nostr\Request\Request; |
||||||
|
use swentel\nostr\Subscription\Subscription; |
||||||
|
|
||||||
|
/** |
||||||
|
* NIP-01 style REQ construction and per-relay response iteration (EVENT / ERROR / …). |
||||||
|
* Extracted from {@see NostrClient} for reuse; logging stays on this service. |
||||||
|
*/ |
||||||
|
final readonly class NostrRelayQuery |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
private LoggerInterface $logger, |
||||||
|
private NostrRelayRequestFactory $relayRequestFactory, |
||||||
|
) { |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Short host/URL for logs (e.g. fragment comments / prewarm) without full wss:// noise. |
||||||
|
*/ |
||||||
|
public static function relayLogLabel(string $relayUrl): string |
||||||
|
{ |
||||||
|
$host = parse_url($relayUrl, \PHP_URL_HOST); |
||||||
|
if (\is_string($host) && $host !== '') { |
||||||
|
return $host; |
||||||
|
} |
||||||
|
|
||||||
|
return $relayUrl; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param list<int|\BackedEnum> $kinds Integers or PHP 8.1 enums backed by int (e.g. {@see \App\Enum\KindsEnum}) |
||||||
|
* @param array<string, mixed> $filters Filter builder keys (e.g. authors, ids, tag, limit, …) |
||||||
|
*/ |
||||||
|
public function createNostrRequest( |
||||||
|
RelaySet $defaultRelaySet, |
||||||
|
?RelaySet $relaySet = null, |
||||||
|
array $kinds = [], |
||||||
|
array $filters = [], |
||||||
|
): Request { |
||||||
|
$subscription = new Subscription(); |
||||||
|
$subscriptionId = $subscription->setId(); |
||||||
|
$filter = new Filter(); |
||||||
|
$kindInts = []; |
||||||
|
foreach ($kinds as $k) { |
||||||
|
$kindInts[] = $k instanceof \BackedEnum ? (int) $k->value : (int) $k; |
||||||
|
} |
||||||
|
$filter->setKinds($kindInts); |
||||||
|
|
||||||
|
foreach ($filters as $key => $value) { |
||||||
|
$method = 'set' . ucfirst($key); |
||||||
|
if (method_exists($filter, $method)) { |
||||||
|
if ($key === 'tag') { |
||||||
|
$filter->setTag($value[0], $value[1]); |
||||||
|
} else { |
||||||
|
$filter->$method($value); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
$requestMessage = new RequestMessage($subscriptionId, [$filter]); |
||||||
|
$set = $relaySet ?? $defaultRelaySet; |
||||||
|
|
||||||
|
return $this->relayRequestFactory->createTimedRequest($set, $requestMessage); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param array<string, mixed> $response Return value of {@see Request::send()}: relay URL → message list|Throwable |
||||||
|
* @return list<mixed> |
||||||
|
*/ |
||||||
|
public function processResponse(array $response, callable $eventHandler): array |
||||||
|
{ |
||||||
|
$results = []; |
||||||
|
foreach ($response as $relayUrl => $relayRes) { |
||||||
|
if ($relayRes instanceof \Throwable) { |
||||||
|
$this->logger->error(sprintf( |
||||||
|
'Relay error at %s: %s', |
||||||
|
self::relayLogLabel($relayUrl), |
||||||
|
$relayRes->getMessage() |
||||||
|
), [ |
||||||
|
'relay' => $relayUrl, |
||||||
|
'error' => $relayRes->getMessage(), |
||||||
|
]); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
$itemEstimate = \is_countable($relayRes) ? \count($relayRes) : null; |
||||||
|
$this->logger->debug(sprintf('Processing relay response from %s', self::relayLogLabel($relayUrl)), [ |
||||||
|
'relay' => $relayUrl, |
||||||
|
'item_count' => $itemEstimate, |
||||||
|
]); |
||||||
|
|
||||||
|
foreach ($relayRes as $item) { |
||||||
|
try { |
||||||
|
if (!\is_object($item)) { |
||||||
|
$this->logger->warning(sprintf( |
||||||
|
'Invalid response item from %s', |
||||||
|
self::relayLogLabel($relayUrl) |
||||||
|
), [ |
||||||
|
'relay' => $relayUrl, |
||||||
|
'item' => $item, |
||||||
|
]); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
switch ($item->type) { |
||||||
|
case 'EVENT': |
||||||
|
$this->logger->debug(sprintf('Processing event from %s', self::relayLogLabel($relayUrl)), [ |
||||||
|
'relay' => $relayUrl, |
||||||
|
'event_id' => $item->event->id ?? 'unknown', |
||||||
|
]); |
||||||
|
$result = $eventHandler($item->event); |
||||||
|
if ($result !== null) { |
||||||
|
$results[] = $result; |
||||||
|
} |
||||||
|
break; |
||||||
|
case 'AUTH': |
||||||
|
$this->logger->warning(sprintf( |
||||||
|
'Relay %s requires authentication', |
||||||
|
self::relayLogLabel($relayUrl) |
||||||
|
), [ |
||||||
|
'relay' => $relayUrl, |
||||||
|
'response' => $item, |
||||||
|
]); |
||||||
|
break; |
||||||
|
case 'ERROR': |
||||||
|
case 'NOTICE': |
||||||
|
$msg = (string) ($item->message ?? 'No message'); |
||||||
|
$this->logger->warning(sprintf( |
||||||
|
'[%s] %s: %s', |
||||||
|
self::relayLogLabel($relayUrl), |
||||||
|
$item->type, |
||||||
|
$msg |
||||||
|
), [ |
||||||
|
'relay' => $relayUrl, |
||||||
|
'type' => $item->type, |
||||||
|
'message' => $msg, |
||||||
|
]); |
||||||
|
break; |
||||||
|
} |
||||||
|
} catch (\Exception $e) { |
||||||
|
$this->logger->error(sprintf( |
||||||
|
'Error processing event from relay %s: %s', |
||||||
|
self::relayLogLabel($relayUrl), |
||||||
|
$e->getMessage() |
||||||
|
), [ |
||||||
|
'relay' => $relayUrl, |
||||||
|
'error' => $e->getMessage(), |
||||||
|
]); |
||||||
|
continue; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return $results; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,49 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Service; |
||||||
|
|
||||||
|
use swentel\nostr\Message\RequestMessage; |
||||||
|
use swentel\nostr\Relay\RelaySet; |
||||||
|
use swentel\nostr\Request\Request; |
||||||
|
|
||||||
|
/** |
||||||
|
* Builds swentel {@see Request} instances with per-relay I/O timeout (config: `nostr_relay_request_timeout_sec`). |
||||||
|
* Shared by {@see NostrClient} so request wiring stays in one place. |
||||||
|
*/ |
||||||
|
final readonly class NostrRelayRequestFactory |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
private int $relayRequestTimeoutSec = 12, |
||||||
|
) { |
||||||
|
} |
||||||
|
|
||||||
|
public function getRelayRequestTimeoutSec(): int |
||||||
|
{ |
||||||
|
return $this->relayRequestTimeoutSec; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* {@see Request::setTimeout()} drives per-relay WebSocket I/O for {@see Request::send()}. |
||||||
|
*/ |
||||||
|
public function createTimedRequest(RelaySet $relaySet, RequestMessage $requestMessage): Request |
||||||
|
{ |
||||||
|
$request = new Request($relaySet, $requestMessage); |
||||||
|
|
||||||
|
return $request->setTimeout($this->relayRequestTimeoutSec); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* For paths that use {@see RelaySet::send()} with a custom message and bypass {@see Request}. |
||||||
|
*/ |
||||||
|
public function applySocketTimeoutToRelaySet(RelaySet $relaySet): void |
||||||
|
{ |
||||||
|
foreach ($relaySet->getRelays() as $relay) { |
||||||
|
$client = $relay->getClient(); |
||||||
|
if (method_exists($client, 'setTimeout')) { |
||||||
|
$client->setTimeout($this->relayRequestTimeoutSec); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,450 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Service; |
||||||
|
|
||||||
|
use App\Entity\Event as PublicationEventEntity; |
||||||
|
use App\Enum\KindsEnum; |
||||||
|
|
||||||
|
/** |
||||||
|
* NIP-33 / NIP-01 wire event merge, #d tags, npub→hex. See {@see NostrClient} call sites. |
||||||
|
*/ |
||||||
|
final readonly class NostrWireEventMerge |
||||||
|
{ |
||||||
|
private const NIP33_PARAMETERIZED_KIND_MIN = 30_000; |
||||||
|
private const NIP33_PARAMETERIZED_KIND_MAX = 39_999; |
||||||
|
|
||||||
|
public function __construct( |
||||||
|
private NostrKeyHelper $keyHelper, |
||||||
|
) { |
||||||
|
} |
||||||
|
|
||||||
|
public function isReplaceableByKindAndPubkeyNip(int $kind): bool |
||||||
|
{ |
||||||
|
return $kind === 0 |
||||||
|
|| $kind === 3 |
||||||
|
|| ($kind >= 10_000 && $kind < 20_000); |
||||||
|
} |
||||||
|
|
||||||
|
public function isNip33ParameterizedKind(int $kind): bool |
||||||
|
{ |
||||||
|
return $kind >= self::NIP33_PARAMETERIZED_KIND_MIN |
||||||
|
&& $kind <= self::NIP33_PARAMETERIZED_KIND_MAX; |
||||||
|
} |
||||||
|
|
||||||
|
private function replaceableKindPubkeyAddressFromWire(mixed $e): ?string |
||||||
|
{ |
||||||
|
if (!\is_object($e)) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
$k = (int) ($e->kind ?? 0); |
||||||
|
if (!$this->isReplaceableByKindAndPubkeyNip($k)) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
$pk = (string) ($e->pubkey ?? ''); |
||||||
|
if (64 !== \strlen($pk) || !ctype_xdigit($pk)) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return (string) $k.':'.strtolower($pk); |
||||||
|
} |
||||||
|
|
||||||
|
private function isValidNostrEventIdString(string $id): bool |
||||||
|
{ |
||||||
|
return 64 === \strlen($id) && ctype_xdigit($id); |
||||||
|
} |
||||||
|
|
||||||
|
public function wireEventSupersedes(mixed $candidate, mixed $incumbent): bool |
||||||
|
{ |
||||||
|
$c = $this->magazineEventCreatedAt($candidate); |
||||||
|
$i = $this->magazineEventCreatedAt($incumbent); |
||||||
|
if ($c !== $i) { |
||||||
|
return $c > $i; |
||||||
|
} |
||||||
|
$idC = $this->magazineEventId($candidate); |
||||||
|
$idI = $this->magazineEventId($incumbent); |
||||||
|
$vC = $this->isValidNostrEventIdString($idC); |
||||||
|
$vI = $this->isValidNostrEventIdString($idI); |
||||||
|
if ($vC !== $vI) { |
||||||
|
return $vC && !$vI; |
||||||
|
} |
||||||
|
if (!$vC) { |
||||||
|
if ($idC === $idI) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
return $idC < $idI; |
||||||
|
} |
||||||
|
if ($idC === $idI) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
return $idC < $idI; |
||||||
|
} |
||||||
|
|
||||||
|
private function kind0Nip01ReplaceableAddress(mixed $ev): ?string |
||||||
|
{ |
||||||
|
if (!\is_object($ev) || (int) ($ev->kind ?? -1) !== KindsEnum::METADATA->value) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
$pk = (string) ($ev->pubkey ?? ''); |
||||||
|
if (64 !== \strlen($pk) || !ctype_xdigit($pk)) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return '0:'.strtolower($pk); |
||||||
|
} |
||||||
|
|
||||||
|
private function kind0ReplaceableIsNewer(mixed $candidate, mixed $incumbent): bool |
||||||
|
{ |
||||||
|
return $this->wireEventSupersedes($candidate, $incumbent); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param list<mixed> $events |
||||||
|
* |
||||||
|
* @return array<string, object> |
||||||
|
*/ |
||||||
|
public function mergeKind0EventsByReplaceableAddress(array $events): array |
||||||
|
{ |
||||||
|
$byAddress = []; |
||||||
|
foreach ($events as $ev) { |
||||||
|
$addr = $this->kind0Nip01ReplaceableAddress($ev); |
||||||
|
if ($addr === null) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
if (!isset($byAddress[$addr]) || $this->kind0ReplaceableIsNewer($ev, $byAddress[$addr])) { |
||||||
|
$byAddress[$addr] = $ev; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return $byAddress; |
||||||
|
} |
||||||
|
|
||||||
|
public function nip33ParameterizedReplaceableAddress(mixed $event): ?string |
||||||
|
{ |
||||||
|
$k = $this->magazineEventKind($event); |
||||||
|
if (!$this->isNip33ParameterizedKind($k)) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
$pk = $this->magazineEventPubkeyHex($event); |
||||||
|
if ($pk === '' || 64 !== \strlen($pk) || !ctype_xdigit($pk)) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
$d = $this->eventDTagValue($event); |
||||||
|
if ($d === null || $d === '') { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return (string) $k.':'.strtolower($pk).':'.$d; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param list<mixed> $events |
||||||
|
* |
||||||
|
* @return list<object> |
||||||
|
*/ |
||||||
|
public function mergeNip33ParameterizedWireEvents(array $events): array |
||||||
|
{ |
||||||
|
$byNip33Address = []; |
||||||
|
$byKindPubkey = []; |
||||||
|
$byId = []; |
||||||
|
foreach ($events as $e) { |
||||||
|
if (!\is_object($e)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$k = (int) ($e->kind ?? 0); |
||||||
|
if ($this->isNip33ParameterizedKind($k)) { |
||||||
|
$a = $this->nip33ParameterizedReplaceableAddress($e); |
||||||
|
if ($a === null) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
if (!isset($byNip33Address[$a]) || $this->wireEventSupersedes($e, $byNip33Address[$a])) { |
||||||
|
$byNip33Address[$a] = $e; |
||||||
|
} |
||||||
|
} elseif ($this->isReplaceableByKindAndPubkeyNip($k)) { |
||||||
|
$a = $this->replaceableKindPubkeyAddressFromWire($e); |
||||||
|
if ($a === null) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
if (!isset($byKindPubkey[$a]) || $this->wireEventSupersedes($e, $byKindPubkey[$a])) { |
||||||
|
$byKindPubkey[$a] = $e; |
||||||
|
} |
||||||
|
} else { |
||||||
|
$id = (string) ($e->id ?? ''); |
||||||
|
if ($id === '') { |
||||||
|
continue; |
||||||
|
} |
||||||
|
if (!isset($byId[$id]) || $this->wireEventSupersedes($e, $byId[$id])) { |
||||||
|
$byId[$id] = $e; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return array_values(array_merge($byId, $byKindPubkey, $byNip33Address)); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param list<mixed> $events |
||||||
|
*/ |
||||||
|
public function pickLatestNip33ParameterizedForQuery( |
||||||
|
array $events, |
||||||
|
int $expectedKind, |
||||||
|
string $authorHexLower, |
||||||
|
string $dTag |
||||||
|
): mixed { |
||||||
|
if (!$this->isNip33ParameterizedKind($expectedKind)) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
$wantD = trim($dTag); |
||||||
|
$expectedAddr = (string) $expectedKind.':'.$authorHexLower.':'.$wantD; |
||||||
|
|
||||||
|
$merged = $this->mergeNip33ParameterizedWireEvents($events); |
||||||
|
foreach ($merged as $e) { |
||||||
|
if ($this->magazineEventKind($e) !== $expectedKind) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
if (strtolower($this->magazineEventPubkeyHex($e)) !== $authorHexLower) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$addr = $this->nip33ParameterizedReplaceableAddress($e); |
||||||
|
if ($addr === $expectedAddr) { |
||||||
|
return $e; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param list<mixed> $events |
||||||
|
*/ |
||||||
|
public function pickEventForNip33OrFirst(array $events, int $kind, string $authorIdent, string $dTag): ?object |
||||||
|
{ |
||||||
|
if ($events === []) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
if ($this->isNip33ParameterizedKind($kind)) { |
||||||
|
$h = $this->authorIdentToHexLower($authorIdent); |
||||||
|
if ($h !== null) { |
||||||
|
$picked = $this->pickLatestNip33ParameterizedForQuery($events, $kind, $h, $dTag); |
||||||
|
if ($picked !== null && \is_object($picked)) { |
||||||
|
return $picked; |
||||||
|
} |
||||||
|
} |
||||||
|
$merged = $this->mergeNip33ParameterizedWireEvents($events); |
||||||
|
$first = $merged[0] ?? null; |
||||||
|
|
||||||
|
return \is_object($first) ? $first : null; |
||||||
|
} |
||||||
|
if ($this->isReplaceableByKindAndPubkeyNip($kind)) { |
||||||
|
$h = $this->authorIdentToHexLower($authorIdent); |
||||||
|
if ($h !== null) { |
||||||
|
$best = null; |
||||||
|
foreach ($events as $e) { |
||||||
|
if (!\is_object($e) || (int) ($e->kind ?? 0) !== $kind) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
if (strtolower((string) ($e->pubkey ?? '')) !== $h) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
if ($best === null || $this->wireEventSupersedes($e, $best)) { |
||||||
|
$best = $e; |
||||||
|
} |
||||||
|
} |
||||||
|
if ($best !== null) { |
||||||
|
return $best; |
||||||
|
} |
||||||
|
} |
||||||
|
foreach ($this->mergeNip33ParameterizedWireEvents($events) as $e) { |
||||||
|
if ((int) ($e->kind ?? 0) === $kind) { |
||||||
|
return $e; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
$e0 = $events[0] ?? null; |
||||||
|
|
||||||
|
return \is_object($e0) ? $e0 : null; |
||||||
|
} |
||||||
|
|
||||||
|
public function authorIdentToHexLower(mixed $ident): ?string |
||||||
|
{ |
||||||
|
return $this->npubToHexPubkey($ident); |
||||||
|
} |
||||||
|
|
||||||
|
public function npubToHexPubkey(mixed $npub): ?string |
||||||
|
{ |
||||||
|
$s = trim((string) $npub); |
||||||
|
if ($s === '') { |
||||||
|
return null; |
||||||
|
} |
||||||
|
if (64 === \strlen($s) && ctype_xdigit($s)) { |
||||||
|
return strtolower($s); |
||||||
|
} |
||||||
|
if (str_starts_with($s, 'npub')) { |
||||||
|
$hex = $this->keyHelper->convertToHex($s); |
||||||
|
|
||||||
|
return $hex !== '' && 64 === \strlen($hex) && ctype_xdigit($hex) ? strtolower($hex) : null; |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
public function eventDTagValue(mixed $event): ?string |
||||||
|
{ |
||||||
|
$tags = null; |
||||||
|
if ($event instanceof PublicationEventEntity) { |
||||||
|
$tags = $event->getTags(); |
||||||
|
} elseif (\is_object($event) && isset($event->tags) && \is_array($event->tags)) { |
||||||
|
$tags = $event->tags; |
||||||
|
} |
||||||
|
if (!\is_array($tags)) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
foreach ($tags as $t) { |
||||||
|
$seq = $this->normalizeNostrTagRowToSequence($t); |
||||||
|
if ($seq === null || ($seq[0] ?? '') !== 'd' || !isset($seq[1]) || (string) $seq[1] === '') { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
return trim((string) $seq[1]); |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @return list<string>|null |
||||||
|
*/ |
||||||
|
private function normalizeNostrTagRowToSequence(mixed $row): ?array |
||||||
|
{ |
||||||
|
if ($row === null) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
if (\is_object($row)) { |
||||||
|
$row = get_object_vars($row); |
||||||
|
} |
||||||
|
if (!\is_array($row) || $row === []) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
$seq = array_values( |
||||||
|
array_map( |
||||||
|
static fn (mixed $v): string => (string) $v, |
||||||
|
$row |
||||||
|
) |
||||||
|
); |
||||||
|
if ($seq[0] === '') { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return $seq; |
||||||
|
} |
||||||
|
|
||||||
|
public function longformIngestShortSlug(string $slug, int $max = 100): string |
||||||
|
{ |
||||||
|
$t = trim($slug); |
||||||
|
if (strlen($t) > $max) { |
||||||
|
return substr($t, 0, $max - 1).'…'; |
||||||
|
} |
||||||
|
|
||||||
|
return $t; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @return array{kind: int, id: string, created_at: int, d: string, nip33: ?string} |
||||||
|
*/ |
||||||
|
public function longformIngestEventWireSummary(object $e): array |
||||||
|
{ |
||||||
|
$d = $this->eventDTagValue($e); |
||||||
|
$nip = $this->nip33ParameterizedReplaceableAddress($e); |
||||||
|
|
||||||
|
return [ |
||||||
|
'kind' => (int) ($e->kind ?? 0), |
||||||
|
'id' => (string) ($e->id ?? ''), |
||||||
|
'created_at' => (int) ($e->created_at ?? 0), |
||||||
|
'd' => $d !== null && $d !== '' ? $this->longformIngestShortSlug($d, 80) : '', |
||||||
|
'nip33' => $nip, |
||||||
|
]; |
||||||
|
} |
||||||
|
|
||||||
|
public function magazineEventToPublicationEntity(mixed $raw): ?PublicationEventEntity |
||||||
|
{ |
||||||
|
if ($raw instanceof PublicationEventEntity) { |
||||||
|
return $raw; |
||||||
|
} |
||||||
|
if (!\is_object($raw)) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
$data = json_decode(json_encode($raw, \JSON_THROW_ON_ERROR), true, 512, \JSON_THROW_ON_ERROR); |
||||||
|
} catch (\JsonException) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
if (!\is_array($data)) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
$entity = new PublicationEventEntity(); |
||||||
|
$entity->setId((string) ($data['id'] ?? '')); |
||||||
|
$entity->setKind((int) ($data['kind'] ?? 0)); |
||||||
|
$entity->setPubkey((string) ($data['pubkey'] ?? '')); |
||||||
|
$entity->setContent((string) ($data['content'] ?? '')); |
||||||
|
$entity->setCreatedAt((int) ($data['created_at'] ?? 0)); |
||||||
|
$tags = $data['tags'] ?? []; |
||||||
|
$entity->setTags(\is_array($tags) ? $tags : []); |
||||||
|
$entity->setSig((string) ($data['sig'] ?? '')); |
||||||
|
|
||||||
|
return $entity; |
||||||
|
} |
||||||
|
|
||||||
|
public function magazineEventCreatedAt(mixed $event): int |
||||||
|
{ |
||||||
|
if ($event instanceof PublicationEventEntity) { |
||||||
|
return $event->getCreatedAt(); |
||||||
|
} |
||||||
|
if (\is_object($event) && isset($event->created_at)) { |
||||||
|
return (int) $event->created_at; |
||||||
|
} |
||||||
|
|
||||||
|
return 0; |
||||||
|
} |
||||||
|
|
||||||
|
private function magazineEventId(mixed $event): string |
||||||
|
{ |
||||||
|
if ($event instanceof PublicationEventEntity) { |
||||||
|
return $event->getId(); |
||||||
|
} |
||||||
|
if (\is_object($event) && isset($event->id)) { |
||||||
|
return (string) $event->id; |
||||||
|
} |
||||||
|
|
||||||
|
return ''; |
||||||
|
} |
||||||
|
|
||||||
|
private function magazineEventKind(mixed $event): int |
||||||
|
{ |
||||||
|
if ($event instanceof PublicationEventEntity) { |
||||||
|
return $event->getKind(); |
||||||
|
} |
||||||
|
if (\is_object($event) && isset($event->kind)) { |
||||||
|
return (int) $event->kind; |
||||||
|
} |
||||||
|
|
||||||
|
return 0; |
||||||
|
} |
||||||
|
|
||||||
|
private function magazineEventPubkeyHex(mixed $event): string |
||||||
|
{ |
||||||
|
if ($event instanceof PublicationEventEntity) { |
||||||
|
return (string) $event->getPubkey(); |
||||||
|
} |
||||||
|
if (\is_object($event) && isset($event->pubkey)) { |
||||||
|
return (string) $event->pubkey; |
||||||
|
} |
||||||
|
|
||||||
|
return ''; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,105 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Service; |
||||||
|
|
||||||
|
use App\Enum\EventStatusEnum; |
||||||
|
use App\Repository\ArticleRepository; |
||||||
|
use Doctrine\DBAL\ArrayParameterType; |
||||||
|
|
||||||
|
/** |
||||||
|
* Top topics for the sidebar and topic browse pages. |
||||||
|
*/ |
||||||
|
final class TopicIndexService |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
private readonly ArticleRepository $articleRepository, |
||||||
|
private readonly MagazineContentService $magazineContent, |
||||||
|
) { |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Up to 25 most relevant topic strings, scored by count + 5× featured (magazine home cards). |
||||||
|
* |
||||||
|
* @return list<string> topic labels (lowercase, no #) |
||||||
|
*/ |
||||||
|
public function getTopTopicLabels(int $limit = 25): array |
||||||
|
{ |
||||||
|
$conn = $this->articleRepository->getEntityManager()->getConnection(); |
||||||
|
$slugs = $this->magazineContent->collectFeaturedArticleSlugsForHome( |
||||||
|
$this->magazineContent->getHomeCategoryAIndexTagsFromStoreOnly(), |
||||||
|
); |
||||||
|
$featured = []; |
||||||
|
foreach ($slugs as $s) { |
||||||
|
$s = \strtolower(\trim($s)); |
||||||
|
if ($s !== '') { |
||||||
|
$featured[$s] = true; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
$rows = $conn->fetchAllAssociative( |
||||||
|
'SELECT a.slug, a.topics FROM article a |
||||||
|
WHERE a.topics IS NOT NULL |
||||||
|
AND a.content IS NOT NULL |
||||||
|
AND CHAR_LENGTH(a.content) > 250 |
||||||
|
AND a.event_status IN (:st)', |
||||||
|
[ |
||||||
|
'st' => [EventStatusEnum::PUBLISHED->value, EventStatusEnum::ARCHIVED->value], |
||||||
|
], |
||||||
|
[ |
||||||
|
'st' => ArrayParameterType::INTEGER, |
||||||
|
], |
||||||
|
); |
||||||
|
|
||||||
|
$acc = []; |
||||||
|
foreach ($rows as $row) { |
||||||
|
$raw = $row['topics'] ?? null; |
||||||
|
if (\is_array($raw)) { |
||||||
|
$dec = $raw; |
||||||
|
} elseif (\is_string($raw) && $raw !== '') { |
||||||
|
$dec = json_decode($raw, true); |
||||||
|
} else { |
||||||
|
continue; |
||||||
|
} |
||||||
|
if (!\is_array($dec)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$slug = \strtolower(\trim((string) ($row['slug'] ?? ''))); |
||||||
|
$isFeat = $slug !== '' && isset($featured[$slug]); |
||||||
|
foreach ($dec as $t) { |
||||||
|
if (!\is_string($t) || $t === '') { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$k = \str_replace('#', '', \strtolower(\trim($t))); |
||||||
|
if ($k === '') { |
||||||
|
continue; |
||||||
|
} |
||||||
|
if (!isset($acc[$k])) { |
||||||
|
$acc[$k] = ['c' => 0, 'f' => 0]; |
||||||
|
} |
||||||
|
++$acc[$k]['c']; |
||||||
|
if ($isFeat) { |
||||||
|
++$acc[$k]['f']; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
uasort( |
||||||
|
$acc, |
||||||
|
static function (array $a, array $b): int { |
||||||
|
$sa = $a['c'] + 5 * $a['f']; |
||||||
|
$sb = $b['c'] + 5 * $b['f']; |
||||||
|
if ($sa === $sb) { |
||||||
|
return $b['c'] <=> $a['c']; |
||||||
|
} |
||||||
|
|
||||||
|
return $sb <=> $sa; |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
$keys = array_keys($acc); |
||||||
|
|
||||||
|
return \array_slice($keys, 0, max(0, $limit)); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,96 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Twig; |
||||||
|
|
||||||
|
use App\Service\CacheService; |
||||||
|
use App\Service\NostrPathHelper; |
||||||
|
use Symfony\Component\Asset\Packages; |
||||||
|
use Throwable; |
||||||
|
use Twig\Extension\AbstractExtension; |
||||||
|
use Twig\TwigFunction; |
||||||
|
|
||||||
|
/** |
||||||
|
* Resolves a card hero image: article image, Nostr kind-0 profile {@see picture}, then site default image. |
||||||
|
*/ |
||||||
|
final class ArticleCardCoverExtension extends AbstractExtension |
||||||
|
{ |
||||||
|
/** |
||||||
|
* Used when the article has no image and the author has no (or no usable) NIP-01 {@see picture} URL. |
||||||
|
* Same asset as the header mark so empty hero slots read as the site, not a blank gray field. |
||||||
|
*/ |
||||||
|
private const DEFAULT_PACKAGE_IMAGE = 'icons/favicon-96x96.png'; |
||||||
|
|
||||||
|
/** |
||||||
|
* @var array<string, string> lowercase 64-hex pubkey → resolved cover URL (author picture or site default) |
||||||
|
*/ |
||||||
|
private array $authorCoverMemo = []; |
||||||
|
|
||||||
|
public function __construct( |
||||||
|
private readonly CacheService $cacheService, |
||||||
|
private readonly NostrPathHelper $nostrPathHelper, |
||||||
|
private readonly Packages $packages, |
||||||
|
) { |
||||||
|
} |
||||||
|
|
||||||
|
public function getFunctions(): array |
||||||
|
{ |
||||||
|
return [ |
||||||
|
new TwigFunction('article_card_cover', $this->articleCardCover(...)), |
||||||
|
]; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param string|null $articleImage Cover URL stored on the article, if any |
||||||
|
* @param string|null $pubkeyHex 64-char hex (lowercase) Nostr public key, if any |
||||||
|
*/ |
||||||
|
public function articleCardCover(?string $articleImage, ?string $pubkeyHex): string |
||||||
|
{ |
||||||
|
if ($articleImage !== null && trim($articleImage) !== '') { |
||||||
|
return trim($articleImage); |
||||||
|
} |
||||||
|
|
||||||
|
$pubkeyHex = $pubkeyHex !== null ? strtolower(trim($pubkeyHex)) : ''; |
||||||
|
if (64 !== strlen($pubkeyHex) || !ctype_xdigit($pubkeyHex)) { |
||||||
|
return $this->defaultSiteImageUrl(); |
||||||
|
} |
||||||
|
|
||||||
|
if (\array_key_exists($pubkeyHex, $this->authorCoverMemo)) { |
||||||
|
return $this->authorCoverMemo[$pubkeyHex]; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
$npub = $this->nostrPathHelper->npubFromPubkeyHex($pubkeyHex); |
||||||
|
if ($npub === '') { |
||||||
|
$url = $this->defaultSiteImageUrl(); |
||||||
|
$this->authorCoverMemo[$pubkeyHex] = $url; |
||||||
|
|
||||||
|
return $url; |
||||||
|
} |
||||||
|
|
||||||
|
$meta = $this->cacheService->getMetadata($npub); |
||||||
|
$pic = isset($meta->picture) ? trim((string) $meta->picture) : ''; |
||||||
|
if ($pic !== '') { |
||||||
|
$this->authorCoverMemo[$pubkeyHex] = $pic; |
||||||
|
|
||||||
|
return $pic; |
||||||
|
} |
||||||
|
} catch (Throwable) { |
||||||
|
$out = $this->defaultSiteImageUrl(); |
||||||
|
$this->authorCoverMemo[$pubkeyHex] = $out; |
||||||
|
|
||||||
|
return $out; |
||||||
|
} |
||||||
|
|
||||||
|
$out = $this->defaultSiteImageUrl(); |
||||||
|
$this->authorCoverMemo[$pubkeyHex] = $out; |
||||||
|
|
||||||
|
return $out; |
||||||
|
} |
||||||
|
|
||||||
|
private function defaultSiteImageUrl(): string |
||||||
|
{ |
||||||
|
return $this->packages->getUrl(self::DEFAULT_PACKAGE_IMAGE); |
||||||
|
} |
||||||
|
} |
||||||
@ -1,127 +0,0 @@ |
|||||||
<?php |
|
||||||
|
|
||||||
namespace App\Twig\Components; |
|
||||||
|
|
||||||
use App\Repository\ArticleRepository; |
|
||||||
use Psr\Log\LoggerInterface; |
|
||||||
use Symfony\Component\HttpFoundation\RequestStack; |
|
||||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; |
|
||||||
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; |
|
||||||
use Symfony\UX\LiveComponent\Attribute\LiveAction; |
|
||||||
use Symfony\UX\LiveComponent\Attribute\LiveProp; |
|
||||||
use Symfony\UX\LiveComponent\DefaultActionTrait; |
|
||||||
use Symfony\Contracts\Cache\CacheInterface; |
|
||||||
|
|
||||||
#[AsLiveComponent] |
|
||||||
final class SearchComponent |
|
||||||
{ |
|
||||||
use DefaultActionTrait; |
|
||||||
|
|
||||||
#[LiveProp(writable: true, useSerializerForHydration: true)] |
|
||||||
public string $query = ''; |
|
||||||
public array $results = []; |
|
||||||
|
|
||||||
public bool $interactive = true; |
|
||||||
|
|
||||||
#[LiveProp] |
|
||||||
public int $vol = 0; |
|
||||||
|
|
||||||
#[LiveProp(writable: true)] |
|
||||||
public int $page = 1; |
|
||||||
|
|
||||||
#[LiveProp] |
|
||||||
public int $resultsPerPage = 12; |
|
||||||
|
|
||||||
private const SESSION_KEY = 'last_search_results'; |
|
||||||
private const SESSION_QUERY_KEY = 'last_search_query'; |
|
||||||
|
|
||||||
public function __construct( |
|
||||||
private readonly ArticleRepository $articleRepository, |
|
||||||
private readonly TokenStorageInterface $tokenStorage, |
|
||||||
private readonly LoggerInterface $logger, |
|
||||||
private readonly CacheInterface $cache, |
|
||||||
private readonly RequestStack $requestStack |
|
||||||
) |
|
||||||
{ |
|
||||||
} |
|
||||||
|
|
||||||
public function mount(): void |
|
||||||
{ |
|
||||||
// Restore search results from session if available and no query provided |
|
||||||
if (empty($this->query)) { |
|
||||||
$session = $this->requestStack->getSession(); |
|
||||||
if ($session->has(self::SESSION_QUERY_KEY)) { |
|
||||||
$this->query = $session->get(self::SESSION_QUERY_KEY); |
|
||||||
$this->results = $session->get(self::SESSION_KEY, []); |
|
||||||
$this->logger->info('Restored search results from session for query: ' . $this->query); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
#[LiveAction] |
|
||||||
public function search(): void |
|
||||||
{ |
|
||||||
$this->logger->info("Query: {$this->query}"); |
|
||||||
|
|
||||||
if (empty($this->query)) { |
|
||||||
$this->results = []; |
|
||||||
$this->clearSearchCache(); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
// Check if the same query exists in session |
|
||||||
$session = $this->requestStack->getSession(); |
|
||||||
if ($session->has(self::SESSION_QUERY_KEY) && |
|
||||||
$session->get(self::SESSION_QUERY_KEY) === $this->query) { |
|
||||||
$this->results = $session->get(self::SESSION_KEY, []); |
|
||||||
$this->logger->info('Using cached search results for query: ' . $this->query); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
try { |
|
||||||
$this->results = []; |
|
||||||
|
|
||||||
// Use database-based search instead of Elasticsearch |
|
||||||
$offset = ($this->page - 1) * $this->resultsPerPage; |
|
||||||
$results = $this->articleRepository->searchArticles( |
|
||||||
$this->query, |
|
||||||
$this->resultsPerPage, |
|
||||||
$offset |
|
||||||
); |
|
||||||
|
|
||||||
$this->logger->info('Search results count: ' . count($results)); |
|
||||||
$this->logger->info('Search results: ', ['results' => $results]); |
|
||||||
|
|
||||||
$this->results = $results; |
|
||||||
|
|
||||||
// Cache the search results in session |
|
||||||
$this->saveSearchToSession($this->query, $this->results); |
|
||||||
|
|
||||||
} catch (\Exception $e) { |
|
||||||
$this->logger->error('Search error: ' . $e->getMessage()); |
|
||||||
$this->results = []; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Save search results to session |
|
||||||
*/ |
|
||||||
private function saveSearchToSession(string $query, array $results): void |
|
||||||
{ |
|
||||||
$session = $this->requestStack->getSession(); |
|
||||||
$session->set(self::SESSION_QUERY_KEY, $query); |
|
||||||
$session->set(self::SESSION_KEY, $results); |
|
||||||
$this->logger->info('Saved search results to session for query: ' . $query); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Clear search cache from session |
|
||||||
*/ |
|
||||||
private function clearSearchCache(): void |
|
||||||
{ |
|
||||||
$session = $this->requestStack->getSession(); |
|
||||||
$session->remove(self::SESSION_QUERY_KEY); |
|
||||||
$session->remove(self::SESSION_KEY); |
|
||||||
$this->logger->info('Cleared search cache from session'); |
|
||||||
} |
|
||||||
} |
|
||||||
@ -0,0 +1,27 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Twig; |
||||||
|
|
||||||
|
use App\Service\FeaturedAuthorListedRows; |
||||||
|
use Twig\Extension\AbstractExtension; |
||||||
|
use Twig\TwigFunction; |
||||||
|
|
||||||
|
/** Twig function for the left nav featured-author avatars (avoids Symfony UX component context quirks). */ |
||||||
|
final class SidebarFeaturedAuthorsExtension extends AbstractExtension |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
private readonly FeaturedAuthorListedRows $featuredAuthorListedRows, |
||||||
|
) { |
||||||
|
} |
||||||
|
|
||||||
|
public function getFunctions(): array |
||||||
|
{ |
||||||
|
return [ |
||||||
|
new TwigFunction('sidebar_featured_author_rows', function (int $limit = 12): array { |
||||||
|
return $this->featuredAuthorListedRows->buildSidebarRows($limit); |
||||||
|
}), |
||||||
|
]; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,26 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Twig; |
||||||
|
|
||||||
|
use App\Service\TopicIndexService; |
||||||
|
use Twig\Extension\AbstractExtension; |
||||||
|
use Twig\TwigFunction; |
||||||
|
|
||||||
|
final class TopTopicsExtension extends AbstractExtension |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
private readonly TopicIndexService $topicIndexService, |
||||||
|
) { |
||||||
|
} |
||||||
|
|
||||||
|
public function getFunctions(): array |
||||||
|
{ |
||||||
|
return [ |
||||||
|
new TwigFunction('top_topic_labels', function (int $limit = 25): array { |
||||||
|
return $this->topicIndexService->getTopTopicLabels($limit); |
||||||
|
}), |
||||||
|
]; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,546 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Util; |
||||||
|
|
||||||
|
/** |
||||||
|
* NIP-84 (kind 9802): `context` tag = full quote; the event’s `.content` = the highlighted part of |
||||||
|
* that quote. If there is no `context` tag (or it is empty), the passage to display is the same |
||||||
|
* as `.content` (entirely highlighted). In-article marks: {@see \App\Service\ArticleBodyHighlightInjector}. |
||||||
|
*/ |
||||||
|
final class HighlightEventTags |
||||||
|
{ |
||||||
|
public const HIGHLIGHT_MARK_CLASS = 'user-highlight__marker'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Turn one Nostr tag (array, associative array, or object from JSON) into an ordered list of |
||||||
|
* string cells. Relay clients and json_decode vary; without this, `context` tags are often skipped. |
||||||
|
* |
||||||
|
* @return list<string>|null empty tag rows become null |
||||||
|
*/ |
||||||
|
public static function nostrTagRowToList(mixed $tag): ?array |
||||||
|
{ |
||||||
|
if (\is_object($tag)) { |
||||||
|
$tag = (array) $tag; |
||||||
|
} |
||||||
|
if (!\is_array($tag)) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
\ksort($tag, \SORT_NUMERIC); |
||||||
|
$tag = \array_values($tag); |
||||||
|
$out = []; |
||||||
|
foreach ($tag as $cell) { |
||||||
|
$out[] = (string) $cell; |
||||||
|
} |
||||||
|
if ($out === []) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return $out; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Canonical tag list for JSON storage (list of list of strings). |
||||||
|
* |
||||||
|
* @param list<mixed> $tags |
||||||
|
* |
||||||
|
* @return list<list<string>> |
||||||
|
*/ |
||||||
|
public static function normalizeTagsForStorage(array $tags): array |
||||||
|
{ |
||||||
|
$out = []; |
||||||
|
foreach ($tags as $tag) { |
||||||
|
$row = self::nostrTagRowToList($tag); |
||||||
|
if (null !== $row && $row !== []) { |
||||||
|
$out[] = $row; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return $out; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Trims Nostr/Unicode spacing (U+00A0, U+200B, U+00AD, other {@see \p{Z}}, etc.) from both ends |
||||||
|
* after standard {@see \trim} — NIP-84 clients often differ from the rendered body on edge spaces. |
||||||
|
*/ |
||||||
|
public static function trimNostrText(string $s): string |
||||||
|
{ |
||||||
|
$s = \trim($s, " \t\n\r\0\x0B"); |
||||||
|
if ($s === '') { |
||||||
|
return ''; |
||||||
|
} |
||||||
|
$edge = '\p{Z}\x{200B}\x{200C}\x{200D}\x{FEFF}'; |
||||||
|
$s = (string) \preg_replace('/^['.$edge.']+/u', '', $s); |
||||||
|
$s = (string) \preg_replace('/['.$edge.']+$/u', '', $s); |
||||||
|
$s = \trim($s, " \t\n\r\0\x0B"); |
||||||
|
|
||||||
|
return $s; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* The full passage from the `context` tag (one tag may split across many values in some clients). |
||||||
|
*/ |
||||||
|
public static function contextFromTags(array $tags): string |
||||||
|
{ |
||||||
|
return self::valuesFromNostrTagName($tags, 'context'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Same shape as the `context` tag: one or more `textquoteselector` rows (used for excerpts only). |
||||||
|
*/ |
||||||
|
public static function textquoteselectorPassageFromTags(array $tags): string |
||||||
|
{ |
||||||
|
return self::valuesFromNostrTagName($tags, 'textquoteselector'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Full “quote” passage for cards: the `context` tag when present and non-empty, otherwise |
||||||
|
* the same string as the event’s `.content` (no surrounding quote beyond the highlight). |
||||||
|
*/ |
||||||
|
public static function fullPassageForHighlightDisplay(string $eventContent, array $tags): string |
||||||
|
{ |
||||||
|
$ctx = \trim(self::contextFromTags($tags)); |
||||||
|
if ($ctx !== '') { |
||||||
|
return $ctx; |
||||||
|
} |
||||||
|
|
||||||
|
return \trim((string) $eventContent); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param list<mixed> $tags |
||||||
|
*/ |
||||||
|
private static function valuesFromNostrTagName(array $tags, string $nameLower): string |
||||||
|
{ |
||||||
|
$parts = []; |
||||||
|
foreach ($tags as $t) { |
||||||
|
$row = self::nostrTagRowToList($t); |
||||||
|
if (null === $row || \count($row) < 2) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$k = self::normalizeNostrTagKey($row[0]); |
||||||
|
if ($k !== $nameLower) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
for ($i = 1, $c = \count($row); $i < $c; ++$i) { |
||||||
|
$p = $row[$i]; |
||||||
|
if ($p !== '') { |
||||||
|
$parts[] = $p; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
if ($parts === []) { |
||||||
|
return ''; |
||||||
|
} |
||||||
|
$joined = \implode(' ', $parts); |
||||||
|
|
||||||
|
return \mb_substr($joined, 0, 8000); |
||||||
|
} |
||||||
|
|
||||||
|
private static function normalizeNostrTagKey(string $k): string |
||||||
|
{ |
||||||
|
$k = (string) \preg_replace('/^\x{FEFF}/u', '', $k); |
||||||
|
$k = \ltrim($k, "\0..\x1F"); |
||||||
|
|
||||||
|
return \strtolower(\trim($k)); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Same character normalization as {@see \App\Service\ArticleBodyHighlightInjector} so |
||||||
|
* `content` can match the `context` tag when Unicode (NBSP, soft hyphen, etc.) differs — NIP-84 |
||||||
|
* requires `content` to be a substring of the passage, but clients often diverge on code points. |
||||||
|
* |
||||||
|
* Newlines and Unicode line/paragraph separators are removed: Nostr `context` often contains |
||||||
|
* `\\n` between sentences, while the article DOM’s flattened text has no line breaks at block |
||||||
|
* boundaries, so they must not break matching. |
||||||
|
* |
||||||
|
* Smart punctuation (curly quotes, en/em dash, Unicode ellipsis) is folded to ASCII so the |
||||||
|
* article HTML from {@see \League\CommonMark\Extension\SmartPunct\SmartPunctExtension} still |
||||||
|
* matches highlight `content` copied with straight quotes from the source article. |
||||||
|
*/ |
||||||
|
public static 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 .= self::searchCharacterNormalized($ch); |
||||||
|
} |
||||||
|
|
||||||
|
return $out; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @return list<int> length L+1; cuml[i] = "search" string length of prefix s[0..i) after per-char normalization |
||||||
|
*/ |
||||||
|
public static 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 = self::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 |
||||||
|
*/ |
||||||
|
public static function mapSearchStringRangeToOrigStringRange(string $orig, int $nStart, int $nEnd): array |
||||||
|
{ |
||||||
|
$L = \mb_strlen($orig, 'UTF-8'); |
||||||
|
$cuml = self::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]; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Find `content` inside `context` (literal or after Unicode/Nostr normalization). Returns half-open |
||||||
|
* mb indices into $context, or null. |
||||||
|
* |
||||||
|
* $context and $content must be the same strings used for final HTML (trim + line ending |
||||||
|
* normalization) — see {@see buildHighlightedBodyHtml}. |
||||||
|
* |
||||||
|
* @return array{0: int, 1: int}|null |
||||||
|
*/ |
||||||
|
public static function findContentSpanInContext(string $context, string $content): ?array |
||||||
|
{ |
||||||
|
$q = $context; |
||||||
|
if ($q === '' || $content === '') { |
||||||
|
return null; |
||||||
|
} |
||||||
|
foreach (self::highlightContentSearchVariants($content) as $needle) { |
||||||
|
$needle = self::normalizeLineEndingsForHighlight($needle); |
||||||
|
if ($needle === '') { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$p = \mb_strpos($q, $needle, 0, 'UTF-8'); |
||||||
|
if (false !== $p) { |
||||||
|
$len = \mb_strlen($needle, 'UTF-8'); |
||||||
|
|
||||||
|
return [$p, $p + $len]; |
||||||
|
} |
||||||
|
} |
||||||
|
$qR = self::replaceTypographicQuotesForSearch($q); |
||||||
|
if ($qR !== $q) { |
||||||
|
foreach (self::highlightContentSearchVariants($content) as $needle) { |
||||||
|
$needle = self::normalizeLineEndingsForHighlight($needle); |
||||||
|
if ($needle === '') { |
||||||
|
continue; |
||||||
|
} |
||||||
|
foreach ([$needle, self::replaceTypographicQuotesForSearch($needle)] as $nTry) { |
||||||
|
if ($nTry === '') { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$p = \mb_strpos($qR, $nTry, 0, 'UTF-8'); |
||||||
|
if (false !== $p) { |
||||||
|
$len = \mb_strlen($nTry, 'UTF-8'); |
||||||
|
|
||||||
|
return [$p, $p + $len]; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
$hS = self::stringForSearch($q); |
||||||
|
foreach (self::highlightContentSearchVariants($content) as $needle) { |
||||||
|
$needle = self::normalizeLineEndingsForHighlight($needle); |
||||||
|
if ($needle === '') { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$nS = self::stringForSearch($needle); |
||||||
|
if ($nS === '') { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$pN = \mb_strpos($hS, $nS, 0, 'UTF-8'); |
||||||
|
if (false === $pN) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$nEnd = $pN + \mb_strlen($nS, 'UTF-8'); |
||||||
|
[$a, $b] = self::mapSearchStringRangeToOrigStringRange($q, $pN, $nEnd); |
||||||
|
if ($b > $a) { |
||||||
|
return [$a, $b]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @return list<string> |
||||||
|
*/ |
||||||
|
public static function highlightContentSearchVariants(string $content): array |
||||||
|
{ |
||||||
|
if ($content === '') { |
||||||
|
return []; |
||||||
|
} |
||||||
|
$candidates = [ |
||||||
|
$content, |
||||||
|
self::replaceTypographicQuotesForSearch($content), |
||||||
|
]; |
||||||
|
$t = \trim($content); |
||||||
|
if ($t !== '' && $t !== $content) { |
||||||
|
$candidates[] = $t; |
||||||
|
} |
||||||
|
if (\class_exists(\Normalizer::class)) { |
||||||
|
$c = \Normalizer::normalize($content, \Normalizer::FORM_C); |
||||||
|
if (\is_string($c) && $c !== '' && $c !== $content) { |
||||||
|
$candidates[] = $c; |
||||||
|
} |
||||||
|
} |
||||||
|
$out = []; |
||||||
|
$seen = []; |
||||||
|
foreach ($candidates as $n) { |
||||||
|
if ($n === '' || isset($seen[$n])) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$seen[$n] = true; |
||||||
|
$out[] = $n; |
||||||
|
} |
||||||
|
|
||||||
|
return $out; |
||||||
|
} |
||||||
|
|
||||||
|
private static function replaceTypographicQuotesForSearch(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" => '-', |
||||||
|
]); |
||||||
|
} |
||||||
|
|
||||||
|
private static function normalizeLineEndingsForHighlight(string $s): string |
||||||
|
{ |
||||||
|
return \str_replace("\r\n", "\n", \str_replace("\r", "\n", $s)); |
||||||
|
} |
||||||
|
|
||||||
|
private static function searchCharacterNormalized(string $ch): string |
||||||
|
{ |
||||||
|
if ($ch === "\n" || $ch === "\r" || $ch === "\f" || $ch === "\v") { |
||||||
|
return ''; |
||||||
|
} |
||||||
|
if ($ch === "\xC2\x85") { // U+0085 (NEL) |
||||||
|
return ''; |
||||||
|
} |
||||||
|
if ($ch === "\xE2\x80\xA8" || $ch === "\xE2\x80\xA9") { // U+2028 LINE, U+2029 PARA separator |
||||||
|
return ''; |
||||||
|
} |
||||||
|
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 ' '; |
||||||
|
} |
||||||
|
// CommonMark SmartPunct / e-book typography → match Nostr `content` with ASCII punctuation |
||||||
|
if ($ch === "\xE2\x80\x99" || $ch === "\xE2\x80\x98") { // U+2019, U+2018 |
||||||
|
return "'"; |
||||||
|
} |
||||||
|
if ($ch === "\xE2\x80\x9C" || $ch === "\xE2\x80\x9D") { // U+201C, U+201D |
||||||
|
return '"'; |
||||||
|
} |
||||||
|
if ($ch === "\xE2\x80\x93" || $ch === "\xE2\x80\x94") { // en dash, em dash |
||||||
|
return '-'; |
||||||
|
} |
||||||
|
if ($ch === "\xE2\x80\xA6") { // U+2026 HORIZONTAL ELLIPSIS |
||||||
|
return '...'; |
||||||
|
} |
||||||
|
|
||||||
|
return $ch; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param string $contextQuote Passage: `context` tag, or the same as `$contentField` when there |
||||||
|
* is no `context` (caller should use {@see fullPassageForHighlightDisplay}). |
||||||
|
* @param string $contentField The event’s `content` (highlighted substring of the passage). |
||||||
|
* |
||||||
|
* @return string safe HTML |
||||||
|
*/ |
||||||
|
public static function buildHighlightedBodyHtml(string $contextQuote, string $contentField): string |
||||||
|
{ |
||||||
|
$q = \trim(self::normalizeLineEndingsForHighlight((string) $contextQuote)); |
||||||
|
$hi = \trim(self::normalizeLineEndingsForHighlight((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); |
||||||
|
} |
||||||
|
if ($q === $hi) { |
||||||
|
return '<mark class="'.self::HIGHLIGHT_MARK_CLASS.'">'.self::escapeWithNl2br($q).'</mark>'; |
||||||
|
} |
||||||
|
$span = self::findContentSpanInContext($q, $hi); |
||||||
|
if (null !== $span) { |
||||||
|
[$start, $end] = $span; |
||||||
|
$before = \mb_substr($q, 0, $start, 'UTF-8'); |
||||||
|
$match = \mb_substr($q, $start, $end - $start, 'UTF-8'); |
||||||
|
$after = \mb_substr($q, $end, null, 'UTF-8'); |
||||||
|
|
||||||
|
return self::escapeWithNl2br($before).self::markHtml($match).self::escapeWithNl2br($after); |
||||||
|
} |
||||||
|
|
||||||
|
// Substring not found after normalization / variants: show the full context quote, then the highlight so the card is not empty. |
||||||
|
return self::escapeWithNl2br($q).'<p class="user-highlight__marker-orphan">'.self::markHtml($hi).'</p>'; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* For narrow list layouts (e.g. home aside with {@see buildHighlightedBodyHtml} + line-clamp): if the |
||||||
|
* `content` is not at the start of the passage, drop the text before the highlight so the |
||||||
|
* clamped block begins at (or a few characters before) the mark and the user actually sees |
||||||
|
* the highlight. |
||||||
|
* |
||||||
|
* @param int $includeCharsOfContextBeforeHighlight Extra characters to keep before the |
||||||
|
* highlight (0 = passage starts with `content`) |
||||||
|
*/ |
||||||
|
public static function buildHighlightedBodyHtmlForNarrowList( |
||||||
|
string $contextQuote, |
||||||
|
string $contentField, |
||||||
|
int $includeCharsOfContextBeforeHighlight = 0, |
||||||
|
): string { |
||||||
|
$q = \trim(self::normalizeLineEndingsForHighlight((string) $contextQuote)); |
||||||
|
$hi = \trim(self::normalizeLineEndingsForHighlight((string) $contentField)); |
||||||
|
if ($q === '' && $hi === '') { |
||||||
|
return ''; |
||||||
|
} |
||||||
|
if ($q === '' || $hi === '') { |
||||||
|
return self::buildHighlightedBodyHtml($q, $hi); |
||||||
|
} |
||||||
|
if ($q === $hi) { |
||||||
|
return self::buildHighlightedBodyHtml($q, $hi); |
||||||
|
} |
||||||
|
$span = self::findContentSpanInContext($q, $hi); |
||||||
|
if (null === $span) { |
||||||
|
return self::buildHighlightedBodyHtml($q, $hi); |
||||||
|
} |
||||||
|
[$st] = $span; |
||||||
|
if (0 === $st) { |
||||||
|
return self::buildHighlightedBodyHtml($q, $hi); |
||||||
|
} |
||||||
|
$lead = \max(0, $includeCharsOfContextBeforeHighlight); |
||||||
|
$offset = \max(0, $st - $lead); |
||||||
|
if (0 === $offset) { |
||||||
|
return self::buildHighlightedBodyHtml($q, $hi); |
||||||
|
} |
||||||
|
$q2 = \mb_substr($q, $offset, null, 'UTF-8'); |
||||||
|
if ($q2 === '') { |
||||||
|
return self::buildHighlightedBodyHtml($q, $hi); |
||||||
|
} |
||||||
|
$html = self::buildHighlightedBodyHtml($q2, $hi); |
||||||
|
|
||||||
|
return self::omittedTextPrefixHtml().$html; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Safe “earlier text omitted” marker before a truncated passage in list cards. |
||||||
|
*/ |
||||||
|
public static function omittedTextPrefixHtml(): string |
||||||
|
{ |
||||||
|
return '<span class="user-highlight__elide" aria-hidden="true">…</span> '; |
||||||
|
} |
||||||
|
|
||||||
|
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 |
||||||
|
{ |
||||||
|
return self::markHighlightSpanHtml($innerText); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Single highlighted span (inner text escaped). Used in cards and by {@see buildHighlightedBodyHtml}. |
||||||
|
*/ |
||||||
|
public static function markHighlightSpanHtml(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) { |
||||||
|
$row = self::nostrTagRowToList($t); |
||||||
|
if (null === $row || \count($row) < 2) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
if (self::normalizeNostrTagKey($row[0]) !== 'textquoteselector') { |
||||||
|
continue; |
||||||
|
} |
||||||
|
for ($i = 1, $c = \count($row); $i < $c; ++$i) { |
||||||
|
$p = \trim($row[$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 |
||||||
|
{ |
||||||
|
$raw = (string) $content; |
||||||
|
if ($raw !== '') { |
||||||
|
$c = self::trimNostrText($raw); |
||||||
|
if ($c !== '') { |
||||||
|
return \mb_substr($c, 0, 400); |
||||||
|
} |
||||||
|
} |
||||||
|
$ctx = self::trimNostrText(self::contextFromTags($tags)); |
||||||
|
if ($ctx !== '') { |
||||||
|
return \mb_substr($ctx, 0, 400); |
||||||
|
} |
||||||
|
$tq = self::trimNostrText(self::excerptFromTextquoteselectorTags($tags)); |
||||||
|
|
||||||
|
return $tq !== '' ? $tq : ''; |
||||||
|
} |
||||||
|
} |
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue