Browse Source

Downsizing: remove nzines

imwald
Nuša Pukšič 9 months ago
parent
commit
03180d0004
  1. 1
      config/packages/security.yaml
  2. 27
      config/packages/workflow.yaml
  3. 16
      publication/Newsroom/Arts/arts.yaml
  4. 23
      publication/Newsroom/Digital/digital.yaml
  5. 23
      publication/Newsroom/Insight/insight.yaml
  6. 24
      publication/Newsroom/Lifestyle/lifestyle.yaml
  7. 26
      publication/Newsroom/Reflections/reflections.yaml
  8. 14
      publication/Newsroom/newsroom.yaml
  9. 69
      src/Command/QualityCheckArticlesCommand.php
  10. 259
      src/Controller/NzineController.php
  11. 141
      src/Entity/Nzine.php
  12. 51
      src/Entity/NzineBot.php
  13. 11
      src/Enum/IndexStatusEnum.php
  14. 32
      src/Form/NzineBotType.php
  15. 30
      src/Form/NzineType.php
  16. 43
      src/Repository/NzineRepository.php
  17. 147
      src/Service/NzineWorkflowService.php
  18. 37
      src/Twig/Components/Organisms/ZineList.php
  19. 16
      templates/components/Organisms/ZineList.html.twig
  20. 3
      templates/components/UserMenu.html.twig
  21. 10
      templates/pages/author.html.twig
  22. 66
      templates/pages/nzine-editor.html.twig
  23. 16
      templates/pages/nzine.html.twig
  24. 2
      translations/messages.en.yaml

1
config/packages/security.yaml

@ -28,5 +28,4 @@ security:
access_control: access_control:
- { path: ^/admin, roles: ROLE_USER } - { path: ^/admin, roles: ROLE_USER }
- { path: ^/search, roles: ROLE_USER } - { path: ^/search, roles: ROLE_USER }
# - { path: ^/nzine, roles: ROLE_USER }
# - { path: ^/profile, roles: ROLE_USER } # - { path: ^/profile, roles: ROLE_USER }

27
config/packages/workflow.yaml

@ -35,30 +35,3 @@ framework:
edit: edit:
from: published from: published
to: edited to: edited
nzine_workflow:
type: state_machine
marking_store:
type: method
property: state
supports:
- App\Entity\Nzine
initial_marking: draft
places:
- draft
- profile_created
- main_index_created
- nested_indices_created
- published
transitions:
create_profile:
from: draft
to: profile_created
create_main_index:
from: profile_created
to: main_index_created
create_nested_indices:
from: main_index_created
to: nested_indices_created
publish:
from: nested_indices_created
to: published

16
publication/Newsroom/Arts/arts.yaml

@ -0,0 +1,16 @@
tags:
- ['d', 'newsroom-magazine-by-newsroom-cat-arts']
- ['type', 'magazine']
- ['l', 'en, ISO-639-1']
- ['reading-direction', 'left-to-right, top-to-bottom']
- ['t', 'art']
- ['title', 'Arts']
- ['summary', 'Arts and artistic pursuits']
- ['published_by', 'Newsroom magazine']
- [ 'p', '7c3bd3d882304e1fb21bbe1d3eb914a6152c747f315fbed6893e99f7119048ef' ]
- ['a', '30023:e844b39d850acbb13bba1a20057250fe6b3deff5f1ecc95b6a99dc35adafb6a2:awfl8cYr_4oS1-EiY0uP2']
- ['a', '30023:5188521b32d40b740a1388d244e22642da3515c0fb39f07057129370008eb518:Legacy-z62rgd']
- ['a', '30023:63d59db8d29abe29db7380beb912b8f600237332b6c9978b208694e4be170f6f:Oana-Bakovi-tf09mu']
- ['a', '30023:5708b1f601ca8c453ebf622426acc9138cf5b714e9a6ff83c4758c4f208df3b8:Music-neuroplasticity-and-mental-health-f6oavz']
- ['a', '30023:63d59db8d29abe29db7380beb912b8f600237332b6c9978b208694e4be170f6f:feature:-katerina-kouzmitcheva']
- ['a', '30023:1b30d27ae3a493ad07fe2507700d7b412a7d551e13099774c71347473cf04146:AI-and-the-Future-of-Sound-Transforming-Music-Creation-and-Experience-gi8u8w']

23
publication/Newsroom/Digital/digital.yaml

