Browse Source

don't publish all events in the magazine tree, when only one changed.

imwald
Silberengel 1 month ago
parent
commit
864c75940b
  1. 119
      assets/controllers/magazine_hierarchy_editor_controller.js
  2. 2
      config/unfold.yaml
  3. 78
      src/Service/MagazineHierarchyPublishService.php
  4. 2
      templates/pages/magazine_edit.html.twig

119
assets/controllers/magazine_hierarchy_editor_controller.js

@ -6,8 +6,7 @@ const KIND_LONGFORM_DRAFT = 30024;
/** /**
* Owner-only magazine hierarchy: build kind-30040 tags from fieldsets, NIP-07 sign each, POST batch. * Owner-only magazine hierarchy: build kind-30040 tags from fieldsets, NIP-07 sign each, POST batch.
* Only signs nodes that differ from the page load snapshot, plus any other index required for * Only signs nodes that differ from the page load snapshot (unchanged indices are not re-signed).
* graph closure (root + every nested kind-30040 `a` reachable from those events).
*/ */
const DTAG_PATTERN = /^[a-zA-Z0-9_-]+$/; const DTAG_PATTERN = /^[a-zA-Z0-9_-]+$/;
@ -680,34 +679,16 @@ export default class MagazineHierarchyEditorController extends Controller {
return; return;
} }
const dirty = nodes.filter((el) => this.isNodeDirty(el)); const ordered = nodes.filter((el) => this.isNodeDirty(el));
if (dirty.length === 0) {
this.setStatus('No changes to publish.');
return;
}
let publishSet;
try {
publishSet = await this.expandPublishSet(rootD, dirty, ownerHex);
} catch (err) {
this.setStatus(err instanceof Error ? err.message : String(err));
return;
}
const ordered = nodes.filter((el) => publishSet.has(readDTag(el)));
if (ordered.length === 0) { if (ordered.length === 0) {
this.setStatus('Nothing to publish.'); this.setStatus('No changes to publish.');
return; return;
} }
const baseTime = Math.floor(Date.now() / 1000); const baseTime = Math.floor(Date.now() / 1000);
const signedEvents = []; const signedEvents = [];
this.setStatus( this.setStatus(ordered.length === 1 ? 'Signing…' : `Signing ${ordered.length} changed index event(s)…`);
ordered.length === dirty.length
? 'Signing…'
: `Signing ${ordered.length} index event(s) (${dirty.length} edited, ${ordered.length - dirty.length} required for nested links)…`,
);
for (let i = 0; i < ordered.length; i++) { for (let i = 0; i < ordered.length; i++) {
const el = ordered[i]; const el = ordered[i];
@ -778,14 +759,19 @@ export default class MagazineHierarchyEditorController extends Controller {
return; return;
} }
const n = Number(data.published); const n = Number(data.published);
const ingested = Number(data.longform_ingest_addresses);
for (const el of ordered) { for (const el of ordered) {
if (el.dataset.isNewNode === '1') { if (el.dataset.isNewNode === '1') {
this.finalizeNewNodeFieldset(el); this.finalizeNewNodeFieldset(el);
} }
this.nodeBaseline.set(el, snapshotFromElement(el)); this.nodeBaseline.set(el, snapshotFromElement(el));
} }
if (Number.isFinite(n) && Number.isFinite(ingested) && ingested > 0) {
this.setStatus(`Published and stored ${n} index event(s); synced ${ingested} long-form address(es) from relays.`);
} else {
this.setStatus(Number.isFinite(n) ? `Published and stored ${n} index event(s).` : 'Published.'); this.setStatus(Number.isFinite(n) ? `Published and stored ${n} index event(s).` : 'Published.');
} }
}
isNodeDirty(el) { isNodeDirty(el) {
if (el.dataset.isNewNode === '1') { if (el.dataset.isNewNode === '1') {
@ -806,60 +792,6 @@ export default class MagazineHierarchyEditorController extends Controller {
); );
} }
/**
* Minimal set of #d tags that must appear in the POST body: dirty nodes, the root, and every
* kind-30040 child referenced (transitively) from those events' `a` tags matches server
* {@see MagazineHierarchyPublishService::validateGraphClosure}.
*
* @param {HTMLElement} dirtyFieldsets
* @returns {Promise<Set<string>>}
*/
async expandPublishSet(rootD, dirtyFieldsets, ownerHex) {
/** @type {Map<string, HTMLElement>} */
const dToEl = new Map();
if (this.hasNodesTarget) {
for (const el of queryEditorNodeFieldsets(this.nodesTarget)) {
const d = readDTag(el);
if (d) {
dToEl.set(d, el);
}
}
}
const W = new Set();
for (const el of dirtyFieldsets) {
const d = readDTag(el);
if (d) {
W.add(d);
}
}
W.add(rootD);
let growing = true;
let guard = 0;
while (growing && guard < 256) {
guard += 1;
growing = false;
const snapshot = [...W];
for (const d of snapshot) {
const el = dToEl.get(d);
if (!el) {
continue;
}
const { dTag, title, summary, content, aText, preserved } = readFieldsForBuild(el);
const tags = await this.buildTags(dTag, preserved, title, summary, aText, ownerHex);
for (const childD of ownedNested30040DsFromTags(tags, ownerHex)) {
if (!W.has(childD)) {
W.add(childD);
growing = true;
}
}
}
}
return W;
}
/** /**
* @param {string} dTag * @param {string} dTag
* @param {unknown[]} preserved * @param {unknown[]} preserved
@ -1020,39 +952,6 @@ function rewrite30040ChildLineInList(list, ownerHex, oldD, newD) {
return false; return false;
} }
/**
* @param {string[][]} tags
* @param {string} ownerHex
* @returns {Set<string>}
*/
function ownedNested30040DsFromTags(tags, ownerHex) {
const out = new Set();
const oh = ownerHex.toLowerCase();
for (const row of tags) {
if (row.length < 2 || String(row[0]).toLowerCase() !== 'a') {
continue;
}
const coord = String(row[1]).trim();
const parts = splitThree(coord);
if (!parts) {
continue;
}
const kind = parseInt(parts.kind, 10);
if (kind !== KIND_PUBLICATION_INDEX) {
continue;
}
const pk = parts.pubkey.toLowerCase();
if (pk !== oh) {
continue;
}
const id = parts.identifier.trim();
if (id !== '') {
out.add(id);
}
}
return out;
}
/** /**
* @param {HTMLElement} el * @param {HTMLElement} el
*/ */

2
config/unfold.yaml

@ -23,7 +23,7 @@ parameters:
'wss://nostr21.com', 'wss://nostr21.com',
'wss://nostr.sovbit.host', 'wss://nostr.sovbit.host',
'wss://orly-relay.imwald.eu', 'wss://orly-relay.imwald.eu',
'wss://nostr.einundzwei.space' 'wss://nostr.einundzwanzig.space'
] ]
# Kind-0 / profile fetches (author metadata, prewarm). Tried first, then default + article_relays (deduped). # Kind-0 / profile fetches (author metadata, prewarm). Tried first, then default + article_relays (deduped).
# Also used as a second pass for kind 30040 (magazine category indices) and category long-form ingest # Also used as a second pass for kind 30040 (magazine category indices) and category long-form ingest

