20 changed files with 36 additions and 2706 deletions
@ -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 @@ |
|||||||
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 @@ |
|||||||
|
<?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 @@ |
|||||||
<?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 @@ |
|||||||
<?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 @@ |
|||||||
<?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 @@ |
|||||||
<?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 @@ |
|||||||
<?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 @@ |
|||||||
<?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 @@ |
|||||||
<?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 @@ |
|||||||
<?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 @@ |
|||||||
<?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 @@ |
|||||||
<?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 @@ |
|||||||
<?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 @@ |
|||||||
{% 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 @@ |
|||||||
{% 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 @@ |
|||||||
{% 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