@ -0,0 +1,23 @@
tags:
- ['d', 'newsroom-magazine-by-newsroom-cat-digital']
- ['type', 'magazine']
- ['l', 'en, ISO-639-1']
- ['reading-direction', 'left-to-right, top-to-bottom']
- ['t', 'technology']
- ['title', 'Digital']
- ['summary', 'Current digital technologies, trends and opportunities']
- ['published_by', 'Newsroom magazine']
- [ 'p', '7c3bd3d882304e1fb21bbe1d3eb914a6152c747f315fbed6893e99f7119048ef' ]
- ['a', '30023:dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319:1749882441713']
- ['a', '30023:624d01ef570a3730afa1ebedc3ed95d57259ac5f37a9f0eac9c2a0d2f122bf4a:1750083282192']
- ['a', '30023:3b7fc823611f1aeaea63ee3bf69b25b8aa16ec6e81d1afc39026808fe194354f:8_keUAm8-jlZIDUhjzJ4L']
- ['a', '30023:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1749232910842']
- ['a', '30023:7bc05901cbcbbbbb87b76aa42ade4b5873d3d009311975e7fdb7766e8c26d22b:Extra-Extra-Read-all-about-it-4w9ksd']
- ['a', '30023:3c389c8f4d46ca81316743a3e33cedb1d0619f8778ee74d47265775e7a2eff7f:1742920054921']
- ['a', '30023:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1741197956785']
- ['a', '30023:378562cd20849dce3b74d85bc3e72c84f8ab59e94aa29650e1ad1b47a6fc6773:8IphCBoygPWv90xkEHlk0']
- ['a', '30023:a012dc823bcf80d5eadf4b6683035634bc40f1b0a52b958278b6bbc96458a70d:2-wTPCMqGqU7H2ptDSelh']
- ['a', '30023:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1738257057128']
- ['a', '30023:65038d696843e53ff11c3316a5b1c1c71b2fdc31bffe0bfcb93b1e4c1fff8852:2025-01-06-technology-never-changes']
- ['a', '30023:4234223996ce6549720e66dd6bc4bb7efb9f25c60c4816d7bc47a65e1d80db24:1724998869566' ]
- ['a', '30023:65038d696843e53ff11c3316a5b1c1c71b2fdc31bffe0bfcb93b1e4c1fff8852:2024-09-23-delete-the-technology']

23
publication/Newsroom/Insight/insight.yaml

@ -0,0 +1,23 @@
tags:
- ['d', 'newsroom-magazine-by-newsroom-cat-insight']
- ['type', 'magazine']
- ['l', 'en, ISO-639-1']
- ['reading-direction', 'left-to-right, top-to-bottom']
- ['t', 'technology']
- ['t', 'business']
- ['t', 'politics']
- ['t', 'economics']
- ['title', 'Insight']
- ['summary', 'Technology, politics, economics and more']
- ['published_by', 'Newsroom magazine']
- ['p', '7c3bd3d882304e1fb21bbe1d3eb914a6152c747f315fbed6893e99f7119048ef' ]
- ['a', '30023:6ad3e2a34818b153c81f48c58f44e5199e7b4fc8dbe37810a000dce3c90b7740:Protocol-ibo4to']
- ['a', '30023:4d41a7cbc9b7f7e8484d15499aa9141c73f9a39c3765cc5d5631fa1e7d3633cc:The-dollar-a-broken-contract-7loce5']
- ['a', '30023:4234223996ce6549720e66dd6bc4bb7efb9f25c60c4816d7bc47a65e1d80db24:1736500765505' ]
- ['a', "30023:bc6ccd13a32f94d36564dac8f5963a0d69c583c9968341bbdf278b21f53098e4:money-flows-aren't-important"]
- ['a', '30023:472f440f29ef996e92a186b8d320ff180c855903882e59d50de1b8bd5669301e:japan-admits-it-has-a-problem-and-consumer-debt-hits-a-wall-in-the-us']
- ['a', '30023:bc52210b20d3fb89326463a3518674c7edde65794a7765c7f3a9119b20bfc6de:compact-nuclear-power-revolutionizing-energy-with-modular-thorium-reactors']
- ['a', '30023:c066aac504e1228a5853117624f2f9156b1f5b332be74bb08f281c136a41a034:purchase-vs-investment']
- ['a', '30023:04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9:Capitalism-Scales-Best-Be-Profitable-1rpxxj']
- ['a', '30023:ed5774ac84f70a9e1ca14027e07de8e643cef5dcf3e3593266886bb745611c5c:v9WY4L0gdPul3slaO5bt2' ]
- ['a', '30023:a396e36e962a991dac21731dd45da2ee3fd9265d65f9839c15847294ec991f1c:Bitcoin-as-Escape-from-Clown-Insight']

24
publication/Newsroom/Lifestyle/lifestyle.yaml

