Browse Source

Implement kind 30817

gitcitadel
Silberengel 2 weeks ago
parent
commit
c60ba3ea61
  1. 10
      assets/controllers/magazine_hierarchy_editor_controller.js
  2. 26
      migrations/Version20260527110000.php
  3. 3
      src/Controller/ArticleController.php
  4. 29
      src/Entity/Article.php
  5. 19
      src/Enum/KindsEnum.php
  6. 31
      src/Factory/ArticleFactory.php
  7. 2
      src/Service/ArticleCommentThreadLoader.php
  8. 2
      src/Service/CommentReplyService.php
  9. 12
      src/Service/MagazineContentService.php
  10. 13
      src/Service/MagazineHierarchyPublishService.php
  11. 16
      src/Service/Nip09DeletionApplier.php
  12. 4
      src/Service/NostrClient.php
  13. 6
      src/Service/NostrKind5DeletionFilter.php
  14. 1
      src/Service/NostrLongformArticleStore.php
  15. 8
      templates/pages/article.html.twig

10
assets/controllers/magazine_hierarchy_editor_controller.js

@ -3,6 +3,10 @@ import { Controller } from '@hotwired/stimulus';
const KIND_PUBLICATION_INDEX = 30040; const KIND_PUBLICATION_INDEX = 30040;
const KIND_LONGFORM = 30023; const KIND_LONGFORM = 30023;
const KIND_LONGFORM_DRAFT = 30024; 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. * 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) { if (Number.isFinite(n) && Number.isFinite(ingested) && ingested > 0) {
this.setStatus( 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 }, { tone: 'success', scroll: true },
); );
} else { } else {
@ -894,8 +898,8 @@ export default class MagazineHierarchyEditorController extends Controller {
if (kind === KIND_PUBLICATION_INDEX && pk !== ownerHex) { if (kind === KIND_PUBLICATION_INDEX && pk !== ownerHex) {
throw new Error(`Nested 30040 address must use magazine owner pubkey: ${coord}`); throw new Error(`Nested 30040 address must use magazine owner pubkey: ${coord}`);
} }
if (kind !== KIND_PUBLICATION_INDEX && kind !== KIND_LONGFORM && kind !== KIND_LONGFORM_DRAFT) { if (kind !== KIND_PUBLICATION_INDEX && !LONGFORM_KINDS.has(kind)) {
throw new Error(`Only kinds 30040, 30023, 30024 allowed in a tag: ${coord}`); throw new Error(`Only kinds 30040, 30023, 30024, 30817 allowed in a tag: ${coord}`);
} }
} }

26
migrations/Version20260527110000.php

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260527110000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add wiki_kinds column to article table for NIP-54 kind 30817 wiki pages';
}
public function up(Schema $schema): void
{
$this->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');
}
}

3
src/Controller/ArticleController.php

@ -278,8 +278,7 @@ class ArticleController extends AbstractController
$author = $data->pubkey; $author = $data->pubkey;
$kind = (int) $data->kind; $kind = (int) $data->kind;
$allowedKinds = [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value]; if (!\in_array($kind, KindsEnum::longformKindValues(), true)) {
if (!\in_array($kind, $allowedKinds, true)) {
throw new \Exception('Not a long form article'); throw new \Exception('Not a long form article');
} }

29
src/Entity/Article.php

@ -9,10 +9,9 @@ use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
/** /**
* Entity storing long-form articles * Entity storing long-form articles and wiki pages.
* Needed beyond the Event entity, because of the local functionalities built on top of the original events * NIP-23 long-form: kinds 30023, 30024.
* - editor * NIP-54 wiki: kind 30817 (same Markdown format; adds `k` tags listing affected kinds).
* NIP-23, kinds 30023, 30024
*/ */
#[ORM\Entity(repositoryClass: ArticleRepository::class)] #[ORM\Entity(repositoryClass: ArticleRepository::class)]
class Article class Article
@ -64,6 +63,13 @@ class Article
#[ORM\Column(nullable: true, enumType: EventStatusEnum::class)] #[ORM\Column(nullable: true, enumType: EventStatusEnum::class)]
private ?EventStatusEnum $eventStatus = EventStatusEnum::PREVIEW; 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 // Local properties
#[ORM\Column(type: Types::JSON, nullable: true)] #[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $currentPlaces; private ?array $currentPlaces;
@ -308,6 +314,21 @@ class Article
return $this; return $this;
} }
/**
* @return list<int>|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() public function isDraft()
{ {
return $this->eventStatus === EventStatusEnum::PREVIEW; return $this->eventStatus === EventStatusEnum::PREVIEW;

19
src/Enum/KindsEnum.php

@ -17,7 +17,26 @@ enum KindsEnum: int
case CURATION_SET = 30004; // NIP-51 case CURATION_SET = 30004; // NIP-51
case LONGFORM = 30023; // NIP-23 case LONGFORM = 30023; // NIP-23
case LONGFORM_DRAFT = 30024; // NIP-23 case LONGFORM_DRAFT = 30024; // NIP-23
case WIKI = 30817; // NIP-54 wiki pages
case PUBLICATION_INDEX = 30040; case PUBLICATION_INDEX = 30040;
/**
* All kinds stored as long-form articles in the `article` table: 30023, 30024, 30817.
*
* @return list<self>
*/
public static function longformKinds(): array
{
return [self::LONGFORM, self::LONGFORM_DRAFT, self::WIKI];
}
/**
* @return list<int>
*/
public static function longformKindValues(): array
{
return [self::LONGFORM->value, self::LONGFORM_DRAFT->value, self::WIKI->value];
}
case ZAP = 9735; // NIP-57, Zaps case ZAP = 9735; // NIP-57, Zaps
case HIGHLIGHTS = 9802; case HIGHLIGHTS = 9802;
case RELAY_LIST = 10002; // NIP-65, Relay list metadata case RELAY_LIST = 10002; // NIP-65, Relay list metadata

