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 @@
@@ -1,122 +1,74 @@
|
||||
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 { |
||||
static values = { |
||||
identifier: String, |
||||
type: String, |
||||
decoded: String, |
||||
fullMatch: String, |
||||
}; |
||||
fullMatch: String |
||||
} |
||||
|
||||
static targets = ['container']; |
||||
static targets = ['container'] |
||||
|
||||
connect() { |
||||
if (this.typeValue === 'naddr' || this.typeValue === 'nevent') { |
||||
if (isPreviewForSameArticleOnPage(this.element, this.typeValue, this.decodedValue)) { |
||||
this.element.setAttribute('hidden', ''); |
||||
this.element.setAttribute('data-nostr-preview-suppressed', 'same-page-article'); |
||||
return; |
||||
} |
||||
} |
||||
this.fetchPreview(); |
||||
async connect() { |
||||
await this.fetchPreview(); |
||||
} |
||||
|
||||
async fetchPreview() { |
||||
if (!this.hasContainerTarget) { |
||||
return; |
||||
} |
||||
this.containerTarget.innerHTML = LOADING_HTML; |
||||
try { |
||||
this.containerTarget.innerHTML = '<div class="nostr-preview__loading text-center my-2"><span class="nostr-preview__spinner" role="status" aria-label="Loading"></span><span class="nostr-preview__loading-text ms-2">Loading preview…</span></div>'; |
||||
if (this.typeValue === 'url' && this.fullMatchValue) { |
||||
const res = await fetch('/og-preview/', { |
||||
method: 'POST', |
||||
headers: { 'Content-Type': 'application/json' }, |
||||
body: JSON.stringify({ url: this.fullMatchValue }), |
||||
// Fetch OG preview for plain URLs
|
||||
fetch("/og-preview/", { |
||||
method: "POST", |
||||
headers: { |
||||
"Content-Type": "application/json" |
||||
}, |
||||
body: JSON.stringify({ url: this.fullMatchValue }) |
||||
}) |
||||
.then(res => { |
||||
if (!res.ok) { |
||||
throw new Error(`HTTP error! status: ${res.status}`); |
||||
} |
||||
return res.text(); |
||||
}) |
||||
.then(data => { |
||||
this.containerTarget.innerHTML = data; |
||||
}) |
||||
.catch(error => { |
||||
console.error("Error:", error); |
||||
this.containerTarget.innerHTML = `<div class="alert alert-warning">Unable to load OG preview for ${this.fullMatchValue}</div>`; |
||||
}); |
||||
if (!res.ok) { |
||||
throw new Error(`HTTP ${res.status}`); |
||||
} |
||||
this.containerTarget.innerHTML = await res.text(); |
||||
return; |
||||
} |
||||
const res = await fetch('/preview/', { |
||||
method: 'POST', |
||||
headers: { 'Content-Type': 'application/json' }, |
||||
body: JSON.stringify({ |
||||
identifier: this.identifierValue, |
||||
type: this.typeValue, |
||||
decoded: this.decodedValue, |
||||
}), |
||||
}); |
||||
if (!res.ok) { |
||||
throw new Error(`HTTP ${res.status}`); |
||||
} else { |
||||
// Fallback to Nostr preview
|
||||
const data = { |
||||
identifier: this.identifierValue, |
||||
type: this.typeValue, |
||||
decoded: this.decodedValue |
||||
}; |
||||
fetch("/preview/", { |
||||
method: "POST", |
||||
headers: { |
||||
"Content-Type": "application/json" |
||||
}, |
||||
body: JSON.stringify(data) |
||||
}) |
||||
.then(res => { |
||||
if (!res.ok) { |
||||
throw new Error(`HTTP error! status: ${res.status}`); |
||||
} |
||||
return res.text(); |
||||
}) |
||||
.then(data => { |
||||
this.containerTarget.innerHTML = data; |
||||
}) |
||||
.catch(error => { |
||||
console.error("Error:", error); |
||||
}); |
||||
} |
||||
this.containerTarget.innerHTML = await res.text(); |
||||
} catch (e) { |
||||
// NetworkError / offline: avoid console.error noise; one inline fallback per block
|
||||
console.debug('nostr_preview: fetch failed', e); |
||||
this.containerTarget.innerHTML = this.typeValue === 'url' && this.fullMatchValue |
||||
? `<div class="alert alert-warning my-2" role="status">Unable to load link preview for ${this.fullMatchValue}.</div>` |
||||
: UNAVAILABLE_HTML; |
||||
} catch (error) { |
||||
console.error('Error fetching Nostr preview:', error); |
||||
this.containerTarget.innerHTML = `<div class="alert alert-warning">Unable to load preview for ${this.fullMatchValue}</div>`; |
||||
} |
||||
} |
||||
} |
||||
|
||||
@ -1,279 +0,0 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -1,3 +0,0 @@
|
||||
framework: |
||||
property_info: |
||||
with_constructor_extractor: true |
||||
@ -1,31 +0,0 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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