@ -0,0 +1,24 @@
tags:
- ['d', 'newsroom-magazine-by-newsroom-cat-lifestyle']
- ['type', 'magazine']
- ['l', 'en, ISO-639-1']
- ['reading-direction', 'left-to-right, top-to-bottom']
- ['t', 'lifestyle']
- ['title', 'Lifestyle']
- ['summary', 'Lifestyle, health and wellness, travel and home']
- ['published_by', 'Newsroom magazine']
- [ 'p', '7c3bd3d882304e1fb21bbe1d3eb914a6152c747f315fbed6893e99f7119048ef' ]
- ['a', '30023:a3c6f928a920617444d8fd10ba65c7a21c75a280cac58ebadd0bb1ded45494fb:24127d1869d6fb0e']
- ['a', '30023:04ed2b8f0bbe0a1330f27831108fc75379267826d544f6abb7aac50275be6756:47b47035a8b7bef0']
- ['a', '30023:c69b71dc564fdc350acddff929f25d7202ac1470c87488608bd6d98e426ba763:wj6WLkUfYkGRaDK48ZfK8']
- ['a', '30023:554ab6fed675985d6b99b79ad7b784ff8ca4a5b02778812e1968eee0c6cbc27e:XTbk9KAgURJbCPniEu_9g']
- ['a', '30023:c69b71dc564fdc350acddff929f25d7202ac1470c87488608bd6d98e426ba763:QxHZzjc6kVnE1KBrKhvhc']
- ['a', '30023:b8ca3d82ac72d2bdf0d94541898397467d59c9614fa4ce88066cbf8fe28bd6b5:Why-your-body-is-your-way-in-pe04jk']
- ['a', '30023:6830c4094f42a138b333157c130a5353b4149cc683e2c18c6e004e22ff17c655:uvU5PQxjIOE4v5dNU3z2b']
- ['a', '30023:554ab6fed675985d6b99b79ad7b784ff8ca4a5b02778812e1968eee0c6cbc27e:Health-Benefits-in-Nature-5eptsc']
- ['a', '30023:db01672ec0b5219ca521f5954f99ea3560e34fc427f47b9416486c3d9f775028:1743584409108']
- ['a', '30023:7d33ba57d8a6e8869a1f1d5215254597594ac0dbfeb01b690def8c461b82db35:DGK40bomu58dGKNVzFioK']
- ['a', '30023:6ad3e2a34818b153c81f48c58f44e5199e7b4fc8dbe37810a000dce3c90b7740:igQitdH5ol42zbap8pGSd']
- ['a', '30023:378562cd20849dce3b74d85bc3e72c84f8ab59e94aa29650e1ad1b47a6fc6773:Life-Beyond-Bitcoin-2hm81r']
- ['a', '30023:378562cd20849dce3b74d85bc3e72c84f8ab59e94aa29650e1ad1b47a6fc6773:The-Benefits-of-Hiking-in-Nature-pcvs1q']
- ['a', '30023:0b118e40d6f3dfabb17f21a94a647701f140d8b063a9e84fe6e483644edc09cb:1742976772735']

26
publication/Newsroom/Reflections/reflections.yaml

@ -0,0 +1,26 @@
tags:
- ['d', 'newsroom-magazine-by-newsroom-cat-reflections']
- ['type', 'magazine']
- ['l', 'en, ISO-639-1']
- ['reading-direction', 'left-to-right, top-to-bottom']
- ['t', 'philosophy']
- ['t', 'religion']
- ['t', 'spirituality']
- ['title', 'Reflections']
- ['summary', 'Religion, philosophy and spirituality']
- ['published_by', 'Newsroom magazine']
- [ 'p', '7c3bd3d882304e1fb21bbe1d3eb914a6152c747f315fbed6893e99f7119048ef' ]
- ['a', '30023:36f96bcb0bb24a522ce2c2e867cc3548ea4c48f951ae49d24fa30f742ff3888b:sovereign-stoic-why-mastering-yourself-and-your-money-is-the-only-way-forward']
- ['a' , '30023:96859d1496ab212d80629b3929fdb8fda7850f7fdd0b31fb265e6844b06bf54d:the-fire-in-the-freezing-forest']
- ['a', '30023:16d114303d8203115918ca34a220e925c022c09168175a5ace5e9f3b61640947:8c5cf16c40358825']
- ['a', '30023:d57360cb86796680f1af1830d7c33834fe7b57c580e1c598eab89e794fe7d935:Dreams-vs-Reality-806ivo']
- ['a', '30023:8d34bd2432240c5637174a3db191878baa1c133aec739b64a264259f414be32b:Miserable-Comforters-Are-You-6kanl1']
- ['a', '30023:6ad3e2a34818b153c81f48c58f44e5199e7b4fc8dbe37810a000dce3c90b7740:Science-0iucu9']
- ['a', '30023:0c65eba8a81341439ac132a0fec38121ce7d68d89e56a5c2b6e88d784a08ef9a:the-real-goal-of-parenting:-raising-the-parents-of-your-grandchildren-']
- ['a', '30023:6e0ea5d6ad5334b4e83354f06bbc1bcd90c09a8576e52bb58b7bfa9b0327f353:1746369859906']
- ['a', '30023:8d34bd2432240c5637174a3db191878baa1c133aec739b64a264259f414be32b:Getting-to-Know-God-Through-the-Bible-hxj284']
- ['a', '30023:f1f5954911a1c286c04be6409b773703a7c5c246607b51527517c58af4121cfe:Spiritual-Arrogance-co4zoh']
- ['a', '30023:554ab6fed675985d6b99b79ad7b784ff8ca4a5b02778812e1968eee0c6cbc27e:0MD-jaH3Nk_VeouWXNWVB']
- ['a', '30023:e111a40578d91eff049a350070c17536cc447cff9ec8c4abfe45d810fa441558:1743962847753']
- ['a', '30023:378562cd20849dce3b74d85bc3e72c84f8ab59e94aa29650e1ad1b47a6fc6773:Living-a-Godly-Life-in-an-Ungodly-World-pv4d5u']
- ['a', '30023:c1e9ab3a56a2ab6ca4bebf44ea64b2fda40ac6311e886ba86b4652169cb56b43:c0d4f9fc46fe7b39']

14
publication/Newsroom/newsroom.yaml

