Browse Source

Remove obsolete NZine setup

imwald
Nuša Pukšič 3 weeks ago
parent
commit
533563321e
  1. 82
      assets/controllers/publishing/nzine_magazine_publish_controller.js
  2. 198
      assets/controllers/publishing/workflow_progress_controller.js
  3. 27
      config/packages/workflow.yaml
  4. 36
      migrations/Version20251218101349.php
  5. 268
      src/Command/NzineSortArticlesCommand.php
  6. 386
      src/Command/RssFetchCommand.php
  7. 4
      src/Controller/MagazineWizardController.php
  8. 510
      src/Controller/NzineController.php
  9. 180
      src/Entity/Nzine.php
  10. 47
      src/Entity/NzineBot.php
  11. 159
      src/Examples/RssNzineSetupExample.php
  12. 56
      src/Form/NzineBotType.php
  13. 30
      src/Form/NzineType.php
  14. 72
      src/Repository/NzineRepository.php
  15. 311
      src/Service/NzineCategoryIndexService.php
  16. 147
      src/Service/NzineWorkflowService.php
  17. 5
      templates/components/UserMenu.html.twig
  18. 57
      templates/nzine/list.html.twig
  19. 151
      templates/pages/nzine-editor.html.twig
  20. 16
      templates/pages/nzine.html.twig

82
assets/controllers/publishing/nzine_magazine_publish_controller.js

