Compare commits
No commits in common. '111cb0df9cca4da4729e8e89c5b4a97ce05f3c3d' and 'f969bc4a57a765016344f6a337763e6b3afc8157' have entirely different histories.
111cb0df9c
...
f969bc4a57
136 changed files with 4080 additions and 11639 deletions
@ -1,122 +1,74 @@ |
|||||||
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'] |
||||||
|
|
||||||
connect() { |
async connect() { |
||||||
if (this.typeValue === 'naddr' || this.typeValue === 'nevent') { |
await this.fetchPreview(); |
||||||
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) { |
||||||
const res = await fetch('/og-preview/', { |
// Fetch OG preview for plain URLs
|
||||||
method: 'POST', |
fetch("/og-preview/", { |
||||||
headers: { 'Content-Type': 'application/json' }, |
method: "POST", |
||||||
body: JSON.stringify({ url: this.fullMatchValue }), |
headers: { |
||||||
|
"Content-Type": "application/json" |
||||||
|
}, |
||||||
|
body: JSON.stringify({ url: this.fullMatchValue }) |
||||||
|
}) |
||||||
|
.then(res => { |
||||||
|
if (!res.ok) { |
||||||
|
throw new Error(`HTTP error! status: ${res.status}`); |
||||||
|
} |
||||||
|
return res.text(); |
||||||
|
}) |
||||||
|
.then(data => { |
||||||
|
this.containerTarget.innerHTML = data; |
||||||
|
}) |
||||||
|
.catch(error => { |
||||||
|
console.error("Error:", error); |
||||||
|
this.containerTarget.innerHTML = `<div class="alert alert-warning">Unable to load OG preview for ${this.fullMatchValue}</div>`; |
||||||
}); |
}); |
||||||
if (!res.ok) { |
} else { |
||||||
throw new Error(`HTTP ${res.status}`); |
// Fallback to Nostr preview
|
||||||
} |
const data = { |
||||||
this.containerTarget.innerHTML = await res.text(); |
identifier: this.identifierValue, |
||||||
return; |
type: this.typeValue, |
||||||
} |
decoded: this.decodedValue |
||||||
const res = await fetch('/preview/', { |
}; |
||||||
method: 'POST', |
fetch("/preview/", { |
||||||
headers: { 'Content-Type': 'application/json' }, |
method: "POST", |
||||||
body: JSON.stringify({ |
headers: { |
||||||
identifier: this.identifierValue, |
"Content-Type": "application/json" |
||||||
type: this.typeValue, |
}, |
||||||
decoded: this.decodedValue, |
body: JSON.stringify(data) |
||||||
}), |
}) |
||||||
}); |
.then(res => { |
||||||
if (!res.ok) { |
if (!res.ok) { |
||||||
throw new Error(`HTTP ${res.status}`); |
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 = await res.text(); |
} catch (error) { |
||||||
} catch (e) { |
console.error('Error fetching Nostr preview:', error); |
||||||
// NetworkError / offline: avoid console.error noise; one inline fallback per block
|
this.containerTarget.innerHTML = `<div class="alert alert-warning">Unable to load preview for ${this.fullMatchValue}</div>`; |
||||||
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; |
|
||||||
} |
} |
||||||
} |
} |
||||||
} |
} |
||||||
|
|||||||
@ -1,279 +0,0 @@ |
|||||||
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`; |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,11 +0,0 @@ |
|||||||
# Enable stateless CSRF protection for forms and logins/logouts |
|
||||||
framework: |
|
||||||
form: |
|
||||||
csrf_protection: |
|
||||||
token_id: submit |
|
||||||
|
|
||||||
csrf_protection: |
|
||||||
stateless_token_ids: |
|
||||||
- submit |
|
||||||
- authenticate |
|
||||||
- logout |
|
||||||
@ -1,11 +0,0 @@ |
|||||||
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 |
|
||||||
@ -1,3 +0,0 @@ |
|||||||
framework: |
|
||||||
property_info: |
|
||||||
with_constructor_extractor: true |
|
||||||
@ -1,31 +0,0 @@ |
|||||||
<?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'); |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,22 +0,0 @@ |
|||||||
--- 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
|
|
||||||
@ -1,673 +0,0 @@ |
|||||||
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 |
|
||||||
@ -1,17 +0,0 @@ |
|||||||
# 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 |
|
||||||
@ -1,160 +0,0 @@ |
|||||||
<?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; |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,49 +0,0 @@ |
|||||||
<?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, |
|
||||||
], |
|
||||||
]); |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,163 +0,0 @@ |
|||||||
<?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 |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,120 +0,0 @@ |
|||||||
<?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); |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,88 +0,0 @@ |
|||||||
<?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; |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,712 +0,0 @@ |
|||||||
<?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; |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,86 +0,0 @@ |
|||||||
<?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, |
|
||||||
]; |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,13 +0,0 @@ |
|||||||
<?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; |
|
||||||
} |
|
||||||
@ -1,106 +0,0 @@ |
|||||||
<?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; |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,169 +0,0 @@ |
|||||||
<?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; |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,89 +0,0 @@ |
|||||||
<?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); |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,41 +0,0 @@ |
|||||||
<?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(); |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,54 +0,0 @@ |
|||||||
<?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, |
|
||||||
]; |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,116 +0,0 @@ |
|||||||
<?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(); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,36 +0,0 @@ |
|||||||
<?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'); |
|
||||||
})); |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,230 +0,0 @@ |
|||||||
<?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, |
|
||||||
]); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,317 +0,0 @@ |
|||||||
<?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; |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,165 +0,0 @@ |
|||||||
<?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; |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,49 +0,0 @@ |
|||||||
<?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); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,450 +0,0 @@ |
|||||||
<?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 ''; |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,105 +0,0 @@ |
|||||||
<?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)); |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,96 +0,0 @@ |
|||||||
<?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); |
|
||||||
} |
|
||||||
} |
|
||||||
@ -0,0 +1,127 @@ |
|||||||
|
<?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'); |
||||||
|
} |
||||||
|
} |
||||||
@ -1,27 +0,0 @@ |
|||||||
<?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); |
|
||||||
}), |
|
||||||
]; |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,26 +0,0 @@ |
|||||||
<?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); |
|
||||||
}), |
|
||||||
]; |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,546 +0,0 @@ |
|||||||
<?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