@ -0,0 +1,14 @@
tags:
- ['d', 'newsroom-magazine-by-newsroom']
- ['type', 'magazine']
- ['l', 'en, ISO-639-1']
- ['reading-direction', 'left-to-right, top-to-bottom']
- ['title', 'Newsroom magazine']
- ['summary', 'The first curated magazine of open content']
- ['published_by', 'Newsroom magazine']
- ['p', '7c3bd3d882304e1fb21bbe1d3eb914a6152c747f315fbed6893e99f7119048ef']
- ['a', '30040:7c3bd3d882304e1fb21bbe1d3eb914a6152c747f315fbed6893e99f7119048ef:newsroom-magazine-by-newsroom-cat-insight']
- ['a', '30040:7c3bd3d882304e1fb21bbe1d3eb914a6152c747f315fbed6893e99f7119048ef:newsroom-magazine-by-newsroom-cat-digital']
- ['a', '30040:7c3bd3d882304e1fb21bbe1d3eb914a6152c747f315fbed6893e99f7119048ef:newsroom-magazine-by-newsroom-cat-lifestyle']
- ['a', '30040:7c3bd3d882304e1fb21bbe1d3eb914a6152c747f315fbed6893e99f7119048ef:newsroom-magazine-by-newsroom-cat-reflections']
- ['a', '30040:7c3bd3d882304e1fb21bbe1d3eb914a6152c747f315fbed6893e99f7119048ef:newsroom-magazine-by-newsroom-cat-arts']

69
src/Command/QualityCheckArticlesCommand.php

@ -1,69 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Article;
use App\Enum\IndexStatusEnum;
use Doctrine\ORM\EntityManagerInterface;
use FOS\ElasticaBundle\Persister\ObjectPersister;
use FOS\ElasticaBundle\Persister\ObjectPersisterInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'articles:qa', description: 'Mark articles by quality and select which to index')]
class QualityCheckArticlesCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $entityManager
)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$articles = $this->entityManager->getRepository(Article::class)->findBy(['indexStatus' => IndexStatusEnum::NOT_INDEXED]);
$count = 0;
foreach ($articles as $article) {
if ($this->meetsCriteria($article)) {
$count += 1;
$article->setIndexStatus(IndexStatusEnum::TO_BE_INDEXED);
} else {
$article->setIndexStatus(IndexStatusEnum::DO_NOT_INDEX);
}
$this->entityManager->persist($article);
}
$this->entityManager->flush();
$output->writeln($count . ' articles marked for indexing successfully.');
return Command::SUCCESS;
}
private function meetsCriteria(Article $article): bool
{
$content = $article->getContent();
// No empty title
if (empty($article->getTitle()) || strtolower($article->getTitle()) === 'test') {
return false;
}
// Do not index stacker news reposts
if (str_contains($content, 'originally posted at https://stacker.news')) {
return false;
}
// Slug must not contain '/' and should not be empty
if (empty($article->getSlug()) || str_contains($article->getSlug(), '/')) {
return false;
}
// Only index articles with more than 12 words
return str_word_count($article->getContent()) > 12;
}
}

259
src/Controller/NzineController.php

