From 864c75940b1402d305979daae7b3ac0b047dfacc Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 5 May 2026 06:41:36 +0200 Subject: [PATCH] don't publish all events in the magazine tree, when only one changed. --- .../magazine_hierarchy_editor_controller.js | 121 ++---------------- config/unfold.yaml | 2 +- .../MagazineHierarchyPublishService.php | 78 ++++++----- templates/pages/magazine_edit.html.twig | 2 +- 4 files changed, 49 insertions(+), 154 deletions(-) diff --git a/assets/controllers/magazine_hierarchy_editor_controller.js b/assets/controllers/magazine_hierarchy_editor_controller.js index 3e86f5d..2591963 100644 --- a/assets/controllers/magazine_hierarchy_editor_controller.js +++ b/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. - * Only signs nodes that differ from the page load snapshot, plus any other index required for - * graph closure (root + every nested kind-30040 `a` reachable from those events). + * Only signs nodes that differ from the page load snapshot (unchanged indices are not re-signed). */ const DTAG_PATTERN = /^[a-zA-Z0-9_-]+$/; @@ -680,34 +679,16 @@ export default class MagazineHierarchyEditorController extends Controller { return; } - const dirty = 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))); + const ordered = nodes.filter((el) => this.isNodeDirty(el)); if (ordered.length === 0) { - this.setStatus('Nothing to publish.'); + this.setStatus('No changes to publish.'); return; } const baseTime = Math.floor(Date.now() / 1000); const signedEvents = []; - this.setStatus( - ordered.length === dirty.length - ? 'Signing…' - : `Signing ${ordered.length} index event(s) (${dirty.length} edited, ${ordered.length - dirty.length} required for nested links)…`, - ); + this.setStatus(ordered.length === 1 ? 'Signing…' : `Signing ${ordered.length} changed index event(s)…`); for (let i = 0; i < ordered.length; i++) { const el = ordered[i]; @@ -778,13 +759,18 @@ export default class MagazineHierarchyEditorController extends Controller { return; } const n = Number(data.published); + const ingested = Number(data.longform_ingest_addresses); for (const el of ordered) { if (el.dataset.isNewNode === '1') { this.finalizeNewNodeFieldset(el); } this.nodeBaseline.set(el, snapshotFromElement(el)); } - this.setStatus(Number.isFinite(n) ? `Published and stored ${n} index event(s).` : 'Published.'); + 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.'); + } } isNodeDirty(el) { @@ -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>} - */ - async expandPublishSet(rootD, dirtyFieldsets, ownerHex) { - /** @type {Map} */ - 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 {unknown[]} preserved @@ -1020,39 +952,6 @@ function rewrite30040ChildLineInList(list, ownerHex, oldD, newD) { return false; } -/** - * @param {string[][]} tags - * @param {string} ownerHex - * @returns {Set} - */ -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 */ diff --git a/config/unfold.yaml b/config/unfold.yaml index b1f2451..5e85d75 100644 --- a/config/unfold.yaml +++ b/config/unfold.yaml @@ -23,7 +23,7 @@ parameters: 'wss://nostr21.com', 'wss://nostr.sovbit.host', '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). # Also used as a second pass for kind 30040 (magazine category indices) and category long-form ingest diff --git a/src/Service/MagazineHierarchyPublishService.php b/src/Service/MagazineHierarchyPublishService.php index a5ee860..89592d9 100644 --- a/src/Service/MagazineHierarchyPublishService.php +++ b/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, - * 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 { @@ -32,7 +33,7 @@ final class MagazineHierarchyPublishService /** * @param array $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 { @@ -93,11 +94,6 @@ final class MagazineHierarchyPublishService $byD[$d] = $wire; } - $graphErr = $this->validateGraphClosure($byD, $rootD); - if ($graphErr !== null) { - return ['ok' => false, 'error' => $graphErr, 'code' => 400]; - } - $relays = $this->nostrClient->getArticleWriteRelayUrls(); if ($relays === []) { return ['ok' => false, 'error' => 'No write relays configured.', 'code' => 500]; @@ -137,12 +133,29 @@ final class MagazineHierarchyPublishService ++$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', [ 'published' => $published, '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 $byD + * + * @return list */ - private function validateGraphClosure(array $byD, string $rootD): ?string + private function collectLongformCoordinatesFromBatch(array $byD): array { - if (!isset($byD[$rootD])) { - return 'Batch must include the magazine root index (#d '.$rootD.').'; - } - - $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) { + $out = []; + foreach ($byD as $wire) { + foreach ($wire->getTags() as $row) { if (!NostrEventTags::tagNameMatches($row, 'a')) { continue; } @@ -213,32 +219,22 @@ final class MagazineHierarchyPublishService $coord = trim((string) $seq[1]); $parts = explode(':', $coord, 3); if (\count($parts) < 3) { - return 'Malformed nested coordinate under #d '.$d; + continue; } $kind = (int) $parts[0]; - if ($kind !== KindsEnum::PUBLICATION_INDEX->value) { + if (!\in_array($kind, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) { continue; } - $childD = trim((string) $parts[2]); - if ($childD === '') { - return 'Empty nested magazine #d under #d '.$d; - } - 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; + $pk = strtolower(trim((string) $parts[1])); + $id = trim((string) $parts[2]); + if ($id === '' || 64 !== \strlen($pk) || !ctype_xdigit($pk)) { + continue; } + $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)); } /** diff --git a/templates/pages/magazine_edit.html.twig b/templates/pages/magazine_edit.html.twig index c26b1c8..f5e8e21 100644 --- a/templates/pages/magazine_edit.html.twig +++ b/templates/pages/magazine_edit.html.twig @@ -67,7 +67,7 @@

- Edit kind 30040 indices (root, categories, subcategories), then sign with your Nostr extension. Use Add top-level category or Add subcategory to create new indices; the parent index’s a list is updated automatically so the tree stays linked. Only indices you changed are signed; any other index required so nested a links stay consistent is still included in the same batch (same rules as before). + Edit kind 30040 indices (root, categories, subcategories), then sign with your Nostr extension. Use Add top-level category or Add subcategory to create new indices; the parent index’s a list is updated automatically so the tree stays linked. Publish 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 a tags without waiting for the prewarm cron.