Browse Source

Name the elastic index, update some features

imwald
Nuša Pukšič 5 months ago
parent
commit
8c8d6d214e
  1. 1
      .env.dist
  2. 3
      .gitignore
  3. 18
      assets/controllers/copy_to_clipboard_controller.js
  4. 16
      assets/controllers/hello_controller.js
  5. 246
      assets/controllers/nostr_publish_controller.js
  6. 2
      assets/styles/app.css
  7. 1
      config/packages/fos_elastica.yaml
  8. 21
      config/packages/workflow.yaml
  9. 82
      src/Controller/Administration/ArticleManagementController.php
  10. 253
      src/Controller/ArticleController.php
  11. 1
      src/Entity/Article.php
  12. 9
      src/Service/NostrClient.php
  13. 42
      src/Service/RedisCacheService.php
  14. 151
      src/Twig/Components/SearchComponent.php
  15. 48
      templates/admin/articles.html.twig
  16. 18
      templates/components/Organisms/Comments.html.twig
  17. 6
      templates/components/UserMenu.html.twig
  18. 33
      templates/pages/editor.html.twig

1
.env.dist

@ -47,6 +47,7 @@ ELASTICSEARCH_HOST=localhost @@ -47,6 +47,7 @@ ELASTICSEARCH_HOST=localhost
ELASTICSEARCH_PORT=9200
ELASTICSEARCH_USERNAME=elastic
ELASTICSEARCH_PASSWORD=your_password
ELASTICSEARCH_INDEX_NAME=articles
###< elastic ###
###> redis ###
REDIS_HOST=localhost

3
.gitignore vendored

@ -19,3 +19,6 @@ @@ -19,3 +19,6 @@
.phpunit.result.cache
/phpunit.xml
###< symfony/phpunit-bridge ###
###> publication ###
/publication/

18
assets/controllers/copy_to_clipboard_controller.js

@ -0,0 +1,18 @@ @@ -0,0 +1,18 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ["copyButton", "textToCopy"];
copyToClipboard(event) {
event.preventDefault();
const text = this.textToCopyTarget.textContent;
navigator.clipboard.writeText(text).then(() => {
this.copyButtonTarget.textContent = "Copied!";
setTimeout(() => {
this.copyButtonTarget.textContent = "Copy to Clipboard";
}, 2000);
}).catch(err => {
console.error('Failed to copy: ', err);
});
}
}

16
assets/controllers/hello_controller.js

@ -1,16 +0,0 @@ @@ -1,16 +0,0 @@
import { Controller } from '@hotwired/stimulus';
/*
* This is an example Stimulus controller!
*
* Any element with a data-controller="hello" attribute will cause
* this controller to be executed. The name "hello" comes from the filename:
* hello_controller.js -> "hello"
*
* Delete this file or adapt it for your use!
*/
export default class extends Controller {
connect() {
this.element.textContent = 'Hello Stimulus! Edit me in assets/controllers/hello_controller.js';
}
}

246
assets/controllers/nostr_publish_controller.js