@ -1,259 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Article;
use App\Entity\Event as EventEntity;
use App\Entity\Nzine;
use App\Entity\User;
use App\Enum\KindsEnum;
use App\Form\NzineBotType;
use App\Form\NzineType;
use App\Service\EncryptionService;
use App\Service\NostrClient;
use App\Service\NzineWorkflowService;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Exception;
use swentel\nostr\Event\Event;
use swentel\nostr\Sign\Sign;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\String\Slugger\AsciiSlugger;
class NzineController extends AbstractController
{
/**
* @throws \JsonException
*/
#[Route('/nzine', name: 'nzine_index')]
public function index(Request $request, NzineWorkflowService $nzineWorkflowService, EntityManagerInterface $entityManager): Response
{
$form = $this->createForm(NzineBotType::class);
$form->handleRequest($request);
$user = $this->getUser();
$nzine = $entityManager->getRepository(Nzine::class)->findAll();
if ($form->isSubmitted() && $form->isValid()) {
$data = $form->getData();
// init object
$nzine = $nzineWorkflowService->init();
// create bot and nzine, save to persistence
$nzine = $nzineWorkflowService->createProfile($nzine, $data['name'], $data['about'], $user);
// create main index
$nzineWorkflowService->createMainIndex($nzine, $data['name'], $data['about']);
return $this->redirectToRoute('nzine_edit', ['npub' => $nzine->getNpub() ]);
}
return $this->render('pages/nzine-editor.html.twig', [
'form' => $form
]);
}
#[Route('/nzine/{npub}', name: 'nzine_edit')]
public function edit(Request $request, $npub, EntityManagerInterface $entityManager,
EncryptionService $encryptionService,
ManagerRegistry $managerRegistry, NostrClient $nostrClient): Response
{
$nzine = $entityManager->getRepository(Nzine::class)->findOneBy(['npub' => $npub]);
if (!$nzine) {
throw $this->createNotFoundException('N-Zine not found');
}
try {
$bot = $entityManager->getRepository(User::class)->findOneBy(['npub' => $npub]);
} catch (Exception $e) {
// sth went wrong, but whatever
$managerRegistry->resetManager();
}
// existing index
$indices = $entityManager->getRepository(EventEntity::class)->findBy(['pubkey' => $npub, 'kind' => KindsEnum::PUBLICATION_INDEX]);
$mainIndexCandidates = array_filter($indices, function ($index) use ($nzine) {
return $index->getSlug() == $nzine->getSlug();
});
$mainIndex = array_pop($mainIndexCandidates);
if (empty($mainIndex)) {
throw $this->createNotFoundException('Main index not found');
}
$catForm = $this->createForm(NzineType::class, ['categories' => $nzine->getMainCategories()]);
$catForm->handleRequest($request);
if ($catForm->isSubmitted() && $catForm->isValid()) {
// Process and normalize the 'tags' field
$data = $catForm->get('categories')->getData();
$nzine->setMainCategories($data);
try {
$entityManager->beginTransaction();
$entityManager->persist($nzine);
$entityManager->flush();
$entityManager->commit();
} catch (Exception $e) {
$entityManager->rollback();
$managerRegistry->resetManager();
}
$catIndices = [];
$bot = $nzine->getNzineBot();
$bot->setEncryptionService($encryptionService);
$private_key = $bot->getNsec(); // decrypted en route
foreach ($data as $cat) {
// check if such an index exists, only create new cats
$id = array_filter($indices, function ($k) use ($cat) {
return $cat['title'] === $k->getTitle();
});
if (!empty($id)) { continue; }
// create new index
// currently not possible to edit existing, because there is no way to tell what has changed
// and which is the corresponding event
$slugger = new AsciiSlugger();
$title = $cat['title'];
$slug = $mainIndex->getSlug().'-'.$slugger->slug($title)->lower();
// create category index
$index = new Event();
$index->setKind(KindsEnum::PUBLICATION_INDEX->value);
$index->addTag(['d' => $slug]);
$index->addTag(['title' => $title]);
$index->addTag(['auto-update' => 'yes']);
$index->addTag(['type' => 'magazine']);
foreach ($cat['tags'] as $tag) {
$index->addTag(['t' => $tag]);
}
$index->setPublicKey($nzine->getNpub());
$signer = new Sign();
$signer->signEvent($index, $private_key);
// save to persistence, first map to EventEntity
$serializer = new Serializer([new ObjectNormalizer()],[new JsonEncoder()]);
$i = $serializer->deserialize($index->toJson(), EventEntity::class, 'json');
// don't save any more for now
$entityManager->persist($i);
$entityManager->flush();
// TODO publish index to relays
$catIndices[] = $index;
}
// add the new and updated indices to the main index
foreach ($catIndices as $idx) {
//remove e tags and add new
// $tags = array_splice($mainIndex->getTags(), -3);
// $mainIndex->setTags($tags);
// TODO add relay hints
$mainIndex->addTag(['a' => KindsEnum::PUBLICATION_INDEX->value .':'. $idx->getPublicKey() .':'. $idx->getSlug()]);
// $mainIndex->addTag(['e' => $idx->getId() ]);
}
// re-sign main index and save to relays
// $signer = new Sign();
// $signer->signEvent($mainIndex, $private_key);
// for now, just save new index
$entityManager->flush();
// redirect to route nzine_view
return $this->redirectToRoute('nzine_view', [
'npub' => $nzine->getNpub(),
]);
}
return $this->render('pages/nzine-editor.html.twig', [
'nzine' => $nzine,
'indices' => $indices,
'bot' => $bot ?? null, // if null, the profile for the bot doesn't exist yet
'catForm' => $catForm
]);
}
/**
* Update and (re)publish indices,
* when you want to look for new articles or
* when categories have changed
* @return void
*/
#[Route('/nzine/{npub}', name: 'nzine_update')]
public function nzineUpdate()
{
// TODO make this a separate step and publish all the indices and populate with articles all at once
}
#[Route('/nzine/v/{npub}', name: 'nzine_view')]
public function nzineView($npub, EntityManagerInterface $entityManager): Response
{
$nzine = $entityManager->getRepository(Nzine::class)->findOneBy(['npub' => $npub]);
if (!$nzine) {
throw $this->createNotFoundException('N-Zine not found');
}
// Find all index events for this nzine
$indices = $entityManager->getRepository(EventEntity::class)->findBy(['pubkey' => $npub, 'kind' => KindsEnum::PUBLICATION_INDEX]);
$mainIndexCandidates = array_filter($indices, function ($index) use ($nzine) {
return $index->getSlug() == $nzine->getSlug();
});
dump($indices);die();
$mainIndex = array_pop($mainIndexCandidates);
return $this->render('pages/nzine.html.twig', [
'nzine' => $nzine,
'index' => $mainIndex,
'events' => $indices, // TODO traverse all and collect all leaves
]);
}
#[Route('/nzine/v/{npub}/{cat}', name: 'nzine_category')]
public function nzineCategory($npub, $cat, EntityManagerInterface $entityManager): Response
{
$nzine = $entityManager->getRepository(Nzine::class)->findOneBy(['npub' => $npub]);
if (!$nzine) {
throw $this->createNotFoundException('N-Zine not found');
}
$bot = $entityManager->getRepository(User::class)->findOneBy(['npub' => $npub]);
$tags = [];
foreach ($nzine->getMainCategories() as $category) {
if (isset($category['title']) && $category['title'] === $cat) {
$tags = $category['tags'] ?? [];
}
}
$all = $entityManager->getRepository(Article::class)->findAll();
$list = array_slice($all, 0, 100);
$filtered = [];
foreach ($tags as $tag) {
$partial = array_filter($list, function($v) use ($tag) {
/* @var Article $v */
return in_array($tag, $v->getTopics() ?? []);
});
$filtered = array_merge($filtered, $partial);
}
return $this->render('pages/nzine.html.twig', [
'nzine' => $nzine,
'bot' => $bot,
'list' => $filtered
]);
}
}

