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'; @@ -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 { @@ -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 { @@ -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}`);
}
}

26
migrations/Version20260527110000.php

@ -0,0 +1,26 @@ @@ -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 @@ -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');
}

29
src/Entity/Article.php

@ -9,10 +9,9 @@ use Doctrine\DBAL\Types\Types; @@ -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 @@ -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 @@ -308,6 +314,21 @@ class Article
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()
{
return $this->eventStatus === EventStatusEnum::PREVIEW;

19
src/Enum/KindsEnum.php

@ -17,7 +17,26 @@ enum KindsEnum: int @@ -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<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 HIGHLIGHTS = 9802;
case RELAY_LIST = 10002; // NIP-65, Relay list metadata

31
src/Factory/ArticleFactory.php

@ -8,14 +8,14 @@ use App\Enum\KindsEnum; @@ -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 @@ -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 @@ -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;
}

2
src/Service/ArticleCommentThreadLoader.php

@ -397,7 +397,7 @@ final readonly class ArticleCommentThreadLoader @@ -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]);

2
src/Service/CommentReplyService.php

@ -205,7 +205,7 @@ final readonly class CommentReplyService @@ -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;

12
src/Service/MagazineContentService.php

@ -447,7 +447,7 @@ final class MagazineContentService @@ -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 @@ -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 @@ -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<array{article: FeaturedArticleCard, body_html: string}>}
@ -752,7 +752,7 @@ final class MagazineContentService @@ -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 @@ -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.
*/
private function slugsFromCategoryCoord(string $categoryCoord, int $maxA): array
@ -1035,7 +1035,7 @@ final class MagazineContentService @@ -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;

13
src/Service/MagazineHierarchyPublishService.php

@ -185,12 +185,9 @@ final class MagazineHierarchyPublishService @@ -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 @@ -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
*
@ -222,7 +219,7 @@ final class MagazineHierarchyPublishService @@ -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]));

16
src/Service/Nip09DeletionApplier.php

@ -73,15 +73,13 @@ final class Nip09DeletionApplier @@ -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 @@ -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 @@ -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 @@ -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;
}

4
src/Service/NostrClient.php

@ -425,7 +425,7 @@ class NostrClient @@ -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 @@ -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]);

6
src/Service/NostrKind5DeletionFilter.php

@ -43,14 +43,12 @@ final class NostrKind5DeletionFilter @@ -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,
];
]);
}
}

1
src/Service/NostrLongformArticleStore.php

@ -87,6 +87,7 @@ final class NostrLongformArticleStore @@ -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());
}

8
templates/pages/article.html.twig

@ -85,6 +85,14 @@ @@ -85,6 +85,14 @@
</span>
</div>
{% 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 class="card-body">
<div class="lede">

Loading…
Cancel
Save