From 8c8d6d214ea6694f5eda3ad0f7547ea1f277ef59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Tue, 26 Aug 2025 17:05:46 +0200 Subject: [PATCH] Name the elastic index, update some features --- .env.dist | 1 + .gitignore | 3 + .../copy_to_clipboard_controller.js | 18 ++ assets/controllers/hello_controller.js | 16 -- .../controllers/nostr_publish_controller.js | 246 +++++++++++++++++ assets/styles/app.css | 2 +- config/packages/fos_elastica.yaml | 1 + config/packages/workflow.yaml | 21 +- .../ArticleManagementController.php | 82 ++++++ src/Controller/ArticleController.php | 253 +++++++++++++++++- src/Entity/Article.php | 1 - src/Service/NostrClient.php | 9 +- src/Service/RedisCacheService.php | 42 +++ src/Twig/Components/SearchComponent.php | 209 +++++++++++---- templates/admin/articles.html.twig | 48 ++++ .../components/Organisms/Comments.html.twig | 18 +- templates/components/UserMenu.html.twig | 6 +- templates/pages/editor.html.twig | 37 ++- 18 files changed, 909 insertions(+), 104 deletions(-) create mode 100644 assets/controllers/copy_to_clipboard_controller.js delete mode 100644 assets/controllers/hello_controller.js create mode 100644 assets/controllers/nostr_publish_controller.js create mode 100644 src/Controller/Administration/ArticleManagementController.php create mode 100644 templates/admin/articles.html.twig diff --git a/.env.dist b/.env.dist index 4bcb2ff..646ac56 100644 --- a/.env.dist +++ b/.env.dist @@ -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 diff --git a/.gitignore b/.gitignore index 3182f27..cc80248 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ .phpunit.result.cache /phpunit.xml ###< symfony/phpunit-bridge ### + +###> publication ### +/publication/ diff --git a/assets/controllers/copy_to_clipboard_controller.js b/assets/controllers/copy_to_clipboard_controller.js new file mode 100644 index 0000000..34998f0 --- /dev/null +++ b/assets/controllers/copy_to_clipboard_controller.js @@ -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); + }); + } +} diff --git a/assets/controllers/hello_controller.js b/assets/controllers/hello_controller.js deleted file mode 100644 index e847027..0000000 --- a/assets/controllers/hello_controller.js +++ /dev/null @@ -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'; - } -} diff --git a/assets/controllers/nostr_publish_controller.js b/assets/controllers/nostr_publish_controller.js new file mode 100644 index 0000000..7d50e74 --- /dev/null +++ b/assets/controllers/nostr_publish_controller.js @@ -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>/gi, '# $1\n\n'); + markdown = markdown.replace(/]*>(.*?)<\/h2>/gi, '## $1\n\n'); + markdown = markdown.replace(/]*>(.*?)<\/h3>/gi, '### $1\n\n'); + + // Convert formatting + markdown = markdown.replace(/]*>(.*?)<\/strong>/gi, '**$1**'); + markdown = markdown.replace(/]*>(.*?)<\/b>/gi, '**$1**'); + markdown = markdown.replace(/]*>(.*?)<\/em>/gi, '*$1*'); + markdown = markdown.replace(/]*>(.*?)<\/i>/gi, '*$1*'); + markdown = markdown.replace(/]*>(.*?)<\/u>/gi, '_$1_'); + + // Convert links + markdown = markdown.replace(/]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)'); + + // Convert lists + markdown = markdown.replace(/]*>(.*?)<\/ul>/gis, '$1\n'); + markdown = markdown.replace(/]*>(.*?)<\/ol>/gis, '$1\n'); + markdown = markdown.replace(/]*>(.*?)<\/li>/gi, '- $1\n'); + + // Convert paragraphs + markdown = markdown.replace(/]*>(.*?)<\/p>/gi, '$1\n\n'); + + // Convert line breaks + markdown = markdown.replace(/]*>/gi, '\n'); + + // Convert blockquotes + markdown = markdown.replace(/]*>(.*?)<\/blockquote>/gis, '> $1\n\n'); + + // Convert code blocks + markdown = markdown.replace(/]*>]*>(.*?)<\/code><\/pre>/gis, '```\n$1\n```\n\n'); + markdown = markdown.replace(/]*>(.*?)<\/code>/gi, '`$1`'); + + // Clean up HTML entities and remaining tags + markdown = markdown.replace(/ /g, ' '); + markdown = markdown.replace(/&/g, '&'); + markdown = markdown.replace(/</g, '<'); + markdown = markdown.replace(/>/g, '>'); + markdown = markdown.replace(/"/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 = `
${message}
`; + } + } + + showSuccess(message) { + if (this.hasStatusTarget) { + this.statusTarget.innerHTML = `
${message}
`; + } + } + + showError(message) { + if (this.hasStatusTarget) { + this.statusTarget.innerHTML = `
${message}
`; + } + } +} diff --git a/assets/styles/app.css b/assets/styles/app.css index e23e5a0..2a613cc 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -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) { diff --git a/config/packages/fos_elastica.yaml b/config/packages/fos_elastica.yaml index b73d506..9b0014d 100644 --- a/config/packages/fos_elastica.yaml +++ b/config/packages/fos_elastica.yaml @@ -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 diff --git a/config/packages/workflow.yaml b/config/packages/workflow.yaml index aad92b3..0840c7d 100644 --- a/config/packages/workflow.yaml +++ b/config/packages/workflow.yaml @@ -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 diff --git a/src/Controller/Administration/ArticleManagementController.php b/src/Controller/Administration/ArticleManagementController.php new file mode 100644 index 0000000..a16e2dc --- /dev/null +++ b/src/Controller/Administration/ArticleManagementController.php @@ -0,0 +1,82 @@ + 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'); + } +} diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 0a8ca3f..fe9527d 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -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 /** * @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 ]); } + /** + * 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... + } diff --git a/src/Entity/Article.php b/src/Entity/Article.php index b342498..bb86536 100644 --- a/src/Entity/Article.php +++ b/src/Entity/Article.php @@ -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 diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index dfa825a..4f895aa 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -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 $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 diff --git a/src/Service/RedisCacheService.php b/src/Service/RedisCacheService.php index 0f280be..53b9e47 100644 --- a/src/Service/RedisCacheService.php +++ b/src/Service/RedisCacheService.php @@ -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; + } + } } diff --git a/src/Twig/Components/SearchComponent.php b/src/Twig/Components/SearchComponent.php index 89fc6c3..d074515 100644 --- a/src/Twig/Components/SearchComponent.php +++ b/src/Twig/Components/SearchComponent.php @@ -123,64 +123,14 @@ final class SearchComponent $this->creditsManager->spendCredits($this->npub, 1, 'search'); $this->credits--; - // Create an optimized query using collapse correctly - $mainQuery = new Query(); - - // Build multi-match query for searching across fields - $multiMatch = new MultiMatch(); - $multiMatch->setQuery($this->query); - $multiMatch->setFields([ - 'title^3', - 'summary^2', - 'content^1.5', - 'topics' - ]); - $multiMatch->setType(MultiMatch::TYPE_MOST_FIELDS); - $multiMatch->setFuzziness('AUTO'); - - $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 - $lengthFilter = new Query\QueryString(); - $lengthFilter->setQuery('content:/.{250,}/'); - // $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 - $mainQuery->setParam('collapse', [ - 'field' => 'slug', - 'inner_hits' => [ - 'name' => 'latest_articles', - 'size' => 1 // Show more related articles - ] - ]); - - // Reduce the minimum score threshold to include more results - $mainQuery->setMinScore(0.1); // Lower minimum score - - // Sort by score and createdAt - $mainQuery->setSort([ - '_score' => ['order' => 'desc'], - 'createdAt' => ['order' => 'desc'] - ]); - - // Add pagination - $offset = ($this->page - 1) * $this->resultsPerPage; - $mainQuery->setFrom($offset); - $mainQuery->setSize($this->resultsPerPage); - - // Execute the search - $results = $this->finder->find($mainQuery); - $this->logger->info('Search results count: ' . count($results)); - $this->logger->info('Search results: ', ['results' => $results]); - - $this->results = $results; + // 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); @@ -191,6 +141,149 @@ final class SearchComponent } } + /** + * Perform a quick search on title and summary only + */ + private function performQuickSearch(string $query): array + { + $mainQuery = new Query(); + + // Simple multi-match query for searching across title and summary only + $multiMatch = new MultiMatch(); + $multiMatch->setQuery($query); + $multiMatch->setFields([ + 'title^5', // Increased weight for title + 'summary^3' // Increased weight for summary + ]); + $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', '*/*')); + + $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); + + $mainQuery->setQuery($boolQuery); + + // Use the collapse field + $mainQuery->setParam('collapse', [ + 'field' => 'slug', + 'inner_hits' => [ + 'name' => 'latest_articles', + 'size' => 1 + ] + ]); + + // Increase minimum score to filter out irrelevant results + $mainQuery->setMinScore(0.3); + + // Sort by score and createdAt + $mainQuery->setSort([ + '_score' => ['order' => 'desc'], + 'createdAt' => ['order' => 'desc'] + ]); + + // 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 - 5); + + // Execute the search + $results = $this->finder->find($mainQuery); + $this->logger->info('Comprehensive search results count: ' . count($results)); + + return $results; + } + + /** + * Merge quick and comprehensive search results, ensuring no duplicates + */ + private function mergeSearchResults(array $quickResults, array $comprehensiveResults): array + { + $mergedResults = $quickResults; + $slugs = []; + + // 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')] public function incrementCreditsCount(): void { diff --git a/templates/admin/articles.html.twig b/templates/admin/articles.html.twig new file mode 100644 index 0000000..ce2bfec --- /dev/null +++ b/templates/admin/articles.html.twig @@ -0,0 +1,48 @@ +{% extends 'base.html.twig' %} + +{% block body %} +

Latest 50 Articles

+ + + + + + + + + + + {% for article in articles %} + + + + + + + {% else %} + + {% endfor %} + +
TitleSummaryCoordinateAdd to Index
{{ article.title }}{{ article.summary|slice(0, 100) }}{% if article.summary|length > 100 %}...{% endif %} + + + + + +
+ + + +
+
No articles found.
+{% endblock %} diff --git a/templates/components/Organisms/Comments.html.twig b/templates/components/Organisms/Comments.html.twig index f38a086..f527c16 100644 --- a/templates/components/Organisms/Comments.html.twig +++ b/templates/components/Organisms/Comments.html.twig @@ -1,10 +1,11 @@
{% for item in list %} -
+
+
@@ -20,6 +21,21 @@
{% endif %} + {% if item.tags is defined and item.tags|length > 0 %} +
    + {% for tag in item.tags %} +
  • + {{ tag[0] }}: {{ tag[1] }} + {% if tag[2] is defined %} + {{ tag[2] }} + {% endif %} + {% if tag[3] is defined %} + {{ tag[3] }} + {% endif %} +
  • + {% endfor %} +
+ {% endif %}
{% endfor %} diff --git a/templates/components/UserMenu.html.twig b/templates/components/UserMenu.html.twig index 9b9e0de..8a80986 100644 --- a/templates/components/UserMenu.html.twig +++ b/templates/components/UserMenu.html.twig @@ -12,9 +12,9 @@ {# #} {% endif %}
    -{#
  • #} -{# Write an article#} -{#
  • #} +
  • + Write an article +
  • {#
  • #} {# {{ 'heading.createNzine'|trans }}#} {#
  • #} diff --git a/templates/pages/editor.html.twig b/templates/pages/editor.html.twig index 6545edb..2d65438 100644 --- a/templates/pages/editor.html.twig +++ b/templates/pages/editor.html.twig @@ -13,7 +13,40 @@ {% block body %} - {{ form_start(form) }} - {{ form_end(form) }} +
    + + +
    + + {{ form_start(form) }} + + + {{ form_row(form.title) }} + {{ form_row(form.summary) }} + {{ form_row(form.content) }} + {{ form_row(form.image) }} + {{ form_row(form.topics) }} + + +
    + + +
    + + +
    + {{ form_row(form.actions.submit) }} +
    + + {{ form_end(form) }} +
    {% endblock %}