141
src/Entity/Nzine.php

@ -1,141 +0,0 @@
<?php
namespace App\Entity;
use App\Repository\NzineRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: NzineRepository::class)]
class Nzine
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $npub = null;
#[ORM\OneToOne(targetEntity: NzineBot::class)]
#[ORM\JoinColumn(nullable: true)]
private ?NzineBot $nzineBot = null;
#[ORM\Column(type: Types::JSON)]
private array|ArrayCollection $mainCategories;
#[ORM\Column(nullable: true)]
private ?array $lists = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $editor = null;
/**
* Slug (d-tag) of the main index event that contains all the main category indices
* @var string|null
*/
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $slug = null;
#[ORM\Column(type: Types::STRING)]
private string $state = 'draft';
public function __construct()
{
$this->mainCategories = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getNpub(): ?string
{
return $this->npub;
}
public function setNpub(string $npub): static
{
$this->npub = $npub;
return $this;
}
public function getNsec(): ?string
{
return $this->nsec;
}
public function setNsec(?string $nsec): void
{
$this->nsec = $nsec;
}
public function getMainCategories(): array
{
return $this->mainCategories;
}
public function setMainCategories(array $mainCategories): static
{
$this->mainCategories = $mainCategories;
return $this;
}
public function getLists(): ?array
{
return $this->lists;
}
public function setLists(?array $lists): static
{
$this->lists = $lists;
return $this;
}
public function getEditor(): ?string
{
return $this->editor;
}
public function setEditor(?string $editor): static
{
$this->editor = $editor;
return $this;
}
public function getNzineBot(): ?NzineBot
{
return $this->nzineBot;
}
public function setNzineBot(?NzineBot $nzineBot): void
{
$this->nzineBot = $nzineBot;
}
public function getSlug(): ?string
{
return $this->slug;
}
public function setSlug(?string $slug): void
{
$this->slug = $slug;
}
public function getState(): string
{
return $this->state;
}
public function setState(string $state): void
{
$this->state = $state;
}
}

51
src/Entity/NzineBot.php

@ -1,51 +0,0 @@
<?php
namespace App\Entity;
use App\Service\EncryptionService;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Ignore;
#[ORM\Entity]
class NzineBot
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: Types::INTEGER)]
private ?int $id = null;
private ?EncryptionService $encryptionService = null;
#[ORM\Column(type: Types::STRING, length: 255)]
private ?string $encryptedNsec = null;
#[Ignore]
private ?string $nsec = null;
public function setEncryptionService(EncryptionService $encryptionService): void
{
$this->encryptionService = $encryptionService;
}
public function getId(): ?int
{
return $this->id;
}
public function getNsec(): ?string
{
if (null === $this->nsec && null !== $this->encryptedNsec) {
$this->nsec = $this->encryptionService->decrypt($this->encryptedNsec);
}
return $this->nsec;
}
public function setNsec(?string $nsec): self
{
$this->nsec = $nsec;
$this->encryptedNsec = $this->encryptionService->encrypt($nsec);
return $this;
}
}

11
src/Enum/IndexStatusEnum.php

@ -1,11 +0,0 @@
<?php
namespace App\Enum;
enum IndexStatusEnum: int
{
case NOT_INDEXED = 0;
case TO_BE_INDEXED = 1;
case INDEXED = 2;
case DO_NOT_INDEX = 3;
}

32
src/Form/NzineBotType.php

@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class NzineBotType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'required' => true
])
->add('about', TextareaType::class, [
'required' => false
])
->add('submit', SubmitType::class)
;
}
public function configureOptions(OptionsResolver $resolver)
{
}
}

30
src/Form/NzineType.php

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class NzineType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('categories', CollectionType::class, [
'entry_type' => MainCategoryType::class,
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'prototype' => true, // Enables the JavaScript prototype feature
'label' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
}
}

43
src/Repository/NzineRepository.php

@ -1,43 +0,0 @@
<?php
namespace App\Repository;
use App\Entity\Nzine;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Nzine>
*/
class NzineRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Nzine::class);
}
// /**
// * @return Nzine[] Returns an array of Nzine objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('j')
// ->andWhere('j.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('j.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?Nzine
// {
// return $this->createQueryBuilder('j')
// ->andWhere('j.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

147
src/Service/NzineWorkflowService.php

