From 495f07ee9d06465c75eb1fba91c3bc15376ee480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Sun, 2 Feb 2025 17:33:37 +0100 Subject: [PATCH] Introduce workflows, make saving to db more resilient --- assets/styles/app.css | 6 + config/packages/workflow.yaml | 30 +++- migrations/Version20250105181626.php | 33 ++++ migrations/Version20250202160116.php | 31 ++++ src/Controller/NzineController.php | 151 +++++------------- src/Entity/Article.php | 2 +- src/Entity/Nzine.php | 13 ++ src/Enum/KindsEnum.php | 2 + src/Enum/RolesEnum.php | 10 ++ src/Form/EditorType.php | 10 +- src/Form/MainCategoryType.php | 2 +- src/Repository/UserEntityRepository.php | 5 +- src/Service/ArticleWorkflowService.php | 9 ++ src/Service/NostrClient.php | 2 + src/Service/NzineWorkflowService.php | 142 ++++++++++++++++ .../NostrEventRenderer.php | 2 +- 16 files changed, 329 insertions(+), 121 deletions(-) create mode 100644 migrations/Version20250105181626.php create mode 100644 migrations/Version20250202160116.php create mode 100644 src/Enum/RolesEnum.php create mode 100644 src/Service/ArticleWorkflowService.php create mode 100644 src/Service/NzineWorkflowService.php diff --git a/assets/styles/app.css b/assets/styles/app.css index 29a08f0..f32a1fa 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -364,3 +364,9 @@ footer p { padding: 15px; /* Spacing inside the content */ border-top: none; /* Remove border overlap with active tab */ } + +/* Quill editor */ +#editor { + height: 400px; + margin-bottom: 20px; +} diff --git a/config/packages/workflow.yaml b/config/packages/workflow.yaml index 97316ba..aad92b3 100644 --- a/config/packages/workflow.yaml +++ b/config/packages/workflow.yaml @@ -35,6 +35,30 @@ framework: edit: from: published to: edited - re-edit: - from: 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 diff --git a/migrations/Version20250105181626.php b/migrations/Version20250105181626.php new file mode 100644 index 0000000..e1b8e1b --- /dev/null +++ b/migrations/Version20250105181626.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE nzine ADD state VARCHAR(255) DEFAULT NULL'); + $this->addSql("UPDATE nzine SET state = 'draft'"); + $this->addSql('ALTER TABLE nzine ALTER COLUMN state SET NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE nzine DROP state'); + } +} diff --git a/migrations/Version20250202160116.php b/migrations/Version20250202160116.php new file mode 100644 index 0000000..5c120e0 --- /dev/null +++ b/migrations/Version20250202160116.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE article ALTER title TYPE TEXT'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE article ALTER title TYPE VARCHAR(225)'); + } +} diff --git a/src/Controller/NzineController.php b/src/Controller/NzineController.php index ff9eeae..4129228 100644 --- a/src/Controller/NzineController.php +++ b/src/Controller/NzineController.php @@ -7,21 +7,17 @@ namespace App\Controller; use App\Entity\Article; use App\Entity\Event as EventEntity; use App\Entity\Nzine; -use App\Entity\NzineBot; use App\Entity\User; use App\Enum\KindsEnum; -use App\Enum\RolesEnum; 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\Key\Key; -use swentel\nostr\Message\EventMessage; -use swentel\nostr\Relay\Relay; use swentel\nostr\Sign\Sign; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -38,92 +34,23 @@ class NzineController extends AbstractController * @throws \JsonException */ #[Route('/nzine', name: 'nzine_index')] - public function index(Request $request, EncryptionService $encryptionService, EntityManagerInterface $entityManager): Response + public function index(Request $request, NzineWorkflowService $nzineWorkflowService): Response { $form = $this->createForm(NzineBotType::class); $form->handleRequest($request); $user = $this->getUser(); - // TODO change into a workflow if ($form->isSubmitted() && $form->isValid()) { $data = $form->getData(); - // create NZine bot - $key = new Key(); - $private_key = '8c55771e896581fffea62c6440e306d502630e9dbd067e484bf6fc9c83ede28c'; - // $private_key = $key->generatePrivateKey(); - // $bot = new NzineBot($encryptionService); - // $bot->setNsec($private_key); - $bot = $entityManager->getRepository(NzineBot::class)->find(1); - //$entityManager->persist($bot); - //$entityManager->flush(); - $profileContent = [ - 'name' => $data['name'], - 'about' => $data['about'], - 'bot' => true - ]; - - // publish bot profile - $profileEvent = new Event(); - $profileEvent->setKind(0); - $profileEvent->setContent(json_encode($profileContent)); - $signer = new Sign(); - $signer->signEvent($profileEvent, $private_key); - $eventMessage = new EventMessage($profileEvent); - $relayUrl = 'wss://purplepag.es'; - $relay = new Relay($relayUrl); - $relay->setMessage($eventMessage); - // $result = $relay->send(); - - // create NZine entity - $nzine = new Nzine(); - $public_key = $key->getPublicKey($private_key); - $nzine->setNpub($public_key); - $nzine->setNzineBot($bot); - $nzine->setEditor($user->getUserIdentifier()); - // $entityManager->persist($nzine); - // $entityManager->flush(); - - // TODO add EDITOR role to the user - $role = RolesEnum::EDITOR->value; - $user = $entityManager->getRepository(User::class)->findOneBy(['npub' => $user->getUserIdentifier()]); - $user->addRole($role); - // $entityManager->persist($user); - // $entityManager->flush(); - - - $slugger = new AsciiSlugger(); - $title = $profileContent['name']; - $slug = 'nzine-'.$slugger->slug($title)->lower().'-'.rand(10000,99999); - // create NZine main index - $index = new Event(); - $index->setKind(KindsEnum::PUBLICATION_INDEX->value); - - $index->addTag(['d' => $slug]); - $index->addTag(['title' => $title]); - $index->addTag(['summary' => $profileContent['about']]); - $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'); - // don't save any more for now - $entityManager->persist($i); - // $entityManager->flush(); - // TODO publish index to relays - - // TODO remove this, this is temporary, to not create a host of nzines - $nzine = $entityManager->getRepository(Nzine::class)->findOneBy(['npub' => $nzine->getNpub()]); - $nzine->setSlug($slug); - $entityManager->persist($nzine); - $entityManager->flush(); - - return $this->redirectToRoute('nzine_edit', ['npub' => $public_key ]); + // 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() ]); } - // on submit, create a key pair and save it securely - // create a new NZine entity and link it to the key pair - // then redirect to edit return $this->render('pages/nzine-editor.html.twig', [ 'form' => $form @@ -132,6 +59,7 @@ class NzineController extends AbstractController #[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]); @@ -140,11 +68,22 @@ class NzineController extends AbstractController } try { $bot = $entityManager->getRepository(User::class)->findOneBy(['npub' => $npub]); - } catch (\Exception $e) { + } 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()) { @@ -153,20 +92,16 @@ class NzineController extends AbstractController $nzine->setMainCategories($data); -// try { -// $entityManager->beginTransaction(); -// $entityManager->persist($nzine); -// $entityManager->flush(); -// $entityManager->commit(); -// } catch (Exception $e) { -// $entityManager->rollback(); -// $managerRegistry->resetManager(); -// } - - // existing indices - $indices = $entityManager->getRepository(EventEntity::class)->findBy(['pubkey' => $npub, 'kind' => KindsEnum::PUBLICATION_INDEX]); - // get oldest, treat it as root - $mainIndex = $indices[0]; + try { + $entityManager->beginTransaction(); + $entityManager->persist($nzine); + $entityManager->flush(); + $entityManager->commit(); + } catch (Exception $e) { + $entityManager->rollback(); + $managerRegistry->resetManager(); + } + // TODO create and update indices foreach ($data as $cat) { // find or create new index @@ -185,7 +120,9 @@ class NzineController extends AbstractController $signer = new Sign(); // TODO get key - // $signer->signEvent($index, $private_key); + + $private_key = $encryptionService->decrypt($nzine->getNsec()); + $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'); @@ -197,15 +134,16 @@ class NzineController extends AbstractController // TODO add the new and updated indices to the main index + // 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, 'catForm' => $catForm ]); @@ -220,25 +158,21 @@ class NzineController extends AbstractController #[Route('/nzine/{npub}', name: 'nzine_update')] public function nzineUpdate() { - // TODO make this a separate step and create all the indices and populate with articles all at once + // 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) { + 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]); - // TODO Filter out the main index by the d-tag saved to entity or something $main = $indices[0]; - // let's pretend we have some nested indices in this zine - $main->setTags(['a', '30040:'.$npub.':1'.$nzine->getSlug()]); - $main->setTags(['a', '30040:'.$npub.':2'.$nzine->getSlug()]); - return $this->render('pages/nzine.html.twig', [ 'nzine' => $nzine, @@ -247,7 +181,8 @@ class NzineController extends AbstractController } #[Route('/nzine/v/{npub}/{cat}', name: 'nzine_category')] - public function nzineCategory($npub, $cat, EntityManagerInterface $entityManager) { + 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'); diff --git a/src/Entity/Article.php b/src/Entity/Article.php index 475eb25..21743b0 100644 --- a/src/Entity/Article.php +++ b/src/Entity/Article.php @@ -39,7 +39,7 @@ class Article #[ORM\Column(nullable: true, enumType: KindsEnum::class)] private ?KindsEnum $kind = null; - #[ORM\Column(length: 225, nullable: true)] + #[ORM\Column(type: Types::TEXT, nullable: true)] private ?string $title = null; #[ORM\Column(type: Types::TEXT, nullable: true)] diff --git a/src/Entity/Nzine.php b/src/Entity/Nzine.php index ca4be60..d623359 100644 --- a/src/Entity/Nzine.php +++ b/src/Entity/Nzine.php @@ -38,6 +38,9 @@ class Nzine #[ORM\Column(type: Types::TEXT, nullable: true)] private ?string $slug = null; + #[ORM\Column(type: 'string')] + private string $state = 'draft'; + public function __construct() { $this->mainCategories = new ArrayCollection(); @@ -125,4 +128,14 @@ class Nzine { $this->slug = $slug; } + + public function getState(): string + { + return $this->state; + } + + public function setState(string $state): void + { + $this->state = $state; + } } diff --git a/src/Enum/KindsEnum.php b/src/Enum/KindsEnum.php index 992fbb6..3495141 100644 --- a/src/Enum/KindsEnum.php +++ b/src/Enum/KindsEnum.php @@ -14,10 +14,12 @@ enum KindsEnum: int case CURATION_SET = 30004; // NIP-51 case LONGFORM = 30023; // NIP-23 case LONGFORM_DRAFT = 30024; // NIP-23 + case PUBLICATION_INDEX = 30040; case CONTENT_SEARCH = 5302; case CONTENT_INDEX = 5312; case CONTENT_SEARCH_RESULT = 6302; case CONTENT_INDEX_RESULT = 6312; case HIGHLIGHTS = 9802; + case RELAY_LIST = 10002; // NIP-65, Relay list metadata case APP_DATA = 30078; // NIP-78, Arbitrary custom app data } diff --git a/src/Enum/RolesEnum.php b/src/Enum/RolesEnum.php new file mode 100644 index 0000000..78f9176 --- /dev/null +++ b/src/Enum/RolesEnum.php @@ -0,0 +1,10 @@ +add('title', TextType::class, [ 'required' => false, 'sanitize_html' => true, - 'attr' => ['placeholder' => 'Enter title', 'class' => 'form-control']]) + 'attr' => ['placeholder' => 'Awesome article', 'class' => 'form-control']]) ->add('summary', TextareaType::class, [ 'required' => false, 'sanitize_html' => true, - 'attr' => ['placeholder' => 'Enter summary', 'class' => 'form-control']]) + 'attr' => ['class' => 'form-control']]) ->add('content', QuillType::class, [ 'required' => false, 'attr' => ['placeholder' => 'Enter content', 'class' => 'form-control']]) ->add('image', UrlType::class, [ 'required' => false, - 'label' => 'Image URL', - 'attr' => ['placeholder' => 'Enter image URL', 'class' => 'form-control']]) + 'label' => 'Cover image URL', + 'attr' => ['class' => 'form-control']]) ->add('topics', TextType::class, [ 'required' => false, 'sanitize_html' => true, - 'help' => 'Separate tags with commas', + 'help' => 'Separate tags with commas, skip #', 'attr' => ['placeholder' => 'Enter tags', 'class' => 'form-control']]) ->add( $builder->create('actions', FormType::class, diff --git a/src/Form/MainCategoryType.php b/src/Form/MainCategoryType.php index bea9917..6965f5e 100644 --- a/src/Form/MainCategoryType.php +++ b/src/Form/MainCategoryType.php @@ -23,7 +23,7 @@ class MainCategoryType extends AbstractType 'label' => 'Title', ]) ->add('tags', TextType::class, [ - 'label' => 'Tags (comma-separated, no #s)', + 'label' => 'Tags (comma-separated)', ]); $builder->get('tags')->addModelTransformer($this->transformer); diff --git a/src/Repository/UserEntityRepository.php b/src/Repository/UserEntityRepository.php index cd682c4..0cdac14 100644 --- a/src/Repository/UserEntityRepository.php +++ b/src/Repository/UserEntityRepository.php @@ -17,10 +17,11 @@ class UserEntityRepository extends ServiceEntityRepository { $entity = $this->findOneBy(['npub' => $user->getNpub()]); - if (!!$entity) { + if ($entity !== null) { $user->setId($entity->getId()); + } else { + $this->entityManager->persist($user); } - $this->entityManager->persist($user); return $user; } diff --git a/src/Service/ArticleWorkflowService.php b/src/Service/ArticleWorkflowService.php new file mode 100644 index 0000000..f781c1a --- /dev/null +++ b/src/Service/ArticleWorkflowService.php @@ -0,0 +1,9 @@ +entityManager->flush(); } catch (\Exception $e) { $this->logger->error($e->getMessage()); + $this->managerRegistry->resetManager(); } } @@ -138,6 +139,7 @@ class NostrClient $this->entityManager->flush(); } catch (\Exception $e) { $this->logger->error($e->getMessage()); + $this->managerRegistry->resetManager(); } } } diff --git a/src/Service/NzineWorkflowService.php b/src/Service/NzineWorkflowService.php new file mode 100644 index 0000000..2622ac2 --- /dev/null +++ b/src/Service/NzineWorkflowService.php @@ -0,0 +1,142 @@ +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($this->encryptionService); + $bot->setNsec($private_key); + $this->entityManager->persist($bot); + // 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); + // 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->persist($nzine); + $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(); + } +} + diff --git a/src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php b/src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php index 9e6f804..a699600 100644 --- a/src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php +++ b/src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php @@ -20,7 +20,7 @@ class NostrEventRenderer implements NodeRendererInterface // Construct the local link URL from the special part $url = '/e/' . $node->getSpecial(); } else if ($node->getType() === 'naddr') { - dump($node); + // dump($node); // Construct the local link URL from the special part $url = '/' . $node->getSpecial(); }