@ -1,82 +0,0 @@ @@ -1,82 +0,0 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['status', 'publishButton'];
static values = {
categoryEvents: String,
magazineEvent: String,
publishUrl: String,
nzineSlug: String,
csrfToken: String
};
async publish(event) {
event.preventDefault();
if (!this.publishUrlValue || !this.csrfTokenValue || !this.nzineSlugValue) {
this.showError('Missing configuration');
return;
}
this.publishButtonTarget.disabled = true;
try {
const categoryEvents = JSON.parse(this.categoryEventsValue || '[]');
const magazineEvent = JSON.parse(this.magazineEventValue || '{}');
this.showStatus('Publishing magazine and categories...');
const response = await fetch(this.publishUrlValue, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': this.csrfTokenValue,
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({
nzineSlug: this.nzineSlugValue,
categoryEvents: categoryEvents,
magazineEvent: magazineEvent
})
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${response.status}`);
}
const result = await response.json();
this.showSuccess(result.message || 'Magazine published successfully!');
// Redirect to home or magazine page after a short delay
setTimeout(() => {
window.location.href = '/';
}, 2000);
} catch (e) {
console.error(e);
this.showError(e.message || 'Publish failed');
} finally {
this.publishButtonTarget.disabled = false;
}
}
showStatus(message) {
if (this.hasStatusTarget) {
this.statusTarget.innerHTML = `<div class="alert alert-info">${message}</div>`;
}
}
showSuccess(message) {
if (this.hasStatusTarget) {
this.statusTarget.innerHTML = `<div class="alert alert-success">${message}</div>`;
}
}
showError(message) {
if (this.hasStatusTarget) {
this.statusTarget.innerHTML = `<div class="alert alert-danger">${message}</div>`;
}
}
}

198
assets/controllers/publishing/workflow_progress_controller.js

@ -1,198 +0,0 @@ @@ -1,198 +0,0 @@
import { Controller } from '@hotwired/stimulus';
/**
* Workflow Progress Bar Controller
*
* Handles animated progress bar with color transitions and status updates.
*
* Usage:
* <div data-controller="workflow-progress"
* data-workflow-progress-percentage-value="80"
* data-workflow-progress-status-value="ready_for_review"
* data-workflow-progress-color-value="success">
* </div>
*/
export default class extends Controller {
static values = {
percentage: { type: Number, default: 0 },
status: { type: String, default: 'empty' },
color: { type: String, default: 'secondary' },
animated: { type: Boolean, default: true }
}
static targets = ['bar', 'badge', 'statusText', 'nextSteps']
connect() {
this.updateProgress();
}
percentageValueChanged() {
this.updateProgress();
}
statusValueChanged() {
this.updateStatusDisplay();
}
colorValueChanged() {
this.updateBarColor();
}
updateProgress() {
if (!this.hasBarTarget) return;
const percentage = this.percentageValue;
if (this.animatedValue) {
// Smooth animation
this.animateProgressBar(percentage);
} else {
// Instant update
this.barTarget.style.width = `${percentage}%`;
this.barTarget.setAttribute('aria-valuenow', percentage);
}
// Update accessibility
this.updateAriaLabel();
}
animateProgressBar(targetPercentage) {
const currentPercentage = parseInt(this.barTarget.style.width) || 0;
const duration = 600; // ms
const steps = 30;
const increment = (targetPercentage - currentPercentage) / steps;
const stepDuration = duration / steps;
let currentStep = 0;
const animate = () => {
if (currentStep >= steps) {
this.barTarget.style.width = `${targetPercentage}%`;
this.barTarget.setAttribute('aria-valuenow', targetPercentage);
return;
}
const newPercentage = currentPercentage + (increment * currentStep);
this.barTarget.style.width = `${newPercentage}%`;
this.barTarget.setAttribute('aria-valuenow', Math.round(newPercentage));
currentStep++;
requestAnimationFrame(() => {
setTimeout(animate, stepDuration);
});
};
animate();
}
updateBarColor() {
if (!this.hasBarTarget) return;
const colorClasses = [
'bg-secondary', 'bg-info', 'bg-primary',
'bg-success', 'bg-warning', 'bg-danger'
];
// Remove all color classes
colorClasses.forEach(cls => this.barTarget.classList.remove(cls));
// Add new color class
this.barTarget.classList.add(`bg-${this.colorValue}`);
}
updateStatusDisplay() {
if (this.hasBadgeTarget) {
const statusMessages = this.getStatusMessage(this.statusValue);
this.badgeTarget.textContent = statusMessages.short;
}
if (this.hasStatusTextTarget) {
const statusMessages = this.getStatusMessage(this.statusValue);
this.statusTextTarget.textContent = statusMessages.long;
}
}
updateAriaLabel() {
if (!this.hasBarTarget) return;
const percentage = this.percentageValue;
const statusMessages = this.getStatusMessage(this.statusValue);
const label = `${statusMessages.short}: ${percentage}% complete`;
this.barTarget.setAttribute('aria-label', label);
}
getStatusMessage(status) {
const messages = {
'empty': {
short: 'Not started',
long: 'Reading list not started yet'
},
'draft': {
short: 'Draft created',
long: 'Draft created, add content to continue'
},
'has_metadata': {
short: 'Title and summary added',
long: 'Metadata complete, add articles next'
},
'has_articles': {
short: 'Articles added',
long: 'Articles added, checking requirements'
},
'ready_for_review': {
short: 'Ready to publish',
long: 'Your reading list is ready to publish'
},
'publishing': {
short: 'Publishing...',
long: 'Publishing to Nostr, please wait'
},
'published': {
short: 'Published',
long: 'Successfully published to Nostr'
},
'editing': {
short: 'Editing',
long: 'Editing published reading list'
}
};
return messages[status] || messages['empty'];
}
// Public methods that can be called from other controllers
setPercentage(percentage) {
this.percentageValue = percentage;
}
setStatus(status) {
this.statusValue = status;
}
setColor(color) {
this.colorValue = color;
}
pulse() {
if (!this.hasBarTarget) return;
this.barTarget.classList.add('workflow-progress-pulse');
setTimeout(() => {
this.barTarget.classList.remove('workflow-progress-pulse');
}, 1000);
}
celebrate() {
if (!this.hasBarTarget) return;
// Add celebration animation when reaching 100%
if (this.percentageValue === 100) {
this.barTarget.classList.add('workflow-progress-celebrate');
setTimeout(() => {
this.barTarget.classList.remove('workflow-progress-celebrate');
}, 2000);
}
}
}

27
config/packages/workflow.yaml

@ -26,33 +26,6 @@ framework: @@ -26,33 +26,6 @@ framework:
edit:
from: published
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
reading_list_workflow:
type: state_machine
audit_trail:

36
migrations/Version20251218101349.php

@ -0,0 +1,36 @@ @@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251218101349 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE nzine DROP CONSTRAINT fk_65025d9871fd5427');
$this->addSql('DROP TABLE nzine');
$this->addSql('DROP TABLE nzine_bot');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE nzine (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, npub VARCHAR(255) NOT NULL, main_categories JSON NOT NULL, lists JSON DEFAULT NULL, editor VARCHAR(255) DEFAULT NULL, slug TEXT DEFAULT NULL, state VARCHAR(255) NOT NULL, nzine_bot_id INT DEFAULT NULL, feed_url TEXT DEFAULT NULL, last_fetched_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, feed_config JSON DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE UNIQUE INDEX uniq_65025d9871fd5427 ON nzine (nzine_bot_id)');
$this->addSql('CREATE TABLE nzine_bot (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, encrypted_nsec VARCHAR(255) NOT NULL, PRIMARY KEY (id))');
$this->addSql('ALTER TABLE nzine ADD CONSTRAINT fk_65025d9871fd5427 FOREIGN KEY (nzine_bot_id) REFERENCES nzine_bot (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
}

268
src/Command/NzineSortArticlesCommand.php

@ -1,268 +0,0 @@ @@ -1,268 +0,0 @@
<?php
namespace App\Command;
use App\Entity\Article;
use App\Entity\Event as DbEvent; // your Doctrine entity
use App\Entity\NzineBot;
use App\Enum\KindsEnum;
use App\Repository\ArticleRepository;
use App\Repository\NzineRepository;
use App\Service\EncryptionService;
use Doctrine\ORM\EntityManagerInterface;
use swentel\nostr\Event\Event as WireEvent;
use swentel\nostr\Key\Key;
use swentel\nostr\Sign\Sign;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'nzine:sort:articles',
description: 'Update 30040 index events with matching 30023 articles based on tags',
)]
class NzineSortArticlesCommand extends Command
{
public function __construct(
private readonly NzineRepository $nzineRepository,
private readonly ArticleRepository $articleRepository,
private readonly EntityManagerInterface $em,
private readonly EncryptionService $encryptionService,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$nzine = $this->nzineRepository->findOneBy([]);
if (!$nzine) {
$io->error('No NZine entity found.');
return Command::FAILURE;
}
/** @var NzineBot $bot */
$bot = $nzine->getNzineBot();
$bot->setEncryptionService($this->encryptionService);
$key = new Key();
$signer = new Sign();
$publicKey = strtolower($key->getPublicKey($bot->getNsec())); // hex
/** @var Article[] $articles */
$articles = $this->articleRepository->findBy(['pubkey' => $publicKey]);
$io->writeln('Articles for bot: ' . count($articles));
/** @var DbEvent[] $indexes */
$indexes = $this->em->getRepository(DbEvent::class)->findBy([
'pubkey' => $publicKey,
'kind' => KindsEnum::PUBLICATION_INDEX,
]);
$io->writeln('Found ' . count($indexes) . ' existing indexes for bot ' . $publicKey);
if (!$indexes) {
$io->warning('No existing publication indexes found; nothing to update.');
return Command::SUCCESS;
}
// newest index per d-tag (slug)
$newestIndexBySlug = [];
foreach ($indexes as $idx) {
$d = $this->firstTagValue($idx->getTags() ?? [], 'd');
if ($d === null) continue;
if (!isset($newestIndexBySlug[$d]) || $idx->getCreatedAt() > $newestIndexBySlug[$d]->getCreatedAt()) {
$newestIndexBySlug[$d] = $idx;
}
}
$mainCategories = $nzine->getMainCategories() ?? [];
$totalUpdated = 0;
foreach ($mainCategories as $category) {
$slug = (string)($category['slug'] ?? '');
if ($slug === '') continue;
$index = $newestIndexBySlug[$slug] ?? null;
if (!$index) {
$io->writeln(" - Skip category '{$slug}': no index found for this slug.");
continue;
}
$tags = $index->getTags() ?? [];
// topics tracked by this index (t-tags)
$trackedTopics = array_values(array_unique(array_filter(array_map(
fn($t) => $this->normTag($t),
$this->allTagValues($tags, 't')
))));
if (!$trackedTopics) {
$io->writeln(" - Index d='{$slug}': no tracked 't' tags, skipping.");
continue;
}
// existing a-tags for dedupe
$existingA = [];
foreach ($tags as $t) {
if (($t[0] ?? null) === 'a' && isset($t[1])) {
$existingA[strtolower($t[1])] = true;
}
}
$added = 0;
foreach ($articles as $article) {
if (strtolower($article->getPubkey()) !== $publicKey) continue;
$slugArticle = (string)$article->getSlug();
if ($slugArticle === '') continue;
$articleTopics = $article->getTopics() ?? [];
if (!$articleTopics) continue;
if (!$this->intersects($articleTopics, $trackedTopics)) continue;
$coord = sprintf('%s:%s:%s', KindsEnum::LONGFORM->value, $publicKey, $slugArticle);
$coordKey = strtolower($coord);
if (!isset($existingA[$coordKey])) {
$tags[] = ['a', $coord];
$existingA[$coordKey] = true;
$added++;
}
}
if ($added > 0) {
$tags = $this->sortedATagsLast($tags);
$index->setTags($tags);
// ---- SIGN USING SWENTEL EVENT ----
$wire = $this->toWireEvent($index, $publicKey);
$wire->setTags($tags);
$signer->signEvent($wire, $bot->getNsec());
$this->applySignedWireToEntity($wire, $index);
// -----------------------------------
$this->em->persist($index);
$io->writeln(" + Updated index d='{$slug}': added {$added} article(s).");
$totalUpdated++;
} else {
$io->writeln(" - Index d='{$slug}': no new matches.");
}
}
if ($totalUpdated > 0) {
$this->em->flush();
}
$io->success("Done. Updated {$totalUpdated} index(es).");
return Command::SUCCESS;
}
private function firstTagValue(array $tags, string $name): ?string
{
foreach ($tags as $t) {
if (($t[0] ?? null) === $name && isset($t[1])) {
return (string)$t[1];
}
}
return null;
}
private function allTagValues(array $tags, string $name): array
{
$out = [];
foreach ($tags as $t) {
if (($t[0] ?? null) === $name && isset($t[1])) {
$out[] = (string)$t[1];
}
}
return $out;
}
private function normTag(?string $t): string
{
$t = trim((string)$t);
if ($t !== '' && $t[0] === '#') $t = substr($t, 1);
return mb_strtolower($t);
}
private function intersects(array $a, array $b): bool
{
if (!$a || !$b) return false;
$set = array_fill_keys($b, true);
foreach ($a as $x) if (isset($set[$x])) return true;
return false;
}
private function sortedATagsLast(array $tags): array
{
$aTags = [];
$other = [];
foreach ($tags as $t) {
if (($t[0] ?? null) === 'a' && isset($t[1])) $aTags[] = $t;
else $other[] = $t;
}
usort($aTags, fn($x, $y) => strcmp(strtolower($x[1]), strtolower($y[1])));
return array_merge($other, $aTags);
}
/**
* Build a swentel wire event from your DB entity so we can sign it.
*/
private function toWireEvent(DbEvent $e, string $pubkey): WireEvent
{
$w = new WireEvent();
$w->setKind($e->getKind());
$createdAt = $e->getCreatedAt();
// accept int or DateTimeInterface
if ($createdAt instanceof \DateTimeInterface) {
$w->setCreatedAt($createdAt->getTimestamp());
} else {
$w->setCreatedAt((int)$createdAt ?: time());
}
$w->setContent((string)($e->getContent() ?? ''));
$w->setTags($e->getTags() ?? []);
$w->setPublicKey($pubkey); // ensure pubkey is set for id computation
return $w;
}
/**
* Copy signature/id (and any normalized fields) back to your entity.
*/
private function applySignedWireToEntity(WireEvent $w, DbEvent $e): void
{
if (method_exists($e, 'setId') && $w->getId()) {
$e->setId($w->getId());
}
if (method_exists($e, 'setSig') && $w->getSignature()) {
$e->setSig($w->getSignature());
}
if (method_exists($e, 'setPubkey') && $w->getPublicKey()) {
$e->setPubkey($w->getPublicKey());
}
// keep tags/content in sync (in case swentel normalized)
if (method_exists($e, 'setTags')) {
$e->setTags($w->getTags());
}
if (method_exists($e, 'setContent')) {
$e->setContent($w->getContent());
}
if (method_exists($e, 'setCreatedAt') && is_int($w->getCreatedAt())) {
// optional: keep your entity’s createdAt as int or DateTime, depending on your schema
try {
$e->setCreatedAt($w->getCreatedAt());
} catch (\TypeError $t) {
// if your setter expects DateTimeImmutable:
if ($w->getCreatedAt()) {
$e->setCreatedAt((new \DateTimeImmutable())->setTimestamp($w->getCreatedAt())->getTimestamp());
}
}
}
// also ensure kind stays set
if (method_exists($e, 'setKind')) {
$e->setKind($w->getKind());
}
}
}

386
src/Command/RssFetchCommand.php

@ -1,386 +0,0 @@ @@ -1,386 +0,0 @@
<?php
namespace App\Command;
use App\Entity\Article;
use App\Entity\NzineBot;
use App\Factory\ArticleFactory;
use App\Repository\NzineRepository;
use App\Service\EncryptionService;
use App\Service\NostrClient;
use App\Service\RssFeedService;
use Doctrine\ORM\EntityManagerInterface;
use League\HTMLToMarkdown\HtmlConverter;
use swentel\nostr\Event\Event;
use swentel\nostr\Key\Key;
use swentel\nostr\Sign\Sign;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\String\Slugger\AsciiSlugger;
#[AsCommand(
name: 'nzine:rss:fetch',
description: 'Fetch RSS feeds and save new articles for configured nzines',
)]
class RssFetchCommand extends Command
{
public function __construct(
private readonly NzineRepository $nzineRepository,
private readonly ArticleFactory $factory,
private readonly RssFeedService $rssFeedService,
private readonly EntityManagerInterface $entityManager,
private readonly NostrClient $nostrClient,
private readonly EncryptionService $encryptionService
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$slugger = new AsciiSlugger();
$nzines = $this->nzineRepository->findAll();
foreach ($nzines as $nzine) {
if (!$nzine->getFeedUrl()) {
continue;
}
/** @var NzineBot $bot */
$bot = $nzine->getNzineBot();
$bot->setEncryptionService($this->encryptionService);
$key = new Key();
$npub = $key->getPublicKey($bot->getNsec());
$articles = $this->entityManager->getRepository(Article::class)->findBy(['pubkey' => $npub]);
$io->writeln('Found ' . count($articles) . ' existing articles for bot ' . $npub);
$io->section('Fetching RSS for: ' . $nzine->getFeedUrl());
try {
$feed = $this->rssFeedService->fetchFeed($nzine->getFeedUrl());
} catch (\Throwable $e) {
$io->warning('Failed to fetch ' . $nzine->getFeedUrl() . ': ' . $e->getMessage());
continue;
}
foreach ($feed['items'] as $item) {
try {
$event = new Event();
$event->setKind(30023); // NIP-23 Long-form content
// created_at — use parsed pubDate (timestamp int) or now
$createdAt = isset($item['pubDate']) && is_numeric($item['pubDate'])
? (int)$item['pubDate']
: time();
$event->setCreatedAt($createdAt);
// slug (NIP-33 'd' tag) — stable per source item
$base = trim(($nzine->getSlug() ?? 'nzine') . '-' . ($item['title'] ?? ''));
$slug = (string) $slugger->slug($base)->lower();
// HTML → Markdown
$raw = trim($item['content'] ?? '') ?: trim($item['description'] ?? '');
$rawHtml = $this->normalizeWeirdHtml($raw);
$cleanHtml = $this->sanitizeHtml($rawHtml);
$markdown = $this->htmlToMarkdown($cleanHtml);
$event->setContent($markdown);
// Tags
$tags = [
['title', $this->safeStr($item['title'] ?? '')],
['d', $slug],
['source', $this->safeStr($item['link'] ?? '')],
];
// summary (short description)
$summary = $this->ellipsis($this->plainText($item['description'] ?? ''), 280);
if ($summary !== '') {
$tags[] = ['summary', $summary];
}
// image
if (!empty($item['image'])) {
$tags[] = ['image', $this->safeStr($item['image'])];
} else {
// try to sniff first <img> from content if media tag was missing
if (preg_match('~<img[^>]+src="([^"]+)"~i', $rawHtml, $m)) {
$tags[] = ['image', $m[1]];
}
}
// categories → "t" tags
if (!empty($item['categories']) && is_array($item['categories'])) {
foreach ($item['categories'] as $category) {
$cat = trim((string)$category);
if ($cat !== '') {
$event->addTag(['t', $cat]);
}
}
}
$event->setTags($tags);
// Sign event
$signer = new Sign();
$signer->signEvent($event, $bot->getNsec());
// Publish (add/adjust relays as you like)
try {
$this->nostrClient->publishEvent($event, [
'wss://purplepag.es',
'wss://relay.damus.io',
'wss://nos.lol',
]);
$io->writeln('Published long-form event: ' . ($item['title'] ?? '(no title)'));
} catch (\Throwable $e) {
$io->warning('Publish failed: ' . $e->getMessage());
}
// Persist locally
$article = $this->factory->createFromLongFormContentEvent((object)$event->toArray());
$this->entityManager->persist($article);
} catch (\Throwable $e) {
// keep going on item errors
$io->warning('Item failed: ' . ($item['title'] ?? '(no title)') . ' — ' . $e->getMessage());
}
}
$this->entityManager->flush();
$io->success('RSS fetch complete for: ' . $nzine->getFeedUrl());
// --- Update bot profile (kind 0) using feed metadata ---
$feedMeta = $feed['feed'] ?? null;
if ($feedMeta) {
$profile = [
'name' => $feedMeta['title'] ?? $nzine->getTitle(),
'about' => $feedMeta['description'] ?? '',
'picture' => $feedMeta['image'] ?? null,
'website' => $feedMeta['link'] ?? null,
];
$p = new Event();
$p->setKind(0);
$p->setCreatedAt(time());
$p->setContent(json_encode($profile, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
$signer = new Sign();
$signer->signEvent($p, $bot->getNsec());
try {
$this->nostrClient->publishEvent($p, ['wss://purplepag.es']);
$io->success('Published bot profile (kind 0) with feed metadata');
} catch (\Throwable $e) {
$io->warning('Failed to publish bot profile event: ' . $e->getMessage());
}
}
}
return Command::SUCCESS;
}
/** -------- Helpers: HTML prep + converter + small utils -------- */
private function normalizeWeirdHtml(string $html): string
{
// 1) Unwrap Ghost "HTML cards": keep only the <body> content, drop <html>/<head> wrappers and scripts
$html = preg_replace_callback('/<!--\s*kg-card-begin:\s*html\s*-->.*?<!--\s*kg-card-end:\s*html\s*-->/si', function ($m) {
$block = $m[0];
// Extract inner <body></body> if present
if (preg_match('/<body\b[^>]*>(.*?)<\/body>/si', $block, $mm)) {
$inner = $mm[1];
} else {
// No explicit body; just strip the markers
$inner = preg_replace('/<!--\s*kg-card-(?:begin|end):\s*html\s*-->/', '', $block);
}
return $inner;
}, $html);
// 2) Nuke any remaining document wrappers that would cut DOM parsing short
$html = preg_replace([
'/<\/?html[^>]*>/i',
'/<\/?body[^>]*>/i',
'/<head\b[^>]*>.*?<\/head>/si',
], '', $html);
dump($html);
return $html;
}
private function sanitizeHtml(string $html): string
{
if ($html === '') return $html;
// 0) quick pre-clean: kill scripts/styles early to avoid DOM bloat
$html = preg_replace('~<(script|style)\b[^>]*>.*?</\1>~is', '', $html);
$html = preg_replace('~<!--.*?-->~s', '', $html); // comments
// 1) Normalize weird widgets and wrappers BEFORE DOM parse
// lightning-widget → simple text
$html = preg_replace_callback(
'~<lightning-widget[^>]*\bto="([^"]+)"[^>]*>.*?</lightning-widget>~is',
fn($m) => '<p>⚡ Tips: ' . htmlspecialchars($m[1]) . '</p>',
$html
);
// Ghost/Koenig wrappers: keep useful inner content
$html = preg_replace('~<figure[^>]*\bkg-image-card\b[^>]*>\s*(<img[^>]+>)\s*</figure>~i', '$1', $html);
$html = preg_replace('~<div[^>]*\bkg-callout-card\b[^>]*>(.*?)</div>~is', '<blockquote>$1</blockquote>', $html);
// YouTube iframes → links
$html = preg_replace_callback(
'~<iframe[^>]+src="https?://www\.youtube\.com/embed/([A-Za-z0-9_\-]+)[^"]*"[^>]*></iframe>~i',
fn($m) => '<p><a href="https://youtu.be/' . $m[1] . '">Watch on YouTube</a></p>',
$html
);
// 2) Try to pretty up malformed markup via Tidy (if available)
if (function_exists('tidy_parse_string')) {
try {
$tidy = tidy_parse_string($html, [
'clean' => true,
'output-xhtml' => true,
'show-body-only' => false,
'wrap' => 0,
'drop-empty-paras' => true,
'merge-divs' => true,
'merge-spans' => true,
'numeric-entities' => false,
'quote-ampersand' => true,
], 'utf8');
$tidy->cleanRepair();
$html = (string)$tidy;
} catch (\Throwable $e) {
// ignore tidy failures
}
}
// 3) DOM sanitize: remove junk, unwrap html/body/head, allowlist elements/attrs
$dom = new \DOMDocument('1.0', 'UTF-8');
libxml_use_internal_errors(true);
$loaded = $dom->loadHTML(
// force UTF-8 meta so DOMDocument doesn't mangle
'<!DOCTYPE html><meta http-equiv="Content-Type" content="text/html; charset=utf-8">'.$html,
LIBXML_NOWARNING | LIBXML_NOERROR
);
libxml_clear_errors();
if (!$loaded) {
// fallback: as-is minus tags we already stripped
return $html;
}
$xpath = new \DOMXPath($dom);
// Remove <head>, <script>, <style>, <link>, <meta>, <noscript>, <object>, <embed>
foreach (['//head','//script','//style','//link','//meta','//noscript','//object','//embed'] as $q) {
foreach ($xpath->query($q) as $n) {
$n->parentNode?->removeChild($n);
}
}
// Remove iframes that survived (non-YouTube or any at this point)
foreach ($xpath->query('//iframe') as $n) {
$n->parentNode?->removeChild($n);
}
// Remove any custom elements we don’t want (e.g., <lightning-widget>, <amp-*>)
foreach ($xpath->query('//*[starts-with(name(), "amp-") or local-name()="lightning-widget"]') as $n) {
$n->parentNode?->removeChild($n);
}
// Allowlist basic attributes; drop event handlers/javascript: urls
$allowedAttrs = ['href','src','alt','title','width','height','class'];
foreach ($xpath->query('//@*') as $attr) {
$name = $attr->nodeName;
$val = $attr->nodeValue ?? '';
if (!in_array($name, $allowedAttrs, true)) {
$attr->ownerElement?->removeAttributeNode($attr);
continue;
}
// kill javascript: and data: except images
if ($name === 'href' || $name === 'src') {
$valTrim = trim($val);
$lower = strtolower($valTrim);
$isDataImg = str_starts_with($lower, 'data:image/');
if (str_starts_with($lower, 'javascript:') || (str_starts_with($lower, 'data:') && !$isDataImg)) {
$attr->ownerElement?->removeAttribute($name);
} else {
$attr->nodeValue = $valTrim;
}
}
}
// Unwrap <html> and <body> → gather innerHTML
$body = $dom->getElementsByTagName('body')->item(0);
$container = $body ?: $dom; // fallback
// Drop empty spans/divs that are just whitespace
foreach ($xpath->query('.//span|.//div', $container) as $n) {
if (!trim($n->textContent ?? '') && !$n->getElementsByTagName('*')->length) {
$n->parentNode?->removeChild($n);
}
}
// Serialize inner HTML of container
$cleanHtml = '';
foreach ($container->childNodes as $child) {
$cleanHtml .= $dom->saveHTML($child);
}
// Final tiny cleanups
$cleanHtml = preg_replace('~\s+</p>~', '</p>', $cleanHtml);
$cleanHtml = preg_replace('~<p>\s+</p>~', '', $cleanHtml);
return trim($cleanHtml);
}
private function htmlToMarkdown(string $html): string
{
$converter = $this->makeConverter();
$md = trim($converter->convert($html));
// ensure there's a blank line after images
// 1) images that already sit alone on a line
$md = preg_replace('/^(>?\s*)!\[[^\]]*]\([^)]*\)\s*$/m', "$0\n", $md);
// 2) inline images: add a newline after the token (optional — comment out if you only want #1)
$md = preg_replace('/!\[[^\]]*]\([^)]*\)/', "$0\n", $md);
// collapse any excessive blank lines to max two
$md = preg_replace("/\n{3,}/", "\n\n", $md);
// Optional: coalesce too many blank lines caused by sanitization/conversion
$md = preg_replace("~\n{3,}~", "\n\n", $md);
return $md;
}
private function makeConverter(): HtmlConverter
{
return new HtmlConverter([
'header_style' => 'atx',
'bold_style' => '**',
'italic_style' => '*',
'hard_break' => true,
'strip_tags' => true,
'remove_nodes' => 'script style',
]);
}
private function plainText(string $html): string
{
return trim(html_entity_decode(strip_tags($html)));
}
private function ellipsis(string $text, int $max): string
{
$text = trim($text);
if ($text === '' || mb_strlen($text) <= $max) return $text;
return rtrim(mb_substr($text, 0, $max - 1)) . '…';
}
private function safeStr(?string $s): string
{
return $s === null ? '' : trim($s);
}
}

4
src/Controller/MagazineWizardController.php

@ -6,13 +6,10 @@ namespace App\Controller; @@ -6,13 +6,10 @@ namespace App\Controller;
use App\Dto\CategoryDraft;
use App\Dto\MagazineDraft;
use App\Entity\Nzine;
use App\Enum\KindsEnum;
use App\Form\CategoryArticlesType;
use App\Form\MagazineSetupType;
use App\Repository\NzineRepository;
use App\Service\EncryptionService;
use App\Service\RedisCacheService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Cache\CacheItemPoolInterface;
use swentel\nostr\Event\Event;
@ -26,7 +23,6 @@ use Symfony\Component\Security\Csrf\CsrfToken; @@ -26,7 +23,6 @@ use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use swentel\nostr\Key\Key;
use Redis as RedisClient;
use Symfony\Contracts\Cache\CacheInterface;
class MagazineWizardController extends AbstractController
{

510
src/Controller/NzineController.php

@ -1,510 +0,0 @@ @@ -1,510 +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
{
/**
* List all NZines owned by the current user
*/
#[Route('/my-nzines', name: 'nzine_list')]
public function list(EntityManagerInterface $entityManager): Response
{
$user = $this->getUser();
$nzines = [];
if ($user) {
$userIdentifier = $user->getUserIdentifier();
// Find all nzines where the current user is the editor
$allNzines = $entityManager->getRepository(Nzine::class)
->findBy(['editor' => $userIdentifier], ['id' => 'DESC']);
foreach ($allNzines as $nzine) {
// Get the feed config for title and summary
$feedConfig = $nzine->getFeedConfig();
$title = $feedConfig['title'] ?? 'Untitled NZine';
$summary = $feedConfig['summary'] ?? null;
// Count categories
$categoryCount = count($nzine->getMainCategories());
// Get main index to check publication status
$mainIndex = $entityManager->getRepository(EventEntity::class)
->findOneBy([
'pubkey' => $nzine->getNpub(),
'kind' => KindsEnum::PUBLICATION_INDEX->value,
// We'd need to filter by d-tag matching slug, but let's get first one for now
]);
$nzines[] = [
'id' => $nzine->getId(),
'npub' => $nzine->getNpub(),
'title' => $title,
'summary' => $summary,
'slug' => $nzine->getSlug(),
'state' => $nzine->getState(),
'categoryCount' => $categoryCount,
'hasMainIndex' => $mainIndex !== null,
'feedUrl' => $nzine->getFeedUrl(),
];
}
}
return $this->render('nzine/list.html.twig', [
'nzines' => $nzines,
]);
}
/**
* @throws \JsonException
*/
#[Route('/nzine', name: 'nzine_index')]
public function index(Request $request, NzineWorkflowService $nzineWorkflowService, EntityManagerInterface $entityManager): Response
{
$user = $this->getUser();
$isAuthenticated = $user !== null;
$form = $this->createForm(NzineBotType::class, null, [
'disabled' => !$isAuthenticated
]);
$form->handleRequest($request);
$nzine = $entityManager->getRepository(Nzine::class)->findAll();
if ($form->isSubmitted() && $form->isValid() && $isAuthenticated) {
$data = $form->getData();
// init object
$nzine = $nzineWorkflowService->init();
// Set RSS feed URL if provided
if (!empty($data['feedUrl'])) {
$nzine->setFeedUrl($data['feedUrl']);
}
// Store title and summary for later use when creating main index
$nzine->setFeedConfig([
'title' => $data['name'],
'summary' => $data['about']
]);
// create bot and nzine, save to persistence
// Note: We don't create the main index yet - that happens after categories are configured
$nzine = $nzineWorkflowService->createProfile($nzine, $data['name'], $data['about'], $user);
return $this->redirectToRoute('nzine_edit', ['npub' => $nzine->getNpub() ]);
}
return $this->render('pages/nzine-editor.html.twig', [
'form' => $form,
'isAuthenticated' => $isAuthenticated
]);
}
#[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 no main index exists yet, allow user to add categories but don't create indices yet
$canCreateIndices = !empty($mainIndex);
$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();
// Auto-generate slugs if not provided
$slugger = new AsciiSlugger();
foreach ($data as &$cat) {
if (empty($cat['slug']) && !empty($cat['title'])) {
$cat['slug'] = $slugger->slug($cat['title'])->lower()->toString();
}
}
unset($cat); // break reference
$nzine->setMainCategories($data);
try {
$entityManager->beginTransaction();
$entityManager->persist($nzine);
$entityManager->flush();
$entityManager->commit();
} catch (Exception $e) {
$entityManager->rollback();
$managerRegistry->resetManager();
}
// Only create category indices if main index exists
if ($canCreateIndices) {
$catIndices = [];
$bot = $nzine->getNzineBot();
$bot->setEncryptionService($encryptionService);
$private_key = $bot->getNsec(); // decrypted en route
foreach ($data as $cat) {
// Validate category has required fields
if (!isset($cat['title']) || empty($cat['title'])) {
continue; // Skip invalid categories
}
// check if such an index exists, only create new cats
$id = array_filter($indices, function ($k) use ($cat) {
return isset($cat['title']) && $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
$title = $cat['title'];
$slug = isset($cat['slug']) && !empty($cat['slug'])
? $cat['slug']
: $slugger->slug($title)->lower()->toString();
// Use just the category slug for the d-tag so it can be found by the magazine frontend
// The main index will reference this via 'a' tags with full coordinates
$indexSlug = $slug;
// create category index
$index = new Event();
$index->setKind(KindsEnum::PUBLICATION_INDEX->value);
$index->addTag(['d', $indexSlug]);
$index->addTag(['title', $title]);
$index->addTag(['auto-update', 'yes']);
$index->addTag(['type', 'magazine']);
// Add tags for RSS matching
if (isset($cat['tags']) && is_array($cat['tags'])) {
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();
} else {
// Categories saved but no indices created yet
$this->addFlash('info', 'Categories saved. Indices will be created once the main index is published.');
}
// redirect to route nzine_view if main index exists, otherwise stay on edit page
if ($canCreateIndices) {
return $this->redirectToRoute('nzine_view', [
'npub' => $nzine->getNpub(),
]);
} else {
return $this->redirectToRoute('nzine_edit', [
'npub' => $nzine->getNpub(),
]);
}
}
return $this->render('pages/nzine-editor.html.twig', [
'nzine' => $nzine,
'indices' => $indices,
'mainIndex' => $mainIndex,
'canCreateIndices' => $canCreateIndices,
'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/{pubkey}', name: 'nzine_view')]
public function nzineView($pubkey, EntityManagerInterface $entityManager): Response
{
$nzine = $entityManager->getRepository(Nzine::class)->findOneBy(['npub' => $pubkey]);
if (!$nzine) {
throw $this->createNotFoundException('N-Zine not found');
}
// Find all index events for this nzine
$indices = $entityManager->getRepository(EventEntity::class)->findBy(['pubkey' => $pubkey, 'kind' => KindsEnum::PUBLICATION_INDEX]);
$mainIndexCandidates = array_filter($indices, function ($index) use ($nzine) {
return $index->getSlug() == $nzine->getSlug();
});
dump($indices, $mainIndexCandidates);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
]);
}
#[Route('/nzine/{npub}/publish', name: 'nzine_publish', methods: ['POST'])]
public function publish($npub, EntityManagerInterface $entityManager,
EncryptionService $encryptionService,
ManagerRegistry $managerRegistry): Response
{
$nzine = $entityManager->getRepository(Nzine::class)->findOneBy(['npub' => $npub]);
if (!$nzine) {
throw $this->createNotFoundException('N-Zine not found');
}
// Check if categories are configured
if (empty($nzine->getMainCategories())) {
$this->addFlash('error', 'Please add at least one category before publishing.');
return $this->redirectToRoute('nzine_edit', ['npub' => $npub]);
}
// Check if main index already exists
$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();
});
if (!empty($mainIndexCandidates)) {
$this->addFlash('warning', 'Main index already exists.');
return $this->redirectToRoute('nzine_edit', ['npub' => $npub]);
}
try {
// Start transaction
$entityManager->beginTransaction();
$bot = $nzine->getNzineBot();
if (!$bot) {
throw new \RuntimeException('Nzine bot not found');
}
$bot->setEncryptionService($encryptionService);
$private_key = $bot->getNsec();
if (!$private_key) {
throw new \RuntimeException('Failed to decrypt bot private key');
}
// Get title and summary from feedConfig
$config = $nzine->getFeedConfig();
$title = $config['title'] ?? 'Untitled';
$summary = $config['summary'] ?? '';
// Generate slug for main index
$slugger = new AsciiSlugger();
$slug = 'nzine-'.$slugger->slug($title)->lower().'-'.rand(10000,99999);
$nzine->setSlug($slug);
// Create main index
$mainIndex = new Event();
$mainIndex->setKind(KindsEnum::PUBLICATION_INDEX->value);
$mainIndex->addTag(['d', $slug]);
$mainIndex->addTag(['title', $title]);
$mainIndex->addTag(['summary', $summary]);
$mainIndex->addTag(['auto-update', 'yes']);
$mainIndex->addTag(['type', 'magazine']);
$mainIndex->setPublicKey($nzine->getNpub());
// Create category indices
$catIndices = [];
foreach ($nzine->getMainCategories() as $cat) {
if (!isset($cat['title'])) {
continue; // Skip categories without titles
}
$catTitle = $cat['title'];
$catSlug = $cat['slug'] ?? $slugger->slug($catTitle)->lower()->toString();
// Use just the category slug for the d-tag so it can be found by the magazine frontend
// The main index will reference this via 'a' tags with full coordinates
$indexSlug = $catSlug;
$catIndex = new Event();
$catIndex->setKind(KindsEnum::PUBLICATION_INDEX->value);
$catIndex->addTag(['d', $indexSlug]);
$catIndex->addTag(['title', $catTitle]);
$catIndex->addTag(['auto-update', 'yes']);
$catIndex->addTag(['type', 'magazine']);
// Add tags for RSS matching
if (isset($cat['tags']) && is_array($cat['tags'])) {
foreach ($cat['tags'] as $tag) {
$catIndex->addTag(['t', $tag]);
}
}
$catIndex->setPublicKey($nzine->getNpub());
// Sign category index
$signer = new Sign();
$signer->signEvent($catIndex, $private_key);
// Save category index
$serializer = new Serializer([new ObjectNormalizer()],[new JsonEncoder()]);
$i = $serializer->deserialize($catIndex->toJson(), EventEntity::class, 'json');
$entityManager->persist($i);
// Add reference to main index
$mainIndex->addTag(['a', KindsEnum::PUBLICATION_INDEX->value .':'. $catIndex->getPublicKey() .':'. $indexSlug]);
$catIndices[] = $catIndex;
}
// Sign main index (after adding all category references)
$signer = new Sign();
$signer->signEvent($mainIndex, $private_key);
// Save main index
$serializer = new Serializer([new ObjectNormalizer()],[new JsonEncoder()]);
$mainIndexEntity = $serializer->deserialize($mainIndex->toJson(), EventEntity::class, 'json');
$entityManager->persist($mainIndexEntity);
// Update nzine state
$nzine->setState('published');
$entityManager->persist($nzine);
// Commit transaction
$entityManager->flush();
$entityManager->commit();
$this->addFlash('success', sprintf(
'N-Zine published successfully! Created main index and %d category indices.',
count($catIndices)
));
return $this->redirectToRoute('nzine_edit', ['npub' => $npub]);
} catch (Exception $e) {
if ($entityManager->getConnection()->isTransactionActive()) {
$entityManager->rollback();
}
$managerRegistry->resetManager();
$this->addFlash('error', 'Failed to publish N-Zine: ' . $e->getMessage());
// Log the full error for debugging
error_log('N-Zine publish error: ' . $e->getMessage() . "\n" . $e->getTraceAsString());
return $this->redirectToRoute('nzine_edit', ['npub' => $npub]);
}
}
}

180
src/Entity/Nzine.php

@ -1,180 +0,0 @@ @@ -1,180 +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: 'string')]
private string $state = 'draft';
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $feedUrl = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $lastFetchedAt = null;
#[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $feedConfig = null;
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;
}
public function getFeedUrl(): ?string
{
return $this->feedUrl;
}
public function setFeedUrl(?string $feedUrl): void
{
$this->feedUrl = $feedUrl;
}
public function getLastFetchedAt(): ?\DateTimeImmutable
{
return $this->lastFetchedAt;
}
public function setLastFetchedAt(?\DateTimeImmutable $lastFetchedAt): void
{
$this->lastFetchedAt = $lastFetchedAt;
}
public function getFeedConfig(): ?array
{
return $this->feedConfig;
}
public function setFeedConfig(?array $feedConfig): void
{
$this->feedConfig = $feedConfig;
}
}

47
src/Entity/NzineBot.php

@ -1,47 +0,0 @@ @@ -1,47 +0,0 @@
<?php
namespace App\Entity;
use App\Service\EncryptionService;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Ignore;
#[ORM\Entity]
class NzineBot
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
private ?EncryptionService $encryptionService = null;
#[ORM\Column(type: '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;
}
}

159
src/Examples/RssNzineSetupExample.php

@ -1,159 +0,0 @@ @@ -1,159 +0,0 @@
<?php
/**
* Example script for setting up a Nzine with RSS feed
*
* This is a reference implementation showing how to configure a nzine
* with RSS feed support. Adapt this to your needs (console command, controller, etc.)
*/
namespace App\Examples;
use App\Entity\Nzine;
use App\Repository\NzineRepository;
use Doctrine\ORM\EntityManagerInterface;
class RssNzineSetupExample
{
public function __construct(
private readonly NzineRepository $nzineRepository,
private readonly EntityManagerInterface $entityManager
) {
}
/**
* Example: Configure an existing nzine with RSS feed
*/
public function setupRssFeedForNzine(int $nzineId): void
{
$nzine = $this->nzineRepository->find($nzineId);
if (!$nzine) {
throw new \RuntimeException("Nzine not found: $nzineId");
}
// Set the RSS feed URL
$nzine->setFeedUrl('https://example.com/feed.rss');
// Configure categories with tags for RSS item matching
$categories = [
[
'name' => 'Artificial Intelligence',
'slug' => 'ai',
'tags' => ['artificial-intelligence', 'machine-learning', 'AI', 'ML', 'deep-learning', 'neural-networks']
],
[
'name' => 'Blockchain & Crypto',
'slug' => 'blockchain',
'tags' => ['crypto', 'cryptocurrency', 'blockchain', 'bitcoin', 'ethereum', 'web3', 'defi', 'nft']
],
[
'name' => 'Programming',
'slug' => 'programming',
'tags' => ['programming', 'coding', 'development', 'software', 'javascript', 'python', 'rust', 'go']
],
[
'name' => 'Nostr Protocol',
'slug' => 'nostr',
'tags' => ['nostr', 'decentralized', 'social-media', 'protocol']
]
];
$nzine->setMainCategories($categories);
// Optional: Set custom feed configuration
$nzine->setFeedConfig([
'enabled' => true,
'description' => 'Tech news aggregator',
// Future options:
// 'max_age_days' => 7,
// 'fetch_full_content' => true,
]);
$this->entityManager->flush();
echo "RSS feed configured for nzine #{$nzineId}\n";
echo "Feed URL: " . $nzine->getFeedUrl() . "\n";
echo "Categories: " . count($categories) . "\n";
}
/**
* Example: Create a new RSS-enabled nzine from scratch
*/
public function createRssEnabledNzine(
string $title,
string $summary,
string $feedUrl,
array $categories
): Nzine {
// Note: This is a simplified example. In practice, you should:
// 1. Use NzineWorkflowService to create the bot and profile
// 2. Create the main index
// 3. Create nested indices
// 4. Transition through the workflow states
$nzine = new Nzine();
$nzine->setFeedUrl($feedUrl);
$nzine->setMainCategories($categories);
// You would normally use the workflow service here:
// $this->nzineWorkflowService->init($nzine);
// $this->nzineWorkflowService->createProfile(...);
// etc.
$this->entityManager->persist($nzine);
$this->entityManager->flush();
return $nzine;
}
/**
* Example: List all RSS-enabled nzines
*/
public function listRssNzines(): void
{
$nzines = $this->nzineRepository->findActiveRssNzines();
echo "RSS-enabled Nzines:\n";
echo str_repeat("=", 80) . "\n";
foreach ($nzines as $nzine) {
echo sprintf(
"ID: %d | Slug: %s | Feed: %s\n",
$nzine->getId(),
$nzine->getSlug() ?? 'N/A',
$nzine->getFeedUrl()
);
$lastFetched = $nzine->getLastFetchedAt();
if ($lastFetched) {
echo " Last fetched: " . $lastFetched->format('Y-m-d H:i:s') . "\n";
}
echo " Categories: " . count($nzine->getMainCategories()) . "\n";
echo "\n";
}
}
/**
* Example RSS feed URLs for testing
*/
public static function getExampleFeeds(): array
{
return [
'tech' => [
'TechCrunch' => 'https://techcrunch.com/feed/',
'Hacker News' => 'https://hnrss.org/newest',
'Ars Technica' => 'https://feeds.arstechnica.com/arstechnica/index',
],
'crypto' => [
'CoinDesk' => 'https://www.coindesk.com/arc/outboundfeeds/rss/',
'Bitcoin Magazine' => 'https://bitcoinmagazine.com/.rss/full/',
],
'programming' => [
'Dev.to' => 'https://dev.to/feed',
'GitHub Blog' => 'https://github.blog/feed/',
]
];
}
}

56
src/Form/NzineBotType.php

@ -1,56 +0,0 @@ @@ -1,56 +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
{
$isDisabled = $options['disabled'] ?? false;
$builder
->add('name', TextType::class, [
'required' => true,
'label' => 'N-Zine Name',
'help' => 'The name of your N-Zine publication',
'disabled' => $isDisabled
])
->add('about', TextareaType::class, [
'required' => false,
'label' => 'Description',
'help' => 'Describe what this N-Zine is about',
'disabled' => $isDisabled
])
->add('feedUrl', TextType::class, [
'required' => false,
'label' => 'RSS Feed URL',
'help' => 'Optional: Add an RSS/Atom feed URL to automatically fetch and publish articles',
'attr' => [
'placeholder' => 'https://example.com/feed.rss'
],
'disabled' => $isDisabled
])
->add('submit', SubmitType::class, [
'label' => 'Create N-Zine',
'disabled' => $isDisabled,
'attr' => [
'class' => 'btn btn-primary',
'title' => $isDisabled ? 'Please login to create an N-Zine' : ''
]
])
;
}
public function configureOptions(OptionsResolver $resolver)
{
}
}

30
src/Form/NzineType.php

@ -1,30 +0,0 @@ @@ -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
{
}
}

72
src/Repository/NzineRepository.php

@ -1,72 +0,0 @@ @@ -1,72 +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);
}
/**
* Find all nzines with RSS feeds configured and in published state
*
* @return Nzine[]
*/
public function findActiveRssNzines(): array
{
return $this->createQueryBuilder('n')
->where('n.feedUrl IS NOT NULL')
->andWhere('n.state = :state')
->setParameter('state', 'published')
->orderBy('n.id', 'ASC')
->getQuery()
->getResult();
}
/**
* Find a specific nzine by ID with RSS feed configured
*/
public function findRssNzineById(int $id): ?Nzine
{
return $this->createQueryBuilder('n')
->where('n.id = :id')
->andWhere('n.feedUrl IS NOT NULL')
->setParameter('id', $id)
->getQuery()
->getOneOrNullResult();
}
// /**
// * @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()
// ;
// }
}

311
src/Service/NzineCategoryIndexService.php

@ -1,311 +0,0 @@ @@ -1,311 +0,0 @@
<?php
namespace App\Service;
use App\Entity\Event as EventEntity;
use App\Entity\Nzine;
use App\Enum\KindsEnum;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use swentel\nostr\Event\Event;
use swentel\nostr\Sign\Sign;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
/**
* Service for managing category index events for nzines
*/
class NzineCategoryIndexService
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly EncryptionService $encryptionService,
private readonly LoggerInterface $logger
) {
}
/**
* Ensure category index events exist for all categories in a nzine
* Creates missing category index events and returns them
*
* @param Nzine $nzine The nzine entity
* @return array Map of category slug => EventEntity
* @throws \JsonException
*/
public function ensureCategoryIndices(Nzine $nzine): array
{
$categories = $nzine->getMainCategories();
if (empty($categories)) {
return [];
}
$bot = $nzine->getNzineBot();
if (!$bot) {
$this->logger->warning('Cannot create category indices: nzine bot not found', [
'nzine_id' => $nzine->getId(),
]);
return [];
}
$bot->setEncryptionService($this->encryptionService);
$privateKey = $bot->getNsec();
if (!$privateKey) {
$this->logger->warning('Cannot create category indices: bot private key not found', [
'nzine_id' => $nzine->getId(),
]);
return [];
}
$categoryIndices = [];
// Load all existing category indices for this nzine at once
$existingIndices = $this->entityManager->getRepository(EventEntity::class)
->findBy([
'pubkey' => $nzine->getNpub(),
'kind' => KindsEnum::PUBLICATION_INDEX->value,
]);
// Index existing events by their d-tag (slug)
$existingBySlug = [];
foreach ($existingIndices as $existingIndex) {
$slug = $existingIndex->getSlug();
if ($slug) {
$existingBySlug[$slug] = $existingIndex;
}
}
foreach ($categories as $category) {
$slug = $category['slug'];
// Check if category index already exists
if (isset($existingBySlug[$slug])) {
$categoryIndices[$slug] = $existingBySlug[$slug];
$this->logger->debug('Using existing category index', [
'category_slug' => $slug,
'title' => $category['title'],
]);
continue;
}
// Create new category index event
$event = new Event();
$event->setKind(KindsEnum::PUBLICATION_INDEX->value);
$event->addTag(['d', $slug]);
$event->addTag(['title', $category['title']]);
$event->addTag(['auto-update', 'yes']);
$event->addTag(['type', 'magazine']);
// Add tags for RSS matching
if (isset($category['tags']) && is_array($category['tags'])) {
foreach ($category['tags'] as $tag) {
$event->addTag(['t', $tag]);
}
}
$event->setPublicKey($nzine->getNpub());
// Sign the event
$signer = new Sign();
$signer->signEvent($event, $privateKey);
// Convert to EventEntity and save
$serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]);
// Move id to eventId before persisting
$eventId = $event->getId();
$event->setId(null);
$eventEntity = $serializer->deserialize($event->toJson(), EventEntity::class, 'json');
$eventEntity->setEventId($eventId);
$this->entityManager->persist($eventEntity);
$categoryIndices[$slug] = $eventEntity;
$this->logger->info('Created category index event', [
'nzine_id' => $nzine->getId(),
'category_slug' => $slug,
]);
}
$this->entityManager->flush();
$this->logger->info('Category indices ready', [
'nzine_id' => $nzine->getId(),
'total_categories' => count($categories),
'total_indices_returned' => count($categoryIndices),
'indexed_by_slug' => array_keys($categoryIndices),
]);
return $categoryIndices;
}
/**
* Extract the slug (d-tag value) from event tags
*/
private function extractSlugFromTags(array $tags): ?string
{
foreach ($tags as $tag) {
if (is_array($tag) && $tag[0] === 'd' && isset($tag[1])) {
return $tag[1];
}
}
return null;
}
/**
* Add an article to a category index
* Creates a new signed event with the article added to the existing tags
*
* @param EventEntity $categoryIndex The category index event
* @param string $articleCoordinate The article coordinate (kind:pubkey:slug)
* @param Nzine $nzine The nzine entity (needed for signing)
* @return EventEntity The new category index event (with updated article list)
*/
public function addArticleToCategoryIndex(EventEntity $categoryIndex, string $articleCoordinate, Nzine $nzine): EventEntity
{
// Check if article already exists in the index
$existingTags = $categoryIndex->getTags();
foreach ($existingTags as $tag) {
if ($tag[0] === 'a' && isset($tag[1]) && $tag[1] === $articleCoordinate) {
// Article already in index, return existing event
$this->logger->debug('Article already in category index', [
'article_coordinate' => $articleCoordinate,
'event_id' => $categoryIndex->getId(),
]);
return $categoryIndex;
}
}
// Get the bot and private key for signing
$bot = $nzine->getNzineBot();
if (!$bot) {
throw new \RuntimeException('Cannot sign category index: nzine bot not found');
}
$bot->setEncryptionService($this->encryptionService);
$privateKey = $bot->getNsec();
if (!$privateKey) {
throw new \RuntimeException('Cannot sign category index: bot private key not found');
}
// Create a new Event object with ALL existing tags PLUS the new article tag
$event = new Event();
$event->setKind($categoryIndex->getKind());
$event->setContent($categoryIndex->getContent() ?? '');
$event->setPublicKey($categoryIndex->getPubkey());
// Add ALL existing tags first
foreach ($existingTags as $tag) {
$event->addTag($tag);
}
// Add the new article coordinate tag
$event->addTag(['a', $articleCoordinate]);
// Sign the event with current timestamp
$signer = new Sign();
$signer->signEvent($event, $privateKey);
// Convert to JSON and deserialize to NEW EventEntity
$serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]);
// Move id to eventId before persisting
$eventId = $event->getId();
$newEventEntity = $serializer->deserialize($event->toJson(), EventEntity::class, 'json');
$newEventEntity->setEventId($eventId);
// Persist the NEW event entity
$this->entityManager->persist($newEventEntity);
$this->entityManager->flush();
$articleCount = count(array_filter($newEventEntity->getTags(), fn($tag) => $tag[0] === 'a'));
$this->logger->debug('Created new category index event with article added', [
'category_slug' => $this->extractSlugFromTags($newEventEntity->getTags()),
'article_coordinate' => $articleCoordinate,
'old_event_id' => $categoryIndex->getId(),
'new_event_id' => $newEventEntity->getId(),
'total_tags' => count($newEventEntity->getTags()),
'article_count' => $articleCount,
]);
return $newEventEntity;
}
/**
* Re-sign and save category index events
* Should be called after all articles have been added to ensure valid signatures
*
* @param array $categoryIndices Map of category slug => EventEntity
* @param Nzine $nzine The nzine entity
*/
public function resignCategoryIndices(array $categoryIndices, Nzine $nzine): void
{
if (empty($categoryIndices)) {
return;
}
$bot = $nzine->getNzineBot();
if (!$bot) {
$this->logger->warning('Cannot re-sign category indices: nzine bot not found', [
'nzine_id' => $nzine->getId(),
]);
return;
}
$bot->setEncryptionService($this->encryptionService);
$privateKey = $bot->getNsec();
if (!$privateKey) {
$this->logger->warning('Cannot re-sign category indices: bot private key not found', [
'nzine_id' => $nzine->getId(),
]);
return;
}
$signer = new Sign();
$serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]);
foreach ($categoryIndices as $slug => $categoryIndex) {
try {
// Create a new Event from the existing EventEntity
$event = new Event();
$event->setKind($categoryIndex->getKind());
$event->setContent($categoryIndex->getContent() ?? '');
$event->setPublicKey($categoryIndex->getPubkey());
// Add all tags from the category index
foreach ($categoryIndex->getTags() as $tag) {
$event->addTag($tag);
}
// Sign the event with current timestamp (creates new ID)
$signer->signEvent($event, $privateKey);
// Deserialize to a NEW EventEntity (not updating the old one)
$newEventEntity = $serializer->deserialize($event->toJson(), EventEntity::class, 'json');
// Persist the NEW event entity
$this->entityManager->persist($newEventEntity);
$this->logger->info('Created new category index event (re-signed)', [
'category_slug' => $slug,
'old_event_id' => $categoryIndex->getId(),
'new_event_id' => $newEventEntity->getId(),
'article_count' => count(array_filter($newEventEntity->getTags(), fn($tag) => $tag[0] === 'a')),
]);
} catch (\Exception $e) {
$this->logger->error('Failed to re-sign category index', [
'category_slug' => $slug,
'error' => $e->getMessage(),
]);
}
}
$this->entityManager->flush();
$this->logger->info('Category indices re-signed and new events created', [
'nzine_id' => $nzine->getId(),
'count' => count($categoryIndices),
]);
}
}

147
src/Service/NzineWorkflowService.php

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

5
templates/components/UserMenu.html.twig

@ -11,11 +11,6 @@ @@ -11,11 +11,6 @@
</ul>
{% endif %}
<ul class="user-nav">
{% if is_granted('ROLE_ADMIN') %}
<li>
<a href="{{ path('nzine_list') }}">My NZines</a>
</li>
{% endif %}
<li>
<a href="{{ path('reading_list_index') }}">Compose List</a>
</li>

57
templates/nzine/list.html.twig

@ -1,57 +0,0 @@ @@ -1,57 +0,0 @@
{% extends 'layout.html.twig' %}
{% block body %}
<section class="d-flex gap-3 center ln-section--newsstand">
<div class="container mt-5 mb-1">
<h1>Your NZines</h1>
<p class="eyebrow">manage your digital magazines</p>
</div>
<div class="cta-row mb-5">
<a class="btn btn-primary" href="{{ path('nzine_index') }}">Create new</a>
</div>
</section>
<div class="w-container mb-5 mt-5">
{% if nzines is defined and nzines|length %}
<ul class="list-unstyled d-grid gap-2 mb-4">
{% for nzine in nzines %}
<li class="card p-3">
<div class="d-flex justify-content-between align-items-start gap-3">
<div class="flex-fill">
<h3 class="h5 m-0">{{ nzine.title }}</h3>
{% if nzine.summary %}<p class="small mt-1 mb-0">{{ nzine.summary }}</p>{% endif %}
<small class="text-muted">
categories: {{ nzine.categoryCount }}
{% if nzine.slug %} • slug: {{ nzine.slug }}{% endif %}
• state: <span class="badge bg-{{ nzine.state == 'published' ? 'success' : 'secondary' }}">{{ nzine.state }}</span>
{% if nzine.feedUrl %} • <span title="{{ nzine.feedUrl }}">RSS feed configured</span>{% endif %}
</small>
</div>
<div class="d-flex flex-row gap-2">
<a class="btn btn-sm btn-primary" href="{{ path('nzine_edit', { npub: nzine.npub }) }}">Edit</a>
{% if nzine.hasMainIndex %}
<a class="btn btn-sm btn-outline-primary" href="{{ path('nzine_view', { pubkey: nzine.npub }) }}">View</a>
{% else %}
<span class="btn btn-sm btn-outline-secondary disabled" title="Publish the NZine first">View</span>
{% endif %}
{% if nzine.npub %}
<span data-controller="utility--copy-to-clipboard">
<span class="hidden" data-utility--copy-to-clipboard-target="textToCopy">{{ nzine.npub }}</span>
<button class="btn btn-sm btn-secondary"
data-utility--copy-to-clipboard-target="copyButton"
data-action="click->utility--copy-to-clipboard#copyToClipboard"
title="Copy npub">Copy npub</button>
</span>
{% endif %}
</div>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p><small>No NZines found. Create your first digital magazine!</small></p>
{% endif %}
</div>
{% endblock %}

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

@ -1,151 +0,0 @@ @@ -1,151 +0,0 @@
{% extends 'layout.html.twig' %}
{% block body %}
{% if nzine is not defined %}
<section class="d-flex gap-3 center ln-section--newsstand">
<div class="container mt-3 mb-3">
<h1>{{ 'heading.createNzine'|trans }}</h1>
</div>
</section>
<div class="w-container mt-5">
<twig:Atoms:Alert type="info">N-Zines are in active development. Expect weirdness.</twig:Atoms:Alert>
{% if not is_granted('IS_AUTHENTICATED_FULLY') %}
<twig:Atoms:Alert type="default">
<strong>Login Required:</strong> You must be logged in to create an N-Zine.
Please log in to continue.
</twig:Atoms:Alert>
{% endif %}
<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>
<twig:Atoms:Alert type="info">
You can automatically aggregate content from RSS feeds.
Just provide a feed URL and configure categories with matching tags.
The system will periodically fetch new articles and publish them as Nostr events.
</twig:Atoms:Alert>
{{ form_start(form) }}
{{ form_widget(form) }}
{{ form_end(form) }}
</div>
{% else %}
<h1>{{ 'heading.editNzine'|trans }}</h1>
{% if not canCreateIndices %}
<twig:Atoms:Alert type="warning">
<strong>Ready to Publish:</strong> Add your categories below, then click "Publish N-Zine" to create all indices in one step.
{% if nzine.state != 'published' %}
<br><small>Current state: {{ nzine.state }}. Once published, you can add more categories later.</small>
{% endif %}
</twig:Atoms:Alert>
{% endif %}
{% if nzine.feedUrl %}
<twig:Atoms:Alert type="success">
<strong>RSS Feed:</strong> {{ nzine.feedUrl }}
{% if nzine.lastFetchedAt %}
<br><small>Last fetched: {{ nzine.lastFetchedAt|date('Y-m-d H:i:s') }}</small>
{% else %}
<br><small>{% if canCreateIndices %}Ready to fetch. Run: <code>php bin/console nzine:rss:fetch --nzine-id={{ nzine.id }}</code>{% else %}Feed will be available after publishing.{% endif %}</small>
{% endif %}
</twig:Atoms:Alert>
{% endif %}
{% if canCreateIndices %}
<h2>Indices</h2>
<ul>
{% for idx in indices %}
<li>{{ idx.getTitle() ?? 'Untitled' }}</li>
{% endfor %}
</ul>
{% endif %}
<h2>Categories</h2>
<p>
{% if canCreateIndices %}
Edit your categories. New categories will be added to the index. You have {{ nzine.mainCategories|length }} categories configured.
{% else %}
Add categories for your N-Zine. Categories help organize articles by topic.
{% if nzine.feedUrl %}
<br><strong>RSS Matching:</strong> Tags are used to match RSS feed items to categories (case-insensitive).
{% endif %}
{% endif %}
</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 Category</button>
</div>
<button type="submit" class="btn btn-primary">Save Categories</button>
{{ form_end(catForm) }}
{% if not canCreateIndices and nzine.mainCategories|length > 0 %}
<hr>
<div class="publish-section" style="background: #f8f9fa; padding: 20px; border-radius: 8px; margin-top: 20px;">
<h3>Ready to Publish?</h3>
<p>
You have configured {{ nzine.mainCategories|length }} categories.
Click the button below to publish your N-Zine. This will:
</p>
<ul>
<li>Create the main index with references to all category indices</li>
<li>Generate and sign {{ nzine.mainCategories|length }} category indices</li>
<li>Make your N-Zine publicly available</li>
{% if nzine.feedUrl %}
<li>Enable RSS feed fetching for automatic content aggregation</li>
{% endif %}
</ul>
<form method="post" action="{{ path('nzine_publish', {npub: nzine.npub}) }}" onsubmit="return confirm('Are you ready to publish? This will create all indices and cannot be easily undone.');">
<button type="submit" class="btn btn-success btn-lg">
<strong>Publish N-Zine</strong>
</button>
</form>
<p style="margin-top: 10px;"><small><em>Note: You'll only need to sign the indices once. After publishing, you can still add more categories.</em></small></p>
</div>
{% endif %}
{% if nzine.feedUrl and canCreateIndices %}
<hr>
<h2>RSS Feed Management</h2>
<p>
<strong>Feed URL:</strong> {{ nzine.feedUrl }}<br>
{% if nzine.lastFetchedAt %}
<strong>Last Fetched:</strong> {{ nzine.lastFetchedAt|date('Y-m-d H:i:s') }}<br>
{% endif %}
</p>
<div class="rss-actions">
<p>Fetch RSS feed articles using the console command:</p>
<code style="display: block; background: #f5f5f5; padding: 10px; margin: 10px 0; border-radius: 4px;">
docker-compose exec php php bin/console nzine:rss:fetch --nzine-id={{ nzine.id }}
</code>
<p><small>Or test without publishing: <code>--dry-run</code></small></p>
<p><small>Set up a cron job to automate fetching. See documentation for details.</small></p>
</div>
{% endif %}
{% endif %}
{% endblock %}

16
templates/pages/nzine.html.twig

@ -1,16 +0,0 @@ @@ -1,16 +0,0 @@
{% extends 'layout.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 %}
Loading…
Cancel
Save