31
src/Factory/ArticleFactory.php

@ -8,14 +8,14 @@ use App\Enum\KindsEnum;
use InvalidArgumentException; 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 class ArticleFactory
{ {
public function createFromLongFormContentEvent($source): Article public function createFromLongFormContentEvent($source): Article
{ {
if ($source->kind !== KindsEnum::LONGFORM->value) { if (!\in_array($source->kind, KindsEnum::longformKindValues(), true)) {
throw new InvalidArgumentException('Source event kind should be 30023'); throw new InvalidArgumentException('Source event kind must be a longform kind (30023, 30024, 30817), got '.$source->kind);
} }
$entity = new Article(); $entity = new Article();
$entity->setRaw($source); $entity->setRaw($source);
@ -33,19 +33,23 @@ class ArticleFactory
$entity->setRatingNegative(0); $entity->setRatingNegative(0);
$entity->setRatingPositive(0); $entity->setRatingPositive(0);
// process tags // process tags
$wikiKinds = $source->kind === KindsEnum::WIKI->value ? [] : null;
foreach ($source->tags as $tag) { foreach ($source->tags as $tag) {
if (!\is_array($tag) || !isset($tag[0])) {
continue;
}
switch ($tag[0]) { switch ($tag[0]) {
case 'd': case 'd':
$entity->setSlug($tag[1]); $entity->setSlug($tag[1] ?? null);
break; break;
case 'title': case 'title':
$entity->setTitle($tag[1]); $entity->setTitle($tag[1] ?? null);
break; break;
case 'summary': case 'summary':
$entity->setSummary($tag[1]); $entity->setSummary($tag[1] ?? null);
break; break;
case 'image': case 'image':
$entity->setImage($tag[1]); $entity->setImage($tag[1] ?? null);
break; break;
case 'published_at': case 'published_at':
$parsed = $this->parseEventTimeValue($tag[1] ?? null); $parsed = $this->parseEventTimeValue($tag[1] ?? null);
@ -54,13 +58,22 @@ class ArticleFactory
} }
break; break;
case 't': 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; break;
case 'client': case 'client':
// used to signal where it was created, ignored for now
break; break;
} }
} }
$entity->setWikiKinds($wikiKinds);
return $entity; return $entity;
} }

2
src/Service/ArticleCommentThreadLoader.php

@ -397,7 +397,7 @@ final readonly class ArticleCommentThreadLoader
continue; continue;
} }
$kind = ctype_digit((string) $parts[0]) ? (int) $parts[0] : 0; $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; continue;
} }
$dTag = trim((string) $parts[2]); $dTag = trim((string) $parts[2]);

2
src/Service/CommentReplyService.php