@ -0,0 +1,246 @@ @@ -0,0 +1,246 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['form', 'publishButton', 'status'];
static values = {
publishUrl: String,
csrfToken: String
};
connect() {
console.log('Nostr publish controller connected');
this.checkNostrSupport();
}
checkNostrSupport() {
if (!window.nostr) {
this.showError('Nostr extension not found. Please install a Nostr browser extension like nos2x or Alby.');
this.publishButtonTarget.disabled = true;
}
}
async publish(event) {
event.preventDefault();
if (!window.nostr) {
this.showError('Nostr extension not found');
return;
}
this.publishButtonTarget.disabled = true;
this.showStatus('Preparing article for signing...');
try {
// Collect form data
const formData = this.collectFormData();
// Validate required fields
if (!formData.title || !formData.content) {
throw new Error('Title and content are required');
}
// Create Nostr event
const nostrEvent = await this.createNostrEvent(formData);
this.showStatus('Requesting signature from Nostr extension...');
// Sign the event with Nostr extension
const signedEvent = await window.nostr.signEvent(nostrEvent);
this.showStatus('Publishing article...');
// Send to backend
await this.sendToBackend(signedEvent, formData);
this.showSuccess('Article published successfully!');
// Optionally redirect after successful publish
setTimeout(() => {
window.location.href = `/article/d/${formData.slug}`;
}, 2000);
} catch (error) {
console.error('Publishing error:', error);
this.showError(`Publishing failed: ${error.message}`);
} finally {
this.publishButtonTarget.disabled = false;
}
}
collectFormData() {
// Find the actual form element within our target
const form = this.formTarget.querySelector('form');
if (!form) {
throw new Error('Form element not found');
}
const formData = new FormData(form);
// Get content from Quill editor if available
const quillEditor = document.querySelector('.ql-editor');
let content = formData.get('editor[content]') || '';
// Convert HTML to markdown (basic conversion)
content = this.htmlToMarkdown(content);
const title = formData.get('editor[title]') || '';
const summary = formData.get('editor[summary]') || '';
const image = formData.get('editor[image]') || '';
const topicsString = formData.get('editor[topics]') || '';
// Parse topics
const topics = topicsString.split(',')
.map(topic => topic.trim())
.filter(topic => topic.length > 0)
.map(topic => topic.startsWith('#') ? topic : `#${topic}`);
// Generate slug from title
const slug = this.generateSlug(title);
return {
title,
summary,
content,
image,
topics,
slug
};
}
async createNostrEvent(formData) {
// Get user's public key
const pubkey = await window.nostr.getPublicKey();
// Create tags array
const tags = [
['d', formData.slug], // NIP-33 replaceable event identifier
['title', formData.title],
['published_at', Math.floor(Date.now() / 1000).toString()]
];
if (formData.summary) {
tags.push(['summary', formData.summary]);
}
if (formData.image) {
tags.push(['image', formData.image]);
}
// Add topic tags
formData.topics.forEach(topic => {
tags.push(['t', topic.replace('#', '')]);
});
// Create the Nostr event (NIP-23 long-form content)
const event = {
kind: 30023, // Long-form content kind
created_at: Math.floor(Date.now() / 1000),
tags: tags,
content: formData.content,
pubkey: pubkey
};
return event;
}
async sendToBackend(signedEvent, formData) {
const response = await fetch(this.publishUrlValue, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': this.csrfTokenValue
},
body: JSON.stringify({
event: signedEvent,
formData: formData
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
}
htmlToMarkdown(html) {
// Basic HTML to Markdown conversion
// This is a simplified version - you might want to use a proper library
let markdown = html;
// Convert headers
markdown = markdown.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n');
markdown = markdown.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n');
markdown = markdown.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n');
// Convert formatting
markdown = markdown.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**');
markdown = markdown.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**');
markdown = markdown.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*');
markdown = markdown.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*');
markdown = markdown.replace(/<u[^>]*>(.*?)<\/u>/gi, '_$1_');
// Convert links
markdown = markdown.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)');
// Convert lists
markdown = markdown.replace(/<ul[^>]*>(.*?)<\/ul>/gis, '$1\n');
markdown = markdown.replace(/<ol[^>]*>(.*?)<\/ol>/gis, '$1\n');
markdown = markdown.replace(/<li[^>]*>(.*?)<\/li>/gi, '- $1\n');
// Convert paragraphs
markdown = markdown.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n');
// Convert line breaks
markdown = markdown.replace(/<br[^>]*>/gi, '\n');
// Convert blockquotes
markdown = markdown.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gis, '> $1\n\n');
// Convert code blocks
markdown = markdown.replace(/<pre[^>]*><code[^>]*>(.*?)<\/code><\/pre>/gis, '```\n$1\n```\n\n');
markdown = markdown.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`');
// Clean up HTML entities and remaining tags
markdown = markdown.replace(/&nbsp;/g, ' ');
markdown = markdown.replace(/&amp;/g, '&');
markdown = markdown.replace(/&lt;/g, '<');
markdown = markdown.replace(/&gt;/g, '>');
markdown = markdown.replace(/&quot;/g, '"');
markdown = markdown.replace(/<[^>]*>/g, ''); // Remove any remaining HTML tags
// Clean up extra whitespace
markdown = markdown.replace(/\n{3,}/g, '\n\n').trim();
return markdown;
}
generateSlug(title) {
return title
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Replace multiple hyphens with single
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
}
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>`;
}
}
}