@ -1,147 +0,0 @@
<?php
namespace App\Service;
use App\Entity\Nzine;
use App\Entity\NzineBot;
use App\Entity\Event as EventEntity;
use App\Entity\User;
use App\Enum\KindsEnum;
use App\Enum\RolesEnum;
use swentel\nostr\Event\Event;
use swentel\nostr\Key\Key;
use swentel\nostr\Sign\Sign;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\String\Slugger\AsciiSlugger;
use Symfony\Component\Workflow\WorkflowInterface;
use Doctrine\ORM\EntityManagerInterface;
class NzineWorkflowService
{
private Nzine $nzine;
public function __construct(private readonly WorkflowInterface $nzineWorkflow,
private readonly NostrClient $nostrClient,
private readonly EncryptionService $encryptionService,
private readonly EntityManagerInterface $entityManager)
{
}
public function init($nzine = null): Nzine
{
if (!is_null($nzine)) {
$this->nzine = $nzine;
} else {
$this->nzine = new Nzine();
}
return $this->nzine;
}
public function createProfile($nzine, $name, $about, $user): Nzine
{
if (!$this->nzineWorkflow->can($nzine, 'create_profile')) {
throw new \LogicException('Cannot create profile in the current state.');
}
$this->nzine = $nzine;
// create NZine bot
$key = new Key();
$private_key = $key->generatePrivateKey();
$bot = new NzineBot();
$bot->setEncryptionService($this->encryptionService);
$bot->setNsec($private_key);
$this->entityManager->persist($bot);
$this->entityManager->flush();
// publish bot profile
$profileContent = [
'name' => $name,
'about' => $about,
'bot' => true
];
$profileEvent = new Event();
$profileEvent->setKind(KindsEnum::METADATA->value);
$profileEvent->setContent(json_encode($profileContent));
$signer = new Sign();
$signer->signEvent($profileEvent, $private_key);
// $this->nostrClient->publishEvent($profileEvent, ['wss://purplepag.es']);
// add EDITOR role to the user
$role = RolesEnum::EDITOR->value;
$user = $this->entityManager->getRepository(User::class)->findOneBy(['npub' => $user->getUserIdentifier()]);
$user->addRole($role);
$this->entityManager->persist($user);
// create NZine entity
$public_key = $key->getPublicKey($private_key);
$this->nzine->setNpub($public_key);
$this->nzine->setNzineBot($bot);
$this->nzine->setEditor($user->getUserIdentifier());
$this->nzineWorkflow->apply($this->nzine, 'create_profile');
$this->entityManager->persist($this->nzine);
$this->entityManager->flush();
return $this->nzine;
}
/**
* @throws \JsonException
*/
public function createMainIndex(Nzine $nzine, string $title, string $summary): void
{
if (!$this->nzineWorkflow->can($nzine, 'create_main_index')) {
// throw new \LogicException('Cannot create main index in the current state.');
}
$bot = $nzine->getNzineBot();
$private_key = $bot->getNsec();
$slugger = new AsciiSlugger();
$slug = 'nzine-'.$slugger->slug($title)->lower().'-'.rand(10000,99999);
// save slug to nzine
$nzine->setSlug($slug);
// create NZine main index
$index = new Event();
$index->setKind(KindsEnum::PUBLICATION_INDEX->value);
$index->addTag(['d' => $slug]);
$index->addTag(['title' => $title]);
$index->addTag(['summary' => $summary]);
$index->addTag(['auto-update' => 'yes']);
$index->addTag(['type' => 'magazine']);
$signer = new Sign();
$signer->signEvent($index, $private_key);
// save to persistence, first map to EventEntity
$serializer = new Serializer([new ObjectNormalizer()],[new JsonEncoder()]);
$i = $serializer->deserialize($index->toJson(), EventEntity::class, 'json');
$this->entityManager->persist($i);
$this->nzineWorkflow->apply($nzine, 'create_main_index');
$this->entityManager->flush();
}
public function createNestedIndex(Nzine $nzine, string $categoryTitle, array $tags): void
{
if (!$this->nzineWorkflow->can($nzine, 'create_nested_indices')) {
throw new \LogicException('Cannot create nested indices in the current state.');
}
// Example logic: Create a nested index for the category
$nestedIndex = new EventEntity();
// $nestedIndex->setTitle($categoryTitle);
$nestedIndex->setTags($tags);
$nestedIndex->setKind('30040'); // Assuming 30040 is the kind for publication indices
$this->entityManager->persist($nestedIndex);
$this->nzineWorkflow->apply($nzine, 'create_nested_indices');
$this->entityManager->persist($nzine);
$this->entityManager->flush();
}
}

37
src/Twig/Components/Organisms/ZineList.php

@ -1,37 +0,0 @@
<?php
namespace App\Twig\Components\Organisms;
use App\Entity\Event;
use App\Entity\Nzine;
use App\Enum\KindsEnum;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class ZineList
{
public array $nzines = [];
public array $indices = [];
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
public function mount(?array $nzines = null): void
{
$this->nzines = $nzines ?? $this->entityManager->getRepository(Nzine::class)->findAll();
if (count($this->nzines) > 0) {
// find indices for each nzine
foreach ($this->nzines as $zine) {
$ids = $this->entityManager->getRepository(Event::class)->findBy(['pubkey' => $zine->getNpub(), 'kind' => KindsEnum::PUBLICATION_INDEX]);
$id = array_filter($ids, function($k) use ($zine) {
return $k->getSlug() == $zine->getSlug();
});
if ($id) {
$this->indices[$zine->getNpub()] = array_pop($id);
}
}
}
}
}

16
templates/components/Organisms/ZineList.html.twig