78
src/Service/MagazineHierarchyPublishService.php

@ -13,7 +13,8 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
/** /**
* Validates NIP-07–signed kind-30040 magazine batches from the site owner, publishes to article relays, * Validates NIP-07–signed kind-30040 magazine batches from the site owner, publishes to article relays,
* and updates {@see MagazineIndexStore}. * updates {@see MagazineIndexStore}, then ingests listed long-form coordinates into MySQL so the UI
* can resolve new `a` tags without waiting for cron {@see NostrClient::ingestLongformForCategoryCoordinates}.
*/ */
final class MagazineHierarchyPublishService final class MagazineHierarchyPublishService
{ {
@ -32,7 +33,7 @@ final class MagazineHierarchyPublishService
/** /**
* @param array<int, mixed> $rawEvents Decoded JSON event objects * @param array<int, mixed> $rawEvents Decoded JSON event objects
* *
* @return array{ok: true, published: int, stored: int}|array{ok: false, error: string, code: int} * @return array{ok: true, published: int, stored: int, longform_ingest_addresses: int}|array{ok: false, error: string, code: int}
*/ */
public function publishOwnerMagazineBatch(User $user, array $rawEvents): array public function publishOwnerMagazineBatch(User $user, array $rawEvents): array
{ {
@ -93,11 +94,6 @@ final class MagazineHierarchyPublishService
$byD[$d] = $wire; $byD[$d] = $wire;
} }
$graphErr = $this->validateGraphClosure($byD, $rootD);
if ($graphErr !== null) {
return ['ok' => false, 'error' => $graphErr, 'code' => 400];
}
$relays = $this->nostrClient->getArticleWriteRelayUrls(); $relays = $this->nostrClient->getArticleWriteRelayUrls();
if ($relays === []) { if ($relays === []) {
return ['ok' => false, 'error' => 'No write relays configured.', 'code' => 500]; return ['ok' => false, 'error' => 'No write relays configured.', 'code' => 500];
@ -137,12 +133,29 @@ final class MagazineHierarchyPublishService
++$stored; ++$stored;
} }
$longformAddresses = $this->collectLongformCoordinatesFromBatch($byD);
if ($longformAddresses !== []) {
try {
$this->nostrClient->ingestLongformForCategoryCoordinates($longformAddresses);
} catch (\Throwable $e) {
$this->logger->warning('magazine_hierarchy.longform_ingest_after_publish_failed', [
'message' => $e->getMessage(),
]);
}
}
$this->logger->info('magazine_hierarchy.published', [ $this->logger->info('magazine_hierarchy.published', [
'published' => $published, 'published' => $published,
'stored' => $stored, 'stored' => $stored,
'longform_ingest_addresses' => \count($longformAddresses),
]); ]);
return ['ok' => true, 'published' => $published, 'stored' => $stored]; return [
'ok' => true,
'published' => $published,
'stored' => $stored,
'longform_ingest_addresses' => \count($longformAddresses),
];
} }
/** /**
@ -185,24 +198,17 @@ final class MagazineHierarchyPublishService
} }
/** /**
* Long-form `a` coordinates from this publish batch (30023 / 30024) for immediate DB sync.
*
* @param array<string, NostrWireEvent> $byD * @param array<string, NostrWireEvent> $byD
*
* @return list<string>
*/ */
private function validateGraphClosure(array $byD, string $rootD): ?string private function collectLongformCoordinatesFromBatch(array $byD): array
{ {
if (!isset($byD[$rootD])) { $out = [];
return 'Batch must include the magazine root index (#d '.$rootD.').'; foreach ($byD as $wire) {
} foreach ($wire->getTags() as $row) {
$queue = [$rootD];
$visited = [];
while ($queue !== []) {
$d = array_shift($queue);
if ($d === '' || isset($visited[$d])) {
continue;
}
$visited[$d] = true;
$ev = $byD[$d];
foreach ($ev->getTags() as $row) {
if (!NostrEventTags::tagNameMatches($row, 'a')) { if (!NostrEventTags::tagNameMatches($row, 'a')) {
continue; continue;
} }
@ -213,32 +219,22 @@ final class MagazineHierarchyPublishService
$coord = trim((string) $seq[1]); $coord = trim((string) $seq[1]);
$parts = explode(':', $coord, 3); $parts = explode(':', $coord, 3);
if (\count($parts) < 3) { if (\count($parts) < 3) {
return 'Malformed nested coordinate under #d '.$d; continue;
} }
$kind = (int) $parts[0]; $kind = (int) $parts[0];
if ($kind !== KindsEnum::PUBLICATION_INDEX->value) { if (!\in_array($kind, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) {
continue; continue;
} }
$childD = trim((string) $parts[2]); $pk = strtolower(trim((string) $parts[1]));
if ($childD === '') { $id = trim((string) $parts[2]);
return 'Empty nested magazine #d under #d '.$d; if ($id === '' || 64 !== \strlen($pk) || !ctype_xdigit($pk)) {
} continue;
if (!isset($byD[$childD])) {
return 'Every nested kind-30040 `a` tag must have a matching event in this batch (missing #d '.$childD.' referenced from '.$d.').';
}
if (!isset($visited[$childD])) {
$queue[] = $childD;
}
}
} }
$out[] = $kind.':'.$pk.':'.$id;
foreach (array_keys($byD) as $d) {
if (!isset($visited[$d])) {
return 'Event #d '.$d.' is not reachable from the magazine root via kind-30040 links.';
} }
} }
return null; return array_values(array_unique($out));
} }
/** /**

2
templates/pages/magazine_edit.html.twig

@ -67,7 +67,7 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<p class="magazine-editor__intro"> <p class="magazine-editor__intro">
Edit kind <strong>30040</strong> indices (root, categories, subcategories), then sign with your Nostr extension. Use <strong>Add top-level category</strong> or <strong>Add subcategory</strong> to create new indices; the parent index’s <code>a</code> list is updated automatically so the tree stays linked. Only indices you changed are signed; any other index required so nested <code>a</code> links stay consistent is still included in the same batch (same rules as before). Edit kind <strong>30040</strong> indices (root, categories, subcategories), then sign with your Nostr extension. Use <strong>Add top-level category</strong> or <strong>Add subcategory</strong> to create new indices; the parent index’s <code>a</code> list is updated automatically so the tree stays linked. <strong>Publish</strong> signs only the indices you actually changed; listed long-form posts are fetched into the site database right after a successful publish so category pages can show new <code>a</code> tags without waiting for the prewarm cron.
</p> </p>
<div <div

Loading…
Cancel
Save