2
assets/styles/app.css

@ -188,7 +188,7 @@ div:nth-child(odd) .featured-list { @@ -188,7 +188,7 @@ div:nth-child(odd) .featured-list {
}
.featured-list .card {
margin-bottom: 10px;
margin-bottom: 20px;
}
.featured-list .card:not(:last-child) {

1
config/packages/fos_elastica.yaml

@ -8,6 +8,7 @@ fos_elastica: @@ -8,6 +8,7 @@ fos_elastica:
indexes:
# create the index by running php bin/console fos:elastica:populate
articles:
index_name: '%env(ELASTICSEARCH_INDEX_NAME)%'
settings:
index:
# Increase refresh interval for better write performance

21
config/packages/workflow.yaml

@ -9,28 +9,19 @@ framework: @@ -9,28 +9,19 @@ framework:
property: 'currentPlace'
supports:
- App\Entity\Article
initial_marking: preview
initial_marking: empty
places:
- preview
- draft
- revised
- published
- edited
transitions:
to_draft:
from: preview
from: empty
to: draft
to_revision:
from: draft
to: revised
publish_preview:
from: preview
to: published
publish_draft:
from: draft
to: published
publish_revised:
from: revised
publish:
from:
- draft
- edited
to: published
edit:
from: published

82
src/Controller/Administration/ArticleManagementController.php

@ -0,0 +1,82 @@ @@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Controller\Administration;
use App\Service\RedisCacheService;
use FOS\ElasticaBundle\Finder\PaginatedFinderInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RedirectResponse;
class ArticleManagementController extends AbstractController
{
// This controller will handle article management functionalities.
#[Route('/admin/articles', name: 'admin_articles')]
public function listArticles(
#[Autowire(service: 'fos_elastica.finder.articles')] PaginatedFinderInterface $finder,
RedisCacheService $redisCacheService
): Response
{
// Query: latest 50, deduplicated by slug, sorted by createdAt desc
$query = [
'size' => 100, // fetch more to allow deduplication
'sort' => [
['createdAt' => ['order' => 'desc']]
]
];
$results = $finder->find($query);
$unique = [];
$articles = [];
foreach ($results as $article) {
$slug = $article->getSlug();
if (!isset($unique[$slug])) {
$unique[$slug] = true;
$articles[] = $article;
if (count($articles) >= 50) break;
}
}
// Fetch main index and extract nested indexes
$mainIndex = $redisCacheService->getMagazineIndex('magazine-newsroom-magazine-by-newsroom');
$indexes = [];
if ($mainIndex && $mainIndex->getTags() !== null) {
foreach ($mainIndex->getTags() as $tag) {
if ($tag[0] === 'a' && isset($tag[1])) {
$parts = explode(':', $tag[1], 3);
$indexes[$tag[1]] = end($parts); // Extract index key from tag
}
}
}
return $this->render('admin/articles.html.twig', [
'articles' => $articles,
'indexes' => $indexes,
]);
}
#[Route('/admin/articles/add-to-index', name: 'admin_article_add_to_index', methods: ['POST'])]
public function addToIndex(
Request $request,
RedisCacheService $redisCacheService
): RedirectResponse {
$slug = $request->request->get('slug');
$indexKey = $request->request->get('index_key');
if (!$slug || !$indexKey) {
$this->addFlash('danger', 'Missing article or index selection.');
return $this->redirectToRoute('admin_articles');
}
// Build the tag: ['a', 'article:'.$slug]
$articleTag = ['a', 'article:' . $slug];
$success = $redisCacheService->addArticleToIndex($indexKey, $articleTag);
if ($success) {
$this->addFlash('success', 'Article added to index.');
} else {
$this->addFlash('danger', 'Failed to add article to index.');
}
return $this->redirectToRoute('admin_articles');
}
}

253
src/Controller/ArticleController.php

@ -10,16 +10,19 @@ use App\Service\RedisCacheService; @@ -10,16 +10,19 @@ use App\Service\RedisCacheService;
use App\Util\CommonMark\Converter;
use Doctrine\ORM\EntityManagerInterface;
use League\CommonMark\Exception\CommonMarkException;
use Mdanter\Ecc\Crypto\Signature\SchnorrSignature;
use nostriphant\NIP19\Bech32;
use nostriphant\NIP19\Data\NAddr;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use swentel\nostr\Key\Key;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\String\Slugger\AsciiSlugger;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Workflow\WorkflowInterface;
class ArticleController extends AbstractController
@ -27,7 +30,7 @@ class ArticleController extends AbstractController @@ -27,7 +30,7 @@ class ArticleController extends AbstractController
/**
* @throws \Exception
*/
#[Route('/article/{naddr}', name: 'article-naddr')]
#[Route('/article/{naddr}', name: 'article-naddr', requirements: ['naddr' => '^(naddr1[0-9a-z]{59})$'])]
public function naddr(NostrClient $nostrClient, $naddr)
{
$decoded = new Bech32($naddr);
@ -255,4 +258,252 @@ class ArticleController extends AbstractController @@ -255,4 +258,252 @@ class ArticleController extends AbstractController
]);
}
/**
* API endpoint to receive and process signed Nostr events
* @throws \Exception
*/
#[Route('/api/article/publish', name: 'api-article-publish', methods: ['POST'])]
public function publishNostrEvent(
Request $request,
EntityManagerInterface $entityManager,
NostrClient $nostrClient,
WorkflowInterface $articlePublishingWorkflow,
CsrfTokenManagerInterface $csrfTokenManager
): JsonResponse {
try {
// Verify CSRF token
$csrfToken = $request->headers->get('X-CSRF-TOKEN');
if (!$csrfTokenManager->isTokenValid(new \Symfony\Component\Security\Csrf\CsrfToken('nostr_publish', $csrfToken))) {
return new JsonResponse(['error' => 'Invalid CSRF token'], 403);
}
// Get JSON data
$data = json_decode($request->getContent(), true);
if (!$data || !isset($data['event'])) {
return new JsonResponse(['error' => 'Invalid request data'], 400);
}
$signedEvent = $data['event'];
$formData = $data['formData'] ?? [];
// Validate Nostr event structure
$this->validateNostrEvent($signedEvent);
// Verify the event signature
if (!$this->verifyNostrSignature($signedEvent)) {
return new JsonResponse(['error' => 'Invalid event signature'], 400);
}
// Check if user is authenticated and matches the event pubkey
$user = $this->getUser();
if (!$user) {
return new JsonResponse(['error' => 'User not authenticated'], 401);
}
$key = new Key();
$currentPubkey = $key->convertToHex($user->getUserIdentifier());
if ($signedEvent['pubkey'] !== $currentPubkey) {
return new JsonResponse(['error' => 'Event pubkey does not match authenticated user'], 403);
}
// Extract article data from the signed event
$articleData = $this->extractArticleDataFromEvent($signedEvent, $formData);
// Check if article with same slug already exists for this author
$repository = $entityManager->getRepository(Article::class);
$existingArticle = $repository->findOneBy([
'slug' => $articleData['slug'],
'pubkey' => $currentPubkey
]);
if ($existingArticle) {
// Update existing article (NIP-33 replaceable event)
$article = $existingArticle;
} else {
// Create new article
$article = new Article();
$article->setPubkey($currentPubkey);
$article->setKind(KindsEnum::LONGFORM);
}
// Update article properties
$article->setEventId($this->generateEventId($signedEvent));
$article->setSlug($articleData['slug']);
$article->setTitle($articleData['title']);
$article->setSummary($articleData['summary']);
$article->setContent($articleData['content']);
$article->setImage($articleData['image']);
$article->setTopics($articleData['topics']);
$article->setSig($signedEvent['sig']);
$article->setRaw($signedEvent);
$article->setCreatedAt(new \DateTimeImmutable('@' . $signedEvent['created_at']));
$article->setPublishedAt(new \DateTimeImmutable());
// Check workflow permissions
if ($articlePublishingWorkflow->can($article, 'publish')) {
$articlePublishingWorkflow->apply($article, 'publish');
}
// Save to database
$entityManager->persist($article);
$entityManager->flush();
// Optionally publish to Nostr relays
try {
// Convert the signed event array to a proper Event object
$eventObj = new \swentel\nostr\Event\Event();
$eventObj->setId($signedEvent['id']);
$eventObj->setPublicKey($signedEvent['pubkey']);
$eventObj->setCreatedAt($signedEvent['created_at']);
$eventObj->setKind($signedEvent['kind']);
$eventObj->setTags($signedEvent['tags']);
$eventObj->setContent($signedEvent['content']);
$eventObj->setSignature($signedEvent['sig']);
// Get user's relays or use default ones
$relays = [];
if ($user && method_exists($user, 'getRelays') && $user->getRelays()) {
foreach ($user->getRelays() as $relayArr) {
if (isset($relayArr[1]) && isset($relayArr[2]) && $relayArr[2] === 'write') {
$relays[] = $relayArr[1];
}
}
}
// Fallback to default relays if no user relays found
if (empty($relays)) {
$relays = [
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://nos.lol'
];
}
$nostrClient->publishEvent($eventObj, $relays);
} catch (\Exception $e) {
// Log error but don't fail the request - article is saved locally
error_log('Failed to publish to Nostr relays: ' . $e->getMessage());
}
return new JsonResponse([
'success' => true,
'message' => 'Article published successfully',
'articleId' => $article->getId(),
'slug' => $article->getSlug()
]);
} catch (\Exception $e) {
return new JsonResponse([
'error' => 'Publishing failed: ' . $e->getMessage()
], 500);
}
}
private function validateNostrEvent(array $event): void
{
$requiredFields = ['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig'];
foreach ($requiredFields as $field) {
if (!isset($event[$field])) {
throw new \InvalidArgumentException("Missing required field: $field");
}
}
if ($event['kind'] !== 30023) {
throw new \InvalidArgumentException('Invalid event kind. Expected 30023 for long-form content.');
}
// Validate d tag exists (required for NIP-33)
$dTagFound = false;
foreach ($event['tags'] as $tag) {
if (is_array($tag) && count($tag) >= 2 && $tag[0] === 'd') {
$dTagFound = true;
break;
}
}
if (!$dTagFound) {
throw new \InvalidArgumentException('Missing required "d" tag for replaceable event');
}
}
private function verifyNostrSignature(array $event): bool
{
try {
// Reconstruct the event ID
$serializedEvent = json_encode([
0,
$event['pubkey'],
$event['created_at'],
$event['kind'],
$event['tags'],
$event['content']
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$eventId = hash('sha256', $serializedEvent);
// Verify the event ID matches
if ($eventId !== $event['id']) {
return false;
}
return (new SchnorrSignature())->verify($event['pubkey'], $event['sig'], $event['id']);
} catch (\Exception $e) {
return false;
}
}
private function extractArticleDataFromEvent(array $event, array $formData): array
{
$data = [
'title' => '',
'summary' => '',
'content' => $event['content'],
'image' => '',
'topics' => [],
'slug' => ''
];
// Extract data from tags
foreach ($event['tags'] as $tag) {
if (!is_array($tag) || count($tag) < 2) continue;
switch ($tag[0]) {
case 'd':
$data['slug'] = $tag[1];
break;
case 'title':
$data['title'] = $tag[1];
break;
case 'summary':
$data['summary'] = $tag[1];
break;
case 'image':
$data['image'] = $tag[1];
break;
case 't':
$data['topics'][] = $tag[1];
break;
}
}
// Fallback to form data if not found in tags
if (empty($data['title']) && !empty($formData['title'])) {
$data['title'] = $formData['title'];
}
if (empty($data['summary']) && !empty($formData['summary'])) {
$data['summary'] = $formData['summary'];
}
return $data;
}
private function generateEventId(array $event): string
{
return $event['id'];
}
// ...existing code...
}

1
src/Entity/Article.php

@ -8,7 +8,6 @@ use App\Enum\KindsEnum; @@ -8,7 +8,6 @@ use App\Enum\KindsEnum;
use App\Repository\ArticleRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use FOS\ElasticaBundle\Provider\IndexableInterface;
/**
* Entity storing long-form articles

9
src/Service/NostrClient.php

@ -432,8 +432,8 @@ class NostrClient @@ -432,8 +432,8 @@ class NostrClient
// Create request using the helper method
$request = $this->createNostrRequest(
kinds: [KindsEnum::COMMENTS->value, KindsEnum::TEXT_NOTE->value],
filters: ['tag' => ['#A', [$coordinate]]],
kinds: [KindsEnum::COMMENTS->value, KindsEnum::TEXT_NOTE->value, KindsEnum::ZAP->value],
filters: ['tag' => ['#a', [$coordinate]]],
relaySet: $relaySet
);
@ -460,10 +460,7 @@ class NostrClient @@ -460,10 +460,7 @@ class NostrClient
$this->logger->info('Getting zaps for coordinate', ['coordinate' => $coordinate]);
// Parse the coordinate to get pubkey
$parts = explode(':', $coordinate);
if (count($parts) !== 3) {
throw new \InvalidArgumentException('Invalid coordinate format, expected kind:pubkey:identifier');
}
$parts = explode(':', $coordinate, 3);
$pubkey = $parts[1];
// Get author's relays for better chances of finding zaps

42
src/Service/RedisCacheService.php

@ -67,4 +67,46 @@ readonly class RedisCacheService @@ -67,4 +67,46 @@ readonly class RedisCacheService
return [];
}
}
/**
* Get a magazine index object by key.
* @param string $key
* @return object|null
*/
public function getMagazineIndex(string $key): ?object
{
try {
$item = $this->redisCache->getItem($key);
return $item->isHit() ? $item->get() : null;
} catch (\Exception $e) {
$this->logger->error('Error fetching magazine index.', ['exception' => $e]);
return null;
}
}
/**
* Update a magazine index by inserting a new article tag at the top.
* @param string $key
* @param array $articleTag The tag array, e.g. ['a', 'article:slug', ...]
* @return bool
*/
public function addArticleToIndex(string $key, array $articleTag): bool
{
$index = $this->getMagazineIndex($key);
if (!$index || !isset($index->tags) || !is_array($index->tags)) {
$this->logger->error('Invalid index object or missing tags array.');
return false;
}
// Insert the new article tag at the top
array_unshift($index->tags, $articleTag);
try {
$item = $this->redisCache->getItem($key);
$item->set($index);
$this->redisCache->save($item);
return true;
} catch (\Exception $e) {
$this->logger->error('Error updating magazine index.', ['exception' => $e]);
return false;
}
}
}