@ -1,16 +0,0 @@
<div {{ attributes }}>
{% for item in nzines %}
{% if item.npub in indices|keys %}
{% set idx = indices[item.npub] %}
{% if idx|length > 0 %}
<a class="card" href="{{ path('nzine_view', { npub: item.npub })}}">
<div class="card-body">
<h3 class="card-title">{{ idx.title }}</h3>
<p class="hidden">{{ idx.summary }}</p>
</div>
</a>
<br>
{% endif %}
{% endif %}
{% endfor %}
</div>

3
templates/components/UserMenu.html.twig

@ -14,9 +14,6 @@
<ul class="user-nav"> <ul class="user-nav">
{# <li>#} {# <li>#}
{# <a href="{{ path('editor-create') }}">Write an article</a>#} {# <a href="{{ path('editor-create') }}">Write an article</a>#}
{# </li>#}
{# <li>#}
{# <a href="{{ path('nzine_index') }}">{{ 'heading.createNzine'|trans }}</a>#}
{# </li>#} {# </li>#}
<li> <li>
<a href="{{ path('app_search_index') }}">{{ 'heading.search'|trans }}</a> <a href="{{ path('app_search_index') }}">{{ 'heading.search'|trans }}</a>

10
templates/pages/author.html.twig

@ -52,20 +52,10 @@
{# });#} {# });#}
{# </script>#} {# </script>#}
{# </div>#} {# </div>#}
{# {% endif %}#}
{# {% if nzine %}#}
{# <a href="{{ path('nzine_view', {npub: author.npub}) }}">View as N-Zine</a>#}
{# <h2>List of indices</h2>#}
{# {% for i in idx %}#}
{# {{ i.title }}#}
{# {% endfor %}#}
{# {% endif %}#} {# {% endif %}#}
<twig:Organisms:CardList :list="articles" class="article-list"></twig:Organisms:CardList> <twig:Organisms:CardList :list="articles" class="article-list"></twig:Organisms:CardList>
{% endblock %} {% endblock %}
{% block aside %} {% block aside %}
{# <twig:Organisms:ZineList :nzines="nzines" />#}
{% endblock %} {% endblock %}

66
templates/pages/nzine-editor.html.twig

@ -1,66 +0,0 @@
{% extends 'base.html.twig' %}
{% block body %}
{% if nzine is not defined %}
<h1>{{ 'heading.createNzine'|trans }}</h1>
<twig:Atoms:Alert >N-Zines are in active development. Expect weirdness.</twig:Atoms:Alert>
<p class="lede">
An N-Zine is a digital magazine definition for
collecting long form articles from the <em>nostr</em> ecosystem according to specified filters.
The N-Zine can then be read and browsed as a traditional digital magazine made available on this platform.
Additionally, it can be subscribed to using the <em>nostr</em> bot which will be generated during the setup process.
Your currently logged-in <em>npub</em> will be assigned to the N-Zine as an editor, so you can come back later and tweak the filters.
</p>
<h2>N-Zine Details</h2>
<p>
Choose a title and write a description for your N-Zine.
A profile for your N-Zine bot will also be created.
The bot will publish an update when a new article is found that matches N-Zine's filters.
<br>
<small>We know it's lame, but right now we cannot automatically update your follows to include the N-Zine bot.</small>
</p>
{{ form_start(form) }}
{{ form_end(form) }}
{% else %}
<h1>{{ 'heading.editNzine'|trans }}</h1>
<h2>Indices</h2>
<ul>
{% for idx in indices %}
<li>{{ idx.title }}</li>
{% endfor %}
</ul>
<h2>Categories</h2>
<p>
Create and edit categories. You can have as many as you like. Aim at up to 9 for the sake of your readers.
</p>
{{ form_start(catForm) }}
<ul class="tags">
{% for cat in catForm.categories %}
<li>{{ form_widget(cat) }}</li>
{% endfor %}
</ul>
<div {{ stimulus_controller('form-collection') }}
data-form-collection-index-value="{{ catForm.categories|length > 0 ? catForm.categories|last.vars.name + 1 : 0 }}"
data-form-collection-prototype-value="{{ form_widget(catForm.categories.vars.prototype)|e('html_attr') }}"
>
<ul {{ stimulus_target('form-collection', 'collectionContainer') }}></ul>
<button type="button" {{ stimulus_action('form-collection', 'addCollectionElement') }}>Add item</button>
</div>
<button class="btn btn-primary">Save</button>
{{ form_end(catForm) }}
{% endif %}
{% endblock %}

16
templates/pages/nzine.html.twig

@ -1,16 +0,0 @@
{% extends 'base.html.twig' %}
{% block body %}
<div>
<h1>{{ index.title }}</h1>
<p>{{ index.summary }}</p>
<br>
<twig:IndexTabs :index="index" />
</div>
{% endblock %}
{% block aside %}
{# <p>TODO search & add to index</p> #}
{% endblock %}

2
translations/messages.en.yaml

@ -7,7 +7,5 @@ heading:
roles: 'Roles' roles: 'Roles'
logout: 'Log out' logout: 'Log out'
logIn: 'Log in' logIn: 'Log in'
createNzine: 'Create an N-Zine'
editNzine: 'Edit your N-Zine'
search: 'Search' search: 'Search'
index: 'Index' index: 'Index'

Loading…
Cancel
Save