@ -205,7 +205,7 @@ final readonly class CommentReplyService
int $parentKind, int $parentKind,
string $parentIdHex string $parentIdHex
): bool { ): bool {
if (\in_array($parentKind, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) { if (\in_array($parentKind, KindsEnum::longformKindValues(), true)) {
foreach ($tags as $row) { foreach ($tags as $row) {
if (!\is_array($row) || ($row[0] ?? null) === null) { if (!\is_array($row) || ($row[0] ?? null) === null) {
continue; continue;

12
src/Service/MagazineContentService.php

@ -447,7 +447,7 @@ final class MagazineContentService
continue; continue;
} }
$kind = (int) ($parts[0] ?? 0); $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']; $entries[] = ['coordinate' => $coordinate, 'status' => 'missing', 'reason' => 'unsupported_kind'];
$missing++; $missing++;
@ -563,7 +563,7 @@ final class MagazineContentService
continue; continue;
} }
$kind = (int) ($parts[0] ?? 0); $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; continue;
} }
$out[] = $coordinate; $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**. * 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. * No strip-level heading — each article’s own title in the template is enough.
* *
* @return array{tiles: list<array{article: FeaturedArticleCard, body_html: string}>} * @return array{tiles: list<array{article: FeaturedArticleCard, body_html: string}>}
@ -752,7 +752,7 @@ final class MagazineContentService
continue; continue;
} }
$kind = (int) ($parts[0] ?? 0); $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; continue;
} }
$pk = strtolower(trim((string) $parts[1])); $pk = strtolower(trim((string) $parts[1]));
@ -947,7 +947,7 @@ final class MagazineContentService
} }
/** /**
* @return list<string> Article `#d` slugs from kind 30023/30024 `a` tags in index order; follows nested * @return list<string> 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. * kind-30040 section indices up to {@see $maxDepth} when the store has them.
*/ */
private function slugsFromCategoryCoord(string $categoryCoord, int $maxA): array private function slugsFromCategoryCoord(string $categoryCoord, int $maxA): array
@ -1035,7 +1035,7 @@ final class MagazineContentService
if ($identifier === '') { if ($identifier === '') {
continue; continue;
} }
if (\in_array($kind, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) { if (\in_array($kind, KindsEnum::longformKindValues(), true)) {
$slugs[] = $identifier; $slugs[] = $identifier;
if (\count($slugs) >= $maxA) { if (\count($slugs) >= $maxA) {
return $slugs; return $slugs;

13
src/Service/MagazineHierarchyPublishService.php

@ -185,12 +185,9 @@ final class MagazineHierarchyPublishService
if ($kind === KindsEnum::PUBLICATION_INDEX->value && !hash_equals($ownerHex, $pk)) { if ($kind === KindsEnum::PUBLICATION_INDEX->value && !hash_equals($ownerHex, $pk)) {
return 'Nested 30040 `a` tags must use the magazine owner pubkey'; return 'Nested 30040 `a` tags must use the magazine owner pubkey';
} }
if (!\in_array($kind, [ $allowedKinds = array_merge([KindsEnum::PUBLICATION_INDEX->value], KindsEnum::longformKindValues());
KindsEnum::PUBLICATION_INDEX->value, if (!\in_array($kind, $allowedKinds, true)) {
KindsEnum::LONGFORM->value, return 'Unsupported kind in `a` tag (only 30040, 30023, 30024, 30817)';
KindsEnum::LONGFORM_DRAFT->value,
], true)) {
return 'Unsupported kind in `a` tag (only 30040, 30023, 30024)';
} }
} }
@ -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<string, NostrWireEvent> $byD * @param array<string, NostrWireEvent> $byD
* *
@ -222,7 +219,7 @@ final class MagazineHierarchyPublishService
continue; continue;
} }
$kind = (int) $parts[0]; $kind = (int) $parts[0];
if (!\in_array($kind, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) { if (!\in_array($kind, KindsEnum::longformKindValues(), true)) {
continue; continue;
} }
$pk = strtolower(trim((string) $parts[1])); $pk = strtolower(trim((string) $parts[1]));

16
src/Service/Nip09DeletionApplier.php

@ -73,15 +73,13 @@ final class Nip09DeletionApplier
} }
$declared = $eKinds[$i] ?? null; $declared = $eKinds[$i] ?? null;
if ($declared !== null if ($declared !== null
&& !\in_array($declared, [ && !\in_array($declared, array_merge(KindsEnum::longformKindValues(), [
KindsEnum::LONGFORM->value,
KindsEnum::LONGFORM_DRAFT->value,
KindsEnum::PUBLICATION_INDEX->value, KindsEnum::PUBLICATION_INDEX->value,
KindsEnum::METADATA->value, KindsEnum::METADATA->value,
KindsEnum::RELAY_LIST->value, KindsEnum::RELAY_LIST->value,
KindsEnum::PAYMENT_TARGETS->value, KindsEnum::PAYMENT_TARGETS->value,
KindsEnum::CURATION_SET->value, KindsEnum::CURATION_SET->value,
], true)) { ]), true)) {
continue; continue;
} }
if ($this->removeArticleByEventIdIfValid($eId, $deletionPubkey, $declared, $seenArticleIds)) { if ($this->removeArticleByEventIdIfValid($eId, $deletionPubkey, $declared, $seenArticleIds)) {
@ -92,12 +90,10 @@ final class Nip09DeletionApplier
if ($this->tryRemoveCoreEventRowByEventId($eId, $deletionPubkey, $declared)) { if ($this->tryRemoveCoreEventRowByEventId($eId, $deletionPubkey, $declared)) {
continue; continue;
} }
if ($declared === null || \in_array($declared, [ if ($declared === null || \in_array($declared, array_merge(KindsEnum::longformKindValues(), [
KindsEnum::LONGFORM->value,
KindsEnum::LONGFORM_DRAFT->value,
KindsEnum::PUBLICATION_INDEX->value, KindsEnum::PUBLICATION_INDEX->value,
KindsEnum::CURATION_SET->value, KindsEnum::CURATION_SET->value,
], true)) { ]), true)) {
$mag = $this->tryRemoveMagazine30040ByEventId($eId, $deletionPubkey); $mag = $this->tryRemoveMagazine30040ByEventId($eId, $deletionPubkey);
if ($mag === 1) { if ($mag === 1) {
++$roots; ++$roots;
@ -273,7 +269,7 @@ final class Nip09DeletionApplier
if ($declaredKind !== null && $k !== null && $declaredKind !== $k) { if ($declaredKind !== null && $k !== null && $declaredKind !== $k) {
return false; 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; return false;
} }
$this->entityManager->remove($article); $this->entityManager->remove($article);
@ -343,7 +339,7 @@ final class Nip09DeletionApplier
return $out; return $out;
} }
if ($kind === KindsEnum::LONGFORM->value || $kind === KindsEnum::LONGFORM_DRAFT->value) { if (\in_array($kind, KindsEnum::longformKindValues(), true)) {
if ($d === '') { if ($d === '') {
return $out; return $out;
} }

4
src/Service/NostrClient.php

@ -425,7 +425,7 @@ class NostrClient
$subscription = new Subscription(); $subscription = new Subscription();
$subscriptionId = $subscription->setId(); $subscriptionId = $subscription->setId();
$filter = new Filter(); $filter = new Filter();
$filter->setKinds([KindsEnum::LONGFORM]); $filter->setKinds(KindsEnum::longformKinds());
$filter->setSince($since); $filter->setSince($since);
$filter->setUntil($until); $filter->setUntil($until);
$requestMessage = new RequestMessage($subscriptionId, [$filter]); $requestMessage = new RequestMessage($subscriptionId, [$filter]);
@ -1092,7 +1092,7 @@ class NostrClient
$subscription = new Subscription(); $subscription = new Subscription();
$subscriptionId = $subscription->setId(); $subscriptionId = $subscription->setId();
$filter = new Filter(); $filter = new Filter();
$filter->setKinds([KindsEnum::LONGFORM]); $filter->setKinds(KindsEnum::longformKinds());
$filter->setTag('#d', $slugs); $filter->setTag('#d', $slugs);
$requestMessage = new RequestMessage($subscriptionId, [$filter]); $requestMessage = new RequestMessage($subscriptionId, [$filter]);

6
src/Service/NostrKind5DeletionFilter.php

@ -43,14 +43,12 @@ final class NostrKind5DeletionFilter
*/ */
private function storedKindValues(): array private function storedKindValues(): array
{ {
return [ return array_merge(KindsEnum::longformKindValues(), [
KindsEnum::METADATA->value, KindsEnum::METADATA->value,
KindsEnum::RELAY_LIST->value, KindsEnum::RELAY_LIST->value,
KindsEnum::PAYMENT_TARGETS->value, KindsEnum::PAYMENT_TARGETS->value,
KindsEnum::LONGFORM->value,
KindsEnum::LONGFORM_DRAFT->value,
KindsEnum::PUBLICATION_INDEX->value, KindsEnum::PUBLICATION_INDEX->value,
KindsEnum::CURATION_SET->value, KindsEnum::CURATION_SET->value,
]; ]);
} }
} }

1
src/Service/NostrLongformArticleStore.php

@ -87,6 +87,7 @@ final class NostrLongformArticleStore
$target->setPublishedAt($source->getPublishedAt()); $target->setPublishedAt($source->getPublishedAt());
} }
$target->setTopics($source->getTopics()); $target->setTopics($source->getTopics());
$target->setWikiKinds($source->getWikiKinds());
if ($source->getKind() !== null) { if ($source->getKind() !== null) {
$target->setKind($source->getKind()); $target->setKind($source->getKind());
} }

8
templates/pages/article.html.twig

@ -85,6 +85,14 @@
</span> </span>
</div> </div>
{% endif %} {% endif %}
{% if article.wikiKinds is not null and article.wikiKinds|length > 0 %}
<div class="wiki-kinds">
<span class="wiki-kinds__label">Specifies:</span>
{% for k in article.wikiKinds %}
<span class="wiki-kinds__badge">kind {{ k }}</span>
{% endfor %}
</div>
{% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="lede"> <div class="lede">

Loading…
Cancel
Save