151
src/Twig/Components/SearchComponent.php

@ -123,46 +123,121 @@ final class SearchComponent @@ -123,46 +123,121 @@ final class SearchComponent
$this->creditsManager->spendCredits($this->npub, 1, 'search');
$this->credits--;
// Create an optimized query using collapse correctly
// Step 1: Run a quick naive query on title and summary only
$quickResults = $this->performQuickSearch($this->query);
// Step 2: Run the comprehensive query
$comprehensiveResults = $this->performComprehensiveSearch($this->query);
// Combine results, making sure we don't have duplicates
$this->results = $this->mergeSearchResults($quickResults, $comprehensiveResults);
// Cache the search results in session
$this->saveSearchToSession($this->query, $this->results);
} catch (\Exception $e) {
$this->logger->error('Search error: ' . $e->getMessage());
$this->results = [];
}
}
/**
* Perform a quick search on title and summary only
*/
private function performQuickSearch(string $query): array
{
$mainQuery = new Query();
// Build multi-match query for searching across fields
// Simple multi-match query for searching across title and summary only
$multiMatch = new MultiMatch();
$multiMatch->setQuery($this->query);
$multiMatch->setQuery($query);
$multiMatch->setFields([
'title^3',
'summary^2',
'content^1.5',
'topics'
'title^5', // Increased weight for title
'summary^3' // Increased weight for summary
]);
$multiMatch->setType(MultiMatch::TYPE_MOST_FIELDS);
$multiMatch->setFuzziness('AUTO');
$multiMatch->setType(MultiMatch::TYPE_BEST_FIELDS); // Changed to BEST_FIELDS for more precise matching
$multiMatch->setOperator(MultiMatch::OPERATOR_AND); // Require all terms to match for better precision
$boolQuery = new BoolQuery();
$boolQuery->addMust($multiMatch);
$boolQuery->addMustNot(new Query\Wildcard('slug', '*/*'));
// For text fields, we need to use a different approach
// Create a regexp query that matches content with at least 250 chars
// This is a simplification - actually matches content with enough words
$mainQuery->setQuery($boolQuery);
// Use the collapse field to prevent duplicate content
$mainQuery->setParam('collapse', [
'field' => 'slug'
]);
// Set a minimum score to filter out irrelevant results
$mainQuery->setMinScore(0.5); // Higher minimum score for quick results
// Sort by relevance only for quick results
$mainQuery->setSort(['_score' => ['order' => 'desc']]);
// Limit to 5 results for the quick search
$mainQuery->setSize(5);
// Execute the quick search
$results = $this->finder->find($mainQuery);
$this->logger->info('Quick search results count: ' . count($results));
return $results;
}
/**
* Perform a comprehensive search across all fields
*/
private function performComprehensiveSearch(string $query): array
{
$mainQuery = new Query();
// Build bool query with multiple conditions for more precise matching
$boolQuery = new BoolQuery();
// Add exact phrase match with high boost for very relevant results
$phraseMatch = new Query\MatchPhrase();
$phraseMatch->setField('title', [
'query' => $query,
'boost' => 10
]);
$boolQuery->addShould($phraseMatch);
// Add regular multi-match with adjusted weights
$multiMatch = new MultiMatch();
$multiMatch->setQuery($query);
$multiMatch->setFields([
'title^4',
'summary^3',
'content^1.2',
'topics^2'
]);
$multiMatch->setType(MultiMatch::TYPE_MOST_FIELDS);
$multiMatch->setFuzziness('AUTO');
$multiMatch->setOperator(MultiMatch::OPERATOR_AND); // Require all terms to match
$boolQuery->addMust($multiMatch);
// Exclude specific patterns
$boolQuery->addMustNot(new Query\Wildcard('slug', '*/*'));
// For content relevance, filter by minimum content length
$lengthFilter = new Query\QueryString();
$lengthFilter->setQuery('content:/.{250,}/');
// $boolQuery->addFilter($lengthFilter);
$boolQuery->addFilter($lengthFilter);
$mainQuery->setQuery($boolQuery);
// Use the collapse field directly in the array format
// This fixes the [collapse] failed to parse field [inner_hits] error
// Use the collapse field
$mainQuery->setParam('collapse', [
'field' => 'slug',
'inner_hits' => [
'name' => 'latest_articles',
'size' => 1 // Show more related articles
'size' => 1
]
]);
// Reduce the minimum score threshold to include more results
$mainQuery->setMinScore(0.1); // Lower minimum score
// Increase minimum score to filter out irrelevant results
$mainQuery->setMinScore(0.3);
// Sort by score and createdAt
$mainQuery->setSort([
@ -170,25 +245,43 @@ final class SearchComponent @@ -170,25 +245,43 @@ final class SearchComponent
'createdAt' => ['order' => 'desc']
]);
// Add pagination
$offset = ($this->page - 1) * $this->resultsPerPage;
// Add pagination for the comprehensive results
// Adjust the pagination to account for the quick results
$offset = ($this->page - 1) * ($this->resultsPerPage - 5);
if ($offset < 0) $offset = 0;
$mainQuery->setFrom($offset);
$mainQuery->setSize($this->resultsPerPage);
$mainQuery->setSize($this->resultsPerPage - 5);
// Execute the search
$results = $this->finder->find($mainQuery);
$this->logger->info('Search results count: ' . count($results));
$this->logger->info('Search results: ', ['results' => $results]);
$this->logger->info('Comprehensive search results count: ' . count($results));
$this->results = $results;
return $results;
}
// Cache the search results in session
$this->saveSearchToSession($this->query, $this->results);
/**
* Merge quick and comprehensive search results, ensuring no duplicates
*/
private function mergeSearchResults(array $quickResults, array $comprehensiveResults): array
{
$mergedResults = $quickResults;
$slugs = [];
} catch (\Exception $e) {
$this->logger->error('Search error: ' . $e->getMessage());
$this->results = [];
// Collect slugs from quick results to avoid duplicates
foreach ($quickResults as $result) {
$slugs[] = $result->getSlug();
}
// Add comprehensive results that aren't already in quick results
foreach ($comprehensiveResults as $result) {
if (!in_array($result->getSlug(), $slugs)) {
$mergedResults[] = $result;
$slugs[] = $result->getSlug();
}
}
return $mergedResults;
}
#[LiveListener('creditsAdded')]

