You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
432 lines
14 KiB
432 lines
14 KiB
<?php |
|
|
|
declare(strict_types=1); |
|
|
|
namespace App\Service; |
|
|
|
use App\Entity\Event as PublicationEventEntity; |
|
use App\Util\NostrEventTags; |
|
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; |
|
|
|
/** |
|
* Builds JSON-safe payloads for the owner-only magazine hierarchy editor (kind 30040 root + categories). |
|
*/ |
|
final class MagazineHierarchyEditorService |
|
{ |
|
public function __construct( |
|
private readonly MagazineIndexStore $store, |
|
private readonly MagazineContentService $magazineContent, |
|
private readonly NostrClient $nostrClient, |
|
private readonly NostrKeyHelper $nostrKeyHelper, |
|
private readonly ParameterBagInterface $params, |
|
) { |
|
} |
|
|
|
/** |
|
* @return array{ |
|
* owner_hex: string, |
|
* root_d_tag: string, |
|
* site_name: string, |
|
* default_category_preserved_tags: list<list<string>>, |
|
* nodes: list<array{ |
|
* d_tag: string, |
|
* is_root: bool, |
|
* depth: int, |
|
* title: string, |
|
* summary: string, |
|
* content: string, |
|
* preserved_tags: list<list<string>>, |
|
* a_coordinates: list<string>, |
|
* parent_d_tag: string|null |
|
* }> |
|
* } |
|
*/ |
|
public function buildEditorPayload(): array |
|
{ |
|
$npub = (string) $this->params->get('npub'); |
|
$dTag = (string) $this->params->get('d_tag'); |
|
$siteName = (string) $this->params->get('name'); |
|
$ownerHex = strtolower($this->nostrKeyHelper->convertToHex($npub)); |
|
|
|
$root = $this->store->getRoot($npub, $dTag); |
|
if ($root === null) { |
|
try { |
|
$fetched = $this->nostrClient->getMagazineIndex($npub, $dTag); |
|
if ($fetched !== null) { |
|
$this->store->putRoot($npub, $dTag, $fetched); |
|
$root = $fetched; |
|
} |
|
} catch (\Throwable) { |
|
} |
|
} |
|
|
|
$parentByChild = $this->buildCategoryParentByChildD($npub, $dTag, $ownerHex); |
|
|
|
$nodes = []; |
|
if ($root !== null) { |
|
$rootNode = $this->nodeFromEvent($root, $dTag, true, $ownerHex, $siteName); |
|
$rootNode['parent_d_tag'] = null; |
|
$nodes[] = $this->withDepth($rootNode, 0); |
|
} else { |
|
$rootNode = $this->emptyRootNode($dTag, $ownerHex, $siteName); |
|
$rootNode['parent_d_tag'] = null; |
|
$nodes[] = $this->withDepth($rootNode, 0); |
|
} |
|
|
|
foreach ($this->orderedCategorySlugsForEditor($npub, $dTag, $ownerHex) as $slug) { |
|
$slug = trim($slug); |
|
if ($slug === '') { |
|
continue; |
|
} |
|
$parentD = $parentByChild[$slug] ?? $dTag; |
|
$cat = $this->store->getCategory($slug); |
|
if ($cat !== null) { |
|
$catNode = $this->nodeFromEvent($cat, $slug, false, $ownerHex, $siteName); |
|
$catNode['parent_d_tag'] = $parentD; |
|
$nodes[] = $this->withDepth( |
|
$catNode, |
|
$this->depthForCategorySlug($slug, $dTag, $parentByChild), |
|
); |
|
} else { |
|
$emptyNode = $this->emptyCategoryNode($slug, $ownerHex, $siteName); |
|
$emptyNode['parent_d_tag'] = $parentD; |
|
$nodes[] = $this->withDepth( |
|
$emptyNode, |
|
$this->depthForCategorySlug($slug, $dTag, $parentByChild), |
|
); |
|
} |
|
} |
|
|
|
return [ |
|
'owner_hex' => $ownerHex, |
|
'root_d_tag' => $dTag, |
|
'site_name' => $siteName, |
|
'nodes' => $nodes, |
|
'default_category_preserved_tags' => $this->defaultCategoryPreservedTags($ownerHex, $siteName), |
|
]; |
|
} |
|
|
|
/** |
|
* Category #d values in depth-first pre-order: each index is immediately followed by its nested |
|
* kind-30040 children (same order as {@code a} tags), matching the magazine tree rather than BFS. |
|
* Appends any store slugs not linked from the cached root, then orphan refs (unchanged behaviour). |
|
* |
|
* @return list<string> |
|
*/ |
|
private function orderedCategorySlugsForEditor(string $npub, string $rootD, string $ownerHex): array |
|
{ |
|
$out = []; |
|
$seen = []; |
|
$rootEvent = $this->store->getRoot($npub, $rootD); |
|
if ($rootEvent !== null) { |
|
$dfs = function (string $slug) use (&$dfs, &$out, &$seen, $ownerHex, $rootD): void { |
|
$slug = trim($slug); |
|
if ($slug === '' || hash_equals($rootD, $slug) || isset($seen[$slug])) { |
|
return; |
|
} |
|
$seen[$slug] = true; |
|
$out[] = $slug; |
|
$cat = $this->store->getCategory($slug); |
|
if ($cat === null) { |
|
return; |
|
} |
|
foreach (NostrEventTags::publicationIndexNestedDSlugsForOwner($cat->getTags(), $ownerHex) as $child) { |
|
$dfs($child); |
|
} |
|
}; |
|
foreach (NostrEventTags::publicationIndexNestedDSlugsForOwner($rootEvent->getTags(), $ownerHex) as $child) { |
|
$dfs($child); |
|
} |
|
} |
|
foreach ($this->magazineContent->getCategorySlugsFromStore() as $slug) { |
|
$slug = trim((string) $slug); |
|
if ($slug === '' || isset($seen[$slug])) { |
|
continue; |
|
} |
|
$seen[$slug] = true; |
|
$out[] = $slug; |
|
} |
|
foreach ($this->collectOrphan30040SlugsReferencedInStore($npub, $rootD, $ownerHex, array_keys($seen)) as $slug) { |
|
$slug = trim((string) $slug); |
|
if ($slug === '' || isset($seen[$slug])) { |
|
continue; |
|
} |
|
$seen[$slug] = true; |
|
$out[] = $slug; |
|
} |
|
|
|
return $out; |
|
} |
|
|
|
/** |
|
* @param array{d_tag: string, is_root: bool, title: string, summary: string, content: string, preserved_tags: list<list<string>>, a_coordinates: list<string>} $node |
|
* |
|
* @return array{ |
|
* d_tag: string, |
|
* is_root: bool, |
|
* depth: int, |
|
* title: string, |
|
* summary: string, |
|
* content: string, |
|
* preserved_tags: list<list<string>>, |
|
* a_coordinates: list<string>, |
|
* parent_d_tag?: string|null |
|
* } |
|
*/ |
|
private function withDepth(array $node, int $depth): array |
|
{ |
|
$node['depth'] = $depth; |
|
|
|
return $node; |
|
} |
|
|
|
/** |
|
* Maps each category #d to the parent index #d (root magazine #d for top-level categories). |
|
* First parent wins when multiple indices reference the same child. |
|
* |
|
* @return array<string, string> |
|
*/ |
|
private function buildCategoryParentByChildD(string $npub, string $rootD, string $ownerHex): array |
|
{ |
|
$ownerHex = strtolower($ownerHex); |
|
$parentByChild = []; |
|
$rootEvent = $this->store->getRoot($npub, $rootD); |
|
if ($rootEvent !== null) { |
|
foreach (NostrEventTags::publicationIndexNestedDSlugsForOwner($rootEvent->getTags(), $ownerHex) as $childD) { |
|
if ($childD === '' || hash_equals($rootD, $childD)) { |
|
continue; |
|
} |
|
if (!isset($parentByChild[$childD])) { |
|
$parentByChild[$childD] = $rootD; |
|
} |
|
} |
|
} |
|
$listed = $this->magazineContent->getCategorySlugsFromStore(); |
|
foreach ($listed as $slug) { |
|
$slug = trim((string) $slug); |
|
if ($slug === '') { |
|
continue; |
|
} |
|
$cat = $this->store->getCategory($slug); |
|
if ($cat === null) { |
|
continue; |
|
} |
|
foreach (NostrEventTags::publicationIndexNestedDSlugsForOwner($cat->getTags(), $ownerHex) as $childD) { |
|
if ($childD === '' || hash_equals($rootD, $childD)) { |
|
continue; |
|
} |
|
if (!isset($parentByChild[$childD])) { |
|
$parentByChild[$childD] = $slug; |
|
} |
|
} |
|
} |
|
foreach ($this->collectOrphan30040SlugsReferencedInStore($npub, $rootD, $ownerHex, $listed) as $slug) { |
|
$slug = trim((string) $slug); |
|
if ($slug === '') { |
|
continue; |
|
} |
|
$cat = $this->store->getCategory($slug); |
|
if ($cat === null) { |
|
continue; |
|
} |
|
foreach (NostrEventTags::publicationIndexNestedDSlugsForOwner($cat->getTags(), $ownerHex) as $childD) { |
|
if ($childD === '' || hash_equals($rootD, $childD)) { |
|
continue; |
|
} |
|
if (!isset($parentByChild[$childD])) { |
|
$parentByChild[$childD] = $slug; |
|
} |
|
} |
|
} |
|
|
|
return $parentByChild; |
|
} |
|
|
|
/** |
|
* 1 = category linked from the root index; larger = deeper nested kind-30040. |
|
*/ |
|
private function depthForCategorySlug(string $slug, string $rootD, array $parentByChild): int |
|
{ |
|
if ($slug === $rootD) { |
|
return 0; |
|
} |
|
if (!isset($parentByChild[$slug])) { |
|
return 1; |
|
} |
|
$depth = 1; |
|
$current = $slug; |
|
for ($i = 0; $i < 64; ++$i) { |
|
$parent = $parentByChild[$current] ?? null; |
|
if ($parent === null) { |
|
return $depth; |
|
} |
|
if (hash_equals($rootD, $parent)) { |
|
return $depth; |
|
} |
|
$current = $parent; |
|
++$depth; |
|
} |
|
|
|
return $depth; |
|
} |
|
|
|
/** |
|
* @return list<list<string>> |
|
*/ |
|
private function defaultCategoryPreservedTags(string $ownerHex, string $siteName): array |
|
{ |
|
return [ |
|
['type', 'magazine'], |
|
['l', 'en, ISO-639-1'], |
|
['reading-direction', 'left-to-right, top-to-bottom'], |
|
['published_by', $siteName], |
|
['p', $ownerHex], |
|
]; |
|
} |
|
|
|
/** |
|
* @return array{ |
|
* d_tag: string, |
|
* is_root: bool, |
|
* title: string, |
|
* summary: string, |
|
* content: string, |
|
* preserved_tags: list<list<string>>, |
|
* a_coordinates: list<string> |
|
* } |
|
*/ |
|
private function emptyRootNode(string $dTag, string $ownerHex, string $siteName): array |
|
{ |
|
return [ |
|
'd_tag' => $dTag, |
|
'is_root' => true, |
|
'title' => '', |
|
'summary' => '', |
|
'content' => '', |
|
'preserved_tags' => $this->defaultCategoryPreservedTags($ownerHex, $siteName), |
|
'a_coordinates' => [], |
|
]; |
|
} |
|
|
|
/** |
|
* @return array{ |
|
* d_tag: string, |
|
* is_root: bool, |
|
* title: string, |
|
* summary: string, |
|
* content: string, |
|
* preserved_tags: list<list<string>>, |
|
* a_coordinates: list<string> |
|
* } |
|
*/ |
|
private function emptyCategoryNode(string $slug, string $ownerHex, string $siteName): array |
|
{ |
|
return [ |
|
'd_tag' => $slug, |
|
'is_root' => false, |
|
'title' => $slug, |
|
'summary' => '', |
|
'content' => '', |
|
'preserved_tags' => $this->defaultCategoryPreservedTags($ownerHex, $siteName), |
|
'a_coordinates' => [], |
|
]; |
|
} |
|
|
|
/** |
|
* @return array{ |
|
* d_tag: string, |
|
* is_root: bool, |
|
* title: string, |
|
* summary: string, |
|
* content: string, |
|
* preserved_tags: list<list<string>>, |
|
* a_coordinates: list<string> |
|
* } |
|
*/ |
|
private function nodeFromEvent(PublicationEventEntity $event, string $dTag, bool $isRoot, string $ownerHex, string $siteName): array |
|
{ |
|
$title = ''; |
|
$summary = ''; |
|
$preserved = []; |
|
$aCoords = []; |
|
foreach ($event->getTags() as $row) { |
|
$seq = NostrEventTags::rowToStringList($row); |
|
if ($seq === null || $seq === []) { |
|
continue; |
|
} |
|
$name = strtolower((string) $seq[0]); |
|
if ($name === 'd') { |
|
continue; |
|
} |
|
if ($name === 'title') { |
|
$title = isset($seq[1]) ? (string) $seq[1] : ''; |
|
|
|
continue; |
|
} |
|
if ($name === 'summary') { |
|
$summary = isset($seq[1]) ? (string) $seq[1] : ''; |
|
|
|
continue; |
|
} |
|
if ($name === 'a') { |
|
$coord = isset($seq[1]) ? trim((string) $seq[1]) : ''; |
|
if ($coord !== '') { |
|
$aCoords[] = $coord; |
|
} |
|
|
|
continue; |
|
} |
|
$preserved[] = array_map(static fn (mixed $v): string => (string) $v, $seq); |
|
} |
|
if ($preserved === []) { |
|
$preserved = $this->defaultCategoryPreservedTags($ownerHex, $siteName); |
|
} |
|
|
|
return [ |
|
'd_tag' => $dTag, |
|
'is_root' => $isRoot, |
|
'title' => $title, |
|
'summary' => $summary, |
|
'content' => $event->getContent(), |
|
'preserved_tags' => $preserved, |
|
'a_coordinates' => $aCoords, |
|
]; |
|
} |
|
|
|
/** |
|
* Category #d values that appear in a kind-30040 `a` tag on the root or any stored category |
|
* but are not returned by {@see MagazineContentService::getCategorySlugsFromStore()} (e.g. newly |
|
* linked before the next prewarm BFS). |
|
* |
|
* @param list<string> $alreadyListed |
|
* |
|
* @return list<string> |
|
*/ |
|
private function collectOrphan30040SlugsReferencedInStore(string $npub, string $rootD, string $ownerHex, array $alreadyListed): array |
|
{ |
|
$have = array_fill_keys($alreadyListed, true); |
|
$out = []; |
|
$events = []; |
|
$r = $this->store->getRoot($npub, $rootD); |
|
if ($r !== null) { |
|
$events[] = $r; |
|
} |
|
foreach ($alreadyListed as $slug) { |
|
$c = $this->store->getCategory($slug); |
|
if ($c !== null) { |
|
$events[] = $c; |
|
} |
|
} |
|
foreach ($events as $event) { |
|
foreach (NostrEventTags::publicationIndexNestedDSlugsForOwner($event->getTags(), $ownerHex) as $childD) { |
|
if ($childD === '' || hash_equals($rootD, $childD) || isset($have[$childD])) { |
|
continue; |
|
} |
|
$have[$childD] = true; |
|
$out[] = $childD; |
|
} |
|
} |
|
|
|
return $out; |
|
} |
|
}
|
|
|