From c60ba3ea61432c19b4f26da00353a2313db397ce Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 27 May 2026 12:18:08 +0200 Subject: [PATCH] Implement kind 30817 --- .../magazine_hierarchy_editor_controller.js | 10 ++++-- migrations/Version20260527110000.php | 26 ++++++++++++++++ src/Controller/ArticleController.php | 3 +- src/Entity/Article.php | 29 ++++++++++++++--- src/Enum/KindsEnum.php | 19 ++++++++++++ src/Factory/ArticleFactory.php | 31 +++++++++++++------ src/Service/ArticleCommentThreadLoader.php | 2 +- src/Service/CommentReplyService.php | 2 +- src/Service/MagazineContentService.php | 12 +++---- .../MagazineHierarchyPublishService.php | 13 +++----- src/Service/Nip09DeletionApplier.php | 16 ++++------ src/Service/NostrClient.php | 4 +-- src/Service/NostrKind5DeletionFilter.php | 6 ++-- src/Service/NostrLongformArticleStore.php | 1 + templates/pages/article.html.twig | 8 +++++ 15 files changed, 132 insertions(+), 50 deletions(-) create mode 100644 migrations/Version20260527110000.php diff --git a/assets/controllers/magazine_hierarchy_editor_controller.js b/assets/controllers/magazine_hierarchy_editor_controller.js index f1971c0..298d0c9 100644 --- a/assets/controllers/magazine_hierarchy_editor_controller.js +++ b/assets/controllers/magazine_hierarchy_editor_controller.js @@ -3,6 +3,10 @@ import { Controller } from '@hotwired/stimulus'; const KIND_PUBLICATION_INDEX = 30040; const KIND_LONGFORM = 30023; const KIND_LONGFORM_DRAFT = 30024; +const KIND_WIKI = 30817; + +/** All kinds allowed as article `a` tags inside a kind-30040 category index. */ +const LONGFORM_KINDS = new Set([KIND_LONGFORM, KIND_LONGFORM_DRAFT, KIND_WIKI]); /** * Owner-only magazine hierarchy: build kind-30040 tags from fieldsets, NIP-07 sign each, POST batch. @@ -795,7 +799,7 @@ export default class MagazineHierarchyEditorController extends Controller { } 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.`, + `Published and stored ${n} index event(s); synced ${ingested} article/wiki address(es) from relays.`, { tone: 'success', scroll: true }, ); } else { @@ -894,8 +898,8 @@ export default class MagazineHierarchyEditorController extends Controller { if (kind === KIND_PUBLICATION_INDEX && pk !== ownerHex) { throw new Error(`Nested 30040 address must use magazine owner pubkey: ${coord}`); } - if (kind !== KIND_PUBLICATION_INDEX && kind !== KIND_LONGFORM && kind !== KIND_LONGFORM_DRAFT) { - throw new Error(`Only kinds 30040, 30023, 30024 allowed in a tag: ${coord}`); + if (kind !== KIND_PUBLICATION_INDEX && !LONGFORM_KINDS.has(kind)) { + throw new Error(`Only kinds 30040, 30023, 30024, 30817 allowed in a tag: ${coord}`); } } diff --git a/migrations/Version20260527110000.php b/migrations/Version20260527110000.php new file mode 100644 index 0000000..5f622d4 --- /dev/null +++ b/migrations/Version20260527110000.php @@ -0,0 +1,26 @@ +addSql('ALTER TABLE article ADD wiki_kinds JSON DEFAULT NULL COMMENT \'NIP-54 wiki: k-tag kind values (null = not a wiki page)\''); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE article DROP COLUMN wiki_kinds'); + } +} diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 780e8fb..154d3fc 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -278,8 +278,7 @@ class ArticleController extends AbstractController $author = $data->pubkey; $kind = (int) $data->kind; - $allowedKinds = [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value]; - if (!\in_array($kind, $allowedKinds, true)) { + if (!\in_array($kind, KindsEnum::longformKindValues(), true)) { throw new \Exception('Not a long form article'); } diff --git a/src/Entity/Article.php b/src/Entity/Article.php index bf29da1..83e6fb4 100644 --- a/src/Entity/Article.php +++ b/src/Entity/Article.php @@ -9,10 +9,9 @@ use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; /** - * Entity storing long-form articles - * Needed beyond the Event entity, because of the local functionalities built on top of the original events - * - editor - * NIP-23, kinds 30023, 30024 + * Entity storing long-form articles and wiki pages. + * NIP-23 long-form: kinds 30023, 30024. + * NIP-54 wiki: kind 30817 (same Markdown format; adds `k` tags listing affected kinds). */ #[ORM\Entity(repositoryClass: ArticleRepository::class)] class Article @@ -64,6 +63,13 @@ class Article #[ORM\Column(nullable: true, enumType: EventStatusEnum::class)] private ?EventStatusEnum $eventStatus = EventStatusEnum::PREVIEW; + /** + * NIP-54 wiki: `k` tags listing the Nostr kinds this spec affects (e.g. [9740, 9741]). + * Null for non-wiki articles; empty array when none were listed. + */ + #[ORM\Column(type: Types::JSON, nullable: true)] + private ?array $wikiKinds = null; + // Local properties #[ORM\Column(type: Types::JSON, nullable: true)] private ?array $currentPlaces; @@ -308,6 +314,21 @@ class Article return $this; } + /** + * @return list|null null = not a wiki page; [] = wiki page with no `k` tags + */ + public function getWikiKinds(): ?array + { + return $this->wikiKinds; + } + + public function setWikiKinds(?array $wikiKinds): static + { + $this->wikiKinds = $wikiKinds; + + return $this; + } + public function isDraft() { return $this->eventStatus === EventStatusEnum::PREVIEW; diff --git a/src/Enum/KindsEnum.php b/src/Enum/KindsEnum.php index b99f3e6..3eb594a 100644 --- a/src/Enum/KindsEnum.php +++ b/src/Enum/KindsEnum.php @@ -17,7 +17,26 @@ enum KindsEnum: int case CURATION_SET = 30004; // NIP-51 case LONGFORM = 30023; // NIP-23 case LONGFORM_DRAFT = 30024; // NIP-23 + case WIKI = 30817; // NIP-54 wiki pages case PUBLICATION_INDEX = 30040; + + /** + * All kinds stored as long-form articles in the `article` table: 30023, 30024, 30817. + * + * @return list + */ + public static function longformKinds(): array + { + return [self::LONGFORM, self::LONGFORM_DRAFT, self::WIKI]; + } + + /** + * @return list + */ + public static function longformKindValues(): array + { + return [self::LONGFORM->value, self::LONGFORM_DRAFT->value, self::WIKI->value]; + } case ZAP = 9735; // NIP-57, Zaps case HIGHLIGHTS = 9802; case RELAY_LIST = 10002; // NIP-65, Relay list metadata diff --git a/src/Factory/ArticleFactory.php b/src/Factory/ArticleFactory.php index 0b41280..ebefc5e 100644 --- a/src/Factory/ArticleFactory.php +++ b/src/Factory/ArticleFactory.php @@ -8,14 +8,14 @@ use App\Enum\KindsEnum; use InvalidArgumentException; /** - * Map nostr events of kind 30023 to local article entity + * Map long-form (30023/30024) and wiki (30817) Nostr events to the Article entity. */ class ArticleFactory { public function createFromLongFormContentEvent($source): Article { - if ($source->kind !== KindsEnum::LONGFORM->value) { - throw new InvalidArgumentException('Source event kind should be 30023'); + if (!\in_array($source->kind, KindsEnum::longformKindValues(), true)) { + throw new InvalidArgumentException('Source event kind must be a longform kind (30023, 30024, 30817), got '.$source->kind); } $entity = new Article(); $entity->setRaw($source); @@ -33,19 +33,23 @@ class ArticleFactory $entity->setRatingNegative(0); $entity->setRatingPositive(0); // process tags + $wikiKinds = $source->kind === KindsEnum::WIKI->value ? [] : null; foreach ($source->tags as $tag) { + if (!\is_array($tag) || !isset($tag[0])) { + continue; + } switch ($tag[0]) { case 'd': - $entity->setSlug($tag[1]); + $entity->setSlug($tag[1] ?? null); break; case 'title': - $entity->setTitle($tag[1]); + $entity->setTitle($tag[1] ?? null); break; case 'summary': - $entity->setSummary($tag[1]); + $entity->setSummary($tag[1] ?? null); break; case 'image': - $entity->setImage($tag[1]); + $entity->setImage($tag[1] ?? null); break; case 'published_at': $parsed = $this->parseEventTimeValue($tag[1] ?? null); @@ -54,13 +58,22 @@ class ArticleFactory } break; case 't': - $entity->addTopic($tag[1]); + if (isset($tag[1])) { + $entity->addTopic($tag[1]); + } + break; + case 'k': + // NIP-54: `k` tags list the Nostr kinds this wiki page specifies + if ($wikiKinds !== null && isset($tag[1]) && ctype_digit((string) $tag[1])) { + $wikiKinds[] = (int) $tag[1]; + } break; case 'client': - // used to signal where it was created, ignored for now break; } } + $entity->setWikiKinds($wikiKinds); + return $entity; } diff --git a/src/Service/ArticleCommentThreadLoader.php b/src/Service/ArticleCommentThreadLoader.php index 5c33e76..36f8700 100644 --- a/src/Service/ArticleCommentThreadLoader.php +++ b/src/Service/ArticleCommentThreadLoader.php @@ -397,7 +397,7 @@ final readonly class ArticleCommentThreadLoader continue; } $kind = ctype_digit((string) $parts[0]) ? (int) $parts[0] : 0; - if (!\in_array($kind, [30023, 30024], true)) { + if (!\in_array($kind, KindsEnum::longformKindValues(), true)) { continue; } $dTag = trim((string) $parts[2]); diff --git a/src/Service/CommentReplyService.php b/src/Service/CommentReplyService.php index b32c9d2..bff9bea 100644 --- a/src/Service/CommentReplyService.php +++ b/src/Service/CommentReplyService.php @@ -205,7 +205,7 @@ final readonly class CommentReplyService int $parentKind, string $parentIdHex ): bool { - if (\in_array($parentKind, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) { + if (\in_array($parentKind, KindsEnum::longformKindValues(), true)) { foreach ($tags as $row) { if (!\is_array($row) || ($row[0] ?? null) === null) { continue; diff --git a/src/Service/MagazineContentService.php b/src/Service/MagazineContentService.php index b7b6994..32c7f51 100644 --- a/src/Service/MagazineContentService.php +++ b/src/Service/MagazineContentService.php @@ -447,7 +447,7 @@ final class MagazineContentService continue; } $kind = (int) ($parts[0] ?? 0); - if (!\in_array($kind, [30023, 30024], true)) { + if (!\in_array($kind, KindsEnum::longformKindValues(), true)) { $entries[] = ['coordinate' => $coordinate, 'status' => 'missing', 'reason' => 'unsupported_kind']; $missing++; @@ -563,7 +563,7 @@ final class MagazineContentService continue; } $kind = (int) ($parts[0] ?? 0); - if (!\in_array($kind, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) { + if (!\in_array($kind, KindsEnum::longformKindValues(), true)) { continue; } $out[] = $coordinate; @@ -717,7 +717,7 @@ final class MagazineContentService /** * Home headline strip: kind **30040** magazine root (`npub` + `d_tag`), walking `a` tags **top to bottom**. - * Only kind **30023** / **30024** addresses become tiles; nested **30040** category `a` tags are skipped. + * Only kind **30023** / **30024** / **30817** addresses become tiles; nested **30040** category `a` tags are skipped. * No strip-level heading — each article’s own title in the template is enough. * * @return array{tiles: list} @@ -752,7 +752,7 @@ final class MagazineContentService continue; } $kind = (int) ($parts[0] ?? 0); - if (!\in_array($kind, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) { + if (!\in_array($kind, KindsEnum::longformKindValues(), true)) { continue; } $pk = strtolower(trim((string) $parts[1])); @@ -947,7 +947,7 @@ final class MagazineContentService } /** - * @return list Article `#d` slugs from kind 30023/30024 `a` tags in index order; follows nested + * @return list Article `#d` slugs from kind 30023/30024/30817 `a` tags in index order; follows nested * kind-30040 section indices up to {@see $maxDepth} when the store has them. */ private function slugsFromCategoryCoord(string $categoryCoord, int $maxA): array @@ -1035,7 +1035,7 @@ final class MagazineContentService if ($identifier === '') { continue; } - if (\in_array($kind, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) { + if (\in_array($kind, KindsEnum::longformKindValues(), true)) { $slugs[] = $identifier; if (\count($slugs) >= $maxA) { return $slugs; diff --git a/src/Service/MagazineHierarchyPublishService.php b/src/Service/MagazineHierarchyPublishService.php index 89592d9..3f8f9d9 100644 --- a/src/Service/MagazineHierarchyPublishService.php +++ b/src/Service/MagazineHierarchyPublishService.php @@ -185,12 +185,9 @@ final class MagazineHierarchyPublishService if ($kind === KindsEnum::PUBLICATION_INDEX->value && !hash_equals($ownerHex, $pk)) { return 'Nested 30040 `a` tags must use the magazine owner pubkey'; } - if (!\in_array($kind, [ - KindsEnum::PUBLICATION_INDEX->value, - KindsEnum::LONGFORM->value, - KindsEnum::LONGFORM_DRAFT->value, - ], true)) { - return 'Unsupported kind in `a` tag (only 30040, 30023, 30024)'; + $allowedKinds = array_merge([KindsEnum::PUBLICATION_INDEX->value], KindsEnum::longformKindValues()); + if (!\in_array($kind, $allowedKinds, true)) { + return 'Unsupported kind in `a` tag (only 30040, 30023, 30024, 30817)'; } } @@ -198,7 +195,7 @@ final class MagazineHierarchyPublishService } /** - * Long-form `a` coordinates from this publish batch (30023 / 30024) for immediate DB sync. + * Long-form `a` coordinates from this publish batch (30023 / 30024 / 30817) for immediate DB sync. * * @param array $byD * @@ -222,7 +219,7 @@ final class MagazineHierarchyPublishService continue; } $kind = (int) $parts[0]; - if (!\in_array($kind, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) { + if (!\in_array($kind, KindsEnum::longformKindValues(), true)) { continue; } $pk = strtolower(trim((string) $parts[1])); diff --git a/src/Service/Nip09DeletionApplier.php b/src/Service/Nip09DeletionApplier.php index a806d7c..4bdb5dd 100644 --- a/src/Service/Nip09DeletionApplier.php +++ b/src/Service/Nip09DeletionApplier.php @@ -73,15 +73,13 @@ final class Nip09DeletionApplier } $declared = $eKinds[$i] ?? null; if ($declared !== null - && !\in_array($declared, [ - KindsEnum::LONGFORM->value, - KindsEnum::LONGFORM_DRAFT->value, + && !\in_array($declared, array_merge(KindsEnum::longformKindValues(), [ KindsEnum::PUBLICATION_INDEX->value, KindsEnum::METADATA->value, KindsEnum::RELAY_LIST->value, KindsEnum::PAYMENT_TARGETS->value, KindsEnum::CURATION_SET->value, - ], true)) { + ]), true)) { continue; } if ($this->removeArticleByEventIdIfValid($eId, $deletionPubkey, $declared, $seenArticleIds)) { @@ -92,12 +90,10 @@ final class Nip09DeletionApplier if ($this->tryRemoveCoreEventRowByEventId($eId, $deletionPubkey, $declared)) { continue; } - if ($declared === null || \in_array($declared, [ - KindsEnum::LONGFORM->value, - KindsEnum::LONGFORM_DRAFT->value, + if ($declared === null || \in_array($declared, array_merge(KindsEnum::longformKindValues(), [ KindsEnum::PUBLICATION_INDEX->value, KindsEnum::CURATION_SET->value, - ], true)) { + ]), true)) { $mag = $this->tryRemoveMagazine30040ByEventId($eId, $deletionPubkey); if ($mag === 1) { ++$roots; @@ -273,7 +269,7 @@ final class Nip09DeletionApplier if ($declaredKind !== null && $k !== null && $declaredKind !== $k) { return false; } - if ($k !== null && !\in_array($k, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) { + if ($k !== null && !\in_array($k, KindsEnum::longformKindValues(), true)) { return false; } $this->entityManager->remove($article); @@ -343,7 +339,7 @@ final class Nip09DeletionApplier return $out; } - if ($kind === KindsEnum::LONGFORM->value || $kind === KindsEnum::LONGFORM_DRAFT->value) { + if (\in_array($kind, KindsEnum::longformKindValues(), true)) { if ($d === '') { return $out; } diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index f2d6dae..a959c6f 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -425,7 +425,7 @@ class NostrClient $subscription = new Subscription(); $subscriptionId = $subscription->setId(); $filter = new Filter(); - $filter->setKinds([KindsEnum::LONGFORM]); + $filter->setKinds(KindsEnum::longformKinds()); $filter->setSince($since); $filter->setUntil($until); $requestMessage = new RequestMessage($subscriptionId, [$filter]); @@ -1092,7 +1092,7 @@ class NostrClient $subscription = new Subscription(); $subscriptionId = $subscription->setId(); $filter = new Filter(); - $filter->setKinds([KindsEnum::LONGFORM]); + $filter->setKinds(KindsEnum::longformKinds()); $filter->setTag('#d', $slugs); $requestMessage = new RequestMessage($subscriptionId, [$filter]); diff --git a/src/Service/NostrKind5DeletionFilter.php b/src/Service/NostrKind5DeletionFilter.php index a88c9d8..a9e4fc4 100644 --- a/src/Service/NostrKind5DeletionFilter.php +++ b/src/Service/NostrKind5DeletionFilter.php @@ -43,14 +43,12 @@ final class NostrKind5DeletionFilter */ private function storedKindValues(): array { - return [ + return array_merge(KindsEnum::longformKindValues(), [ KindsEnum::METADATA->value, KindsEnum::RELAY_LIST->value, KindsEnum::PAYMENT_TARGETS->value, - KindsEnum::LONGFORM->value, - KindsEnum::LONGFORM_DRAFT->value, KindsEnum::PUBLICATION_INDEX->value, KindsEnum::CURATION_SET->value, - ]; + ]); } } diff --git a/src/Service/NostrLongformArticleStore.php b/src/Service/NostrLongformArticleStore.php index e3be91e..75d66ff 100644 --- a/src/Service/NostrLongformArticleStore.php +++ b/src/Service/NostrLongformArticleStore.php @@ -87,6 +87,7 @@ final class NostrLongformArticleStore $target->setPublishedAt($source->getPublishedAt()); } $target->setTopics($source->getTopics()); + $target->setWikiKinds($source->getWikiKinds()); if ($source->getKind() !== null) { $target->setKind($source->getKind()); } diff --git a/templates/pages/article.html.twig b/templates/pages/article.html.twig index e08b4e2..2e16a21 100644 --- a/templates/pages/article.html.twig +++ b/templates/pages/article.html.twig @@ -85,6 +85,14 @@ {% endif %} + {% if article.wikiKinds is not null and article.wikiKinds|length > 0 %} +
+ Specifies: + {% for k in article.wikiKinds %} + kind {{ k }} + {% endfor %} +
+ {% endif %}