48
templates/admin/articles.html.twig

@ -0,0 +1,48 @@ @@ -0,0 +1,48 @@
{% extends 'base.html.twig' %}
{% block body %}
<h1>Latest 50 Articles</h1>
<table>
<thead>
<tr>
<th>Title</th>
<th>Summary</th>
<th>Coordinate</th>
<th>Add to Index</th>
</tr>
</thead>
<tbody>
{% for article in articles %}
<tr>
<td><a href="{{ path('article-slug', {slug: article.slug|url_encode}) }}">{{ article.title }}</a></td>
<td>{{ article.summary|slice(0, 100) }}{% if article.summary|length > 100 %}...{% endif %}</td>
<td>
<span data-controller="copy-to-clipboard">
<span class="hidden" data-copy-to-clipboard-target="textToCopy">30023:{{ article.pubkey }}:{{ article.slug }}</span>
<button type="button"
data-copy-to-clipboard-target="copyButton"
data-action="click->copy-to-clipboard#copyToClipboard"
style="margin-left: 0.5em;">Copy to Clipboard</button>
</span>
</td>
<td>
<form method="post" action="{{ path('admin_article_add_to_index') }}" style="display:inline;">
<input type="hidden" name="slug" value="{{ article.slug }}">
<label>
<select name="index_key" required>
<option value="">Select index</option>
{% for index in indexes %}
<option value="{{ index }}">{{ index }}</option>
{% endfor %}
</select>
</label>
<button type="submit">Add</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="4">No articles found.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

