20 changed files with 36 additions and 2706 deletions
@ -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>`; |
||||
} |
||||
} |
||||
} |
||||
|
||||
@ -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); |
||||
} |
||||
} |
||||
} |
||||
|
||||
@ -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'); |
||||
} |
||||
} |
||||
@ -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()); |
||||
} |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
@ -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]); |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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/', |
||||
] |
||||
]; |
||||
} |
||||
} |
||||
|
||||
@ -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) |
||||
{ |
||||
} |
||||
} |
||||
@ -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 |
||||
{ |
||||
} |
||||
} |
||||
@ -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() |
||||
// ; |
||||
// } |
||||
} |
||||
@ -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), |
||||
]); |
||||
} |
||||
} |
||||
@ -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(); |
||||
} |
||||
} |
||||
|
||||
@ -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 %} |
||||
|
||||
@ -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 %} |
||||
@ -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…
Reference in new issue