18
templates/components/Organisms/Comments.html.twig

@ -1,10 +1,11 @@ @@ -1,10 +1,11 @@
<div class="comments">
{% for item in list %}
<div class="card comment">
<div class="card comment {% if item.kind is defined and item.kind == 9735 %}zap-comment{% endif %}">
<div class="metadata">
<p><twig:Molecules:UserFromNpub ident="{{ item.pubkey }}" /></p>
<small>{{ item.created_at|date('F j Y') }}</small>
</div>
<div class="card-body">
<twig:Atoms:Content content="{{ item.content }}" />
</div>
@ -20,6 +21,21 @@ @@ -20,6 +21,21 @@
</div>
</div>
{% endif %}
{% if item.tags is defined and item.tags|length > 0 %}
<ul>
{% for tag in item.tags %}
<li>
<strong>{{ tag[0] }}:</strong> {{ tag[1] }}
{% if tag[2] is defined %}
<span>{{ tag[2] }}</span>
{% endif %}
{% if tag[3] is defined %}
<span>{{ tag[3] }}</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endfor %}
</div>

6
templates/components/UserMenu.html.twig

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

33
templates/pages/editor.html.twig

@ -13,7 +13,40 @@ @@ -13,7 +13,40 @@
{% block body %}
<div {{ stimulus_controller('nostr-publish', {
publishUrl: path('api-article-publish'),
csrfToken: csrf_token('nostr_publish')
}) }} data-nostr-publish-form-target="form">
<!-- Status messages -->
<div data-nostr-publish-target="status"></div>
{{ form_start(form) }}
<!-- Override the submit button to use Nostr publishing -->
{{ form_row(form.title) }}
{{ form_row(form.summary) }}
{{ form_row(form.content) }}
{{ form_row(form.image) }}
{{ form_row(form.topics) }}
<!-- Custom actions section with Nostr publish button -->
<div class="actions">
<!-- Replace the default submit button with Nostr publish button -->
<button type="button"
class="btn btn-primary"
data-nostr-publish-target="publishButton"
data-action="click->nostr-publish#publish">
Publish to Nostr
</button>
</div>
<!-- Keep the original submit button hidden for fallback -->
<div style="display: none;">
{{ form_row(form.actions.submit) }}
</div>
{{ form_end(form) }}
</div>
{% endblock %}

Loading…
Cancel
Save