From 77a9fad2ed48af7856c30a0c6a3176f8973b8328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Sun, 7 Dec 2025 19:25:57 +0100 Subject: [PATCH] Move stimulus controllers around, ES feature flag --- .env.dist | 2 + ...nique_visitors_per_day_chart_controller.js | 0 .../visits_per_day_chart_controller.js | 0 .../advanced_metadata_controller.js | 0 .../author_articles_controller.js | 0 .../comments_mercure_controller.js | 0 .../discover-scroll_controller.js | 0 .../reading_list_dropdown_controller.js | 2 +- .../{ => media}/image-loader_controller.js | 0 .../{ => media}/media-loader_controller.js | 0 .../{ => nostr}/amber_connect_controller.js | 5 +- .../controllers/nostr/manual_nip46_session.js | 0 assets/controllers/{ => nostr}/nostr-utils.ts | 0 .../{ => nostr}/nostr_comment_controller.js | 0 .../nostr_index_sign_controller.js | 0 .../{ => nostr}/nostr_preview_controller.js | 0 .../{ => nostr}/nostr_publish_controller.js | 0 .../nostr_single_sign_controller.js | 48 ++++- assets/controllers/nostr/signer_manager.js | 127 +++++++++++++ .../image_upload_controller.js | 0 .../nzine_magazine_publish_controller.js | 0 .../{ => publishing}/quill_controller.js | 0 .../tabular_publish_controller.js | 0 .../workflow_progress_controller.js | 0 .../search_broadcast_controller.js | 0 .../search_visibility_controller.js | 0 .../{ => search}/topic-filter_controller.js | 0 assets/controllers/signer_manager.js | 85 --------- .../{ => ui}/form-collection_controller.js | 0 .../{ => ui}/gallery_controller.js | 0 .../{ => ui}/highlights_toggle_controller.js | 0 .../controllers/{ => ui}/menu_controller.js | 0 .../{ => ui}/progress_bar_controller.js | 0 .../{ => ui}/sidebar_toggle_controller.js | 0 .../copy_to_clipboard_controller.js | 0 .../install-prompt_controller.js | 0 .../{ => utility}/login_controller.js | 0 .../service-worker_controller.js | 0 .../share_dropdown_controller.js | 0 config/services.yaml | 20 ++ .../ArticleManagementController.php | 1 + src/Controller/ArticleController.php | 3 - src/Controller/AuthorController.php | 13 +- src/Controller/DefaultController.php | 14 +- src/Controller/ForumController.php | 1 + src/Repository/ArticleRepository.php | 172 ++++++++++++++++++ src/Service/NostrClient.php | 19 +- src/Service/Search/ArticleSearchFactory.php | 28 +++ src/Service/Search/ArticleSearchInterface.php | 55 ++++++ src/Service/Search/DatabaseArticleSearch.php | 76 ++++++++ .../Search/ElasticsearchArticleSearch.php | 160 ++++++++++++++++ .../Components/Organisms/FeaturedList.php | 5 +- src/Twig/Components/SearchComponent.php | 43 +---- templates/admin/analytics.html.twig | 6 +- templates/admin/articles.html.twig | 8 +- templates/admin/cache.html.twig | 16 +- templates/base.html.twig | 8 +- templates/components/Header.html.twig | 10 +- .../Molecules/NostrPreview.html.twig | 12 +- .../Organisms/CommentForm.html.twig | 16 +- .../components/ReadingListDropdown.html.twig | 20 +- .../ReadingListWorkflowStatus.html.twig | 18 +- .../components/SearchComponent.html.twig | 4 +- templates/components/UserMenu.html.twig | 6 +- templates/feedback/form.html.twig | 10 +- templates/layout.html.twig | 4 +- templates/login/amber.html.twig | 6 +- templates/login/index.html.twig | 4 +- templates/nzine/list.html.twig | 8 +- templates/pages/article.html.twig | 16 +- templates/pages/editor.html.twig | 46 ++--- templates/partial/_gallery.html.twig | 12 +- templates/profile/author-media.html.twig | 8 +- templates/profile/author.html.twig | 2 +- templates/reading_list/index.html.twig | 8 +- templates/tabular_data/preview.html.twig | 12 +- 76 files changed, 844 insertions(+), 295 deletions(-) rename assets/controllers/{ => analytics}/unique_visitors_per_day_chart_controller.js (100%) rename assets/controllers/{ => analytics}/visits_per_day_chart_controller.js (100%) rename assets/controllers/{ => content}/advanced_metadata_controller.js (100%) rename assets/controllers/{ => content}/author_articles_controller.js (100%) rename assets/controllers/{ => content}/comments_mercure_controller.js (100%) rename assets/controllers/{ => content}/discover-scroll_controller.js (100%) rename assets/controllers/{ => content}/reading_list_dropdown_controller.js (99%) rename assets/controllers/{ => media}/image-loader_controller.js (100%) rename assets/controllers/{ => media}/media-loader_controller.js (100%) rename assets/controllers/{ => nostr}/amber_connect_controller.js (91%) create mode 100644 assets/controllers/nostr/manual_nip46_session.js rename assets/controllers/{ => nostr}/nostr-utils.ts (100%) rename assets/controllers/{ => nostr}/nostr_comment_controller.js (100%) rename assets/controllers/{ => nostr}/nostr_index_sign_controller.js (100%) rename assets/controllers/{ => nostr}/nostr_preview_controller.js (100%) rename assets/controllers/{ => nostr}/nostr_publish_controller.js (100%) rename assets/controllers/{ => nostr}/nostr_single_sign_controller.js (70%) create mode 100644 assets/controllers/nostr/signer_manager.js rename assets/controllers/{ => publishing}/image_upload_controller.js (100%) rename assets/controllers/{ => publishing}/nzine_magazine_publish_controller.js (100%) rename assets/controllers/{ => publishing}/quill_controller.js (100%) rename assets/controllers/{ => publishing}/tabular_publish_controller.js (100%) rename assets/controllers/{ => publishing}/workflow_progress_controller.js (100%) rename assets/controllers/{ => search}/search_broadcast_controller.js (100%) rename assets/controllers/{ => search}/search_visibility_controller.js (100%) rename assets/controllers/{ => search}/topic-filter_controller.js (100%) delete mode 100644 assets/controllers/signer_manager.js rename assets/controllers/{ => ui}/form-collection_controller.js (100%) rename assets/controllers/{ => ui}/gallery_controller.js (100%) rename assets/controllers/{ => ui}/highlights_toggle_controller.js (100%) rename assets/controllers/{ => ui}/menu_controller.js (100%) rename assets/controllers/{ => ui}/progress_bar_controller.js (100%) rename assets/controllers/{ => ui}/sidebar_toggle_controller.js (100%) rename assets/controllers/{ => utility}/copy_to_clipboard_controller.js (100%) rename assets/controllers/{ => utility}/install-prompt_controller.js (100%) rename assets/controllers/{ => utility}/login_controller.js (100%) rename assets/controllers/{ => utility}/service-worker_controller.js (100%) rename assets/controllers/{ => utility}/share_dropdown_controller.js (100%) create mode 100644 src/Service/Search/ArticleSearchFactory.php create mode 100644 src/Service/Search/ArticleSearchInterface.php create mode 100644 src/Service/Search/DatabaseArticleSearch.php create mode 100644 src/Service/Search/ElasticsearchArticleSearch.php diff --git a/.env.dist b/.env.dist index e0a99fc..1de8b24 100644 --- a/.env.dist +++ b/.env.dist @@ -43,6 +43,8 @@ MERCURE_PUBLIC_URL="https://${SERVER_NAME}/.well-known/mercure" MERCURE_JWT_SECRET="!NotSoSecretMercureHubJWTSecretKey!" ###< symfony/mercure-bundle ### ###> elastic ### +# Set to 'true' to enable Elasticsearch, 'false' to use database queries +ELASTICSEARCH_ENABLED=false ELASTICSEARCH_HOST=localhost ELASTICSEARCH_PORT=9200 ELASTICSEARCH_USERNAME=elastic diff --git a/assets/controllers/unique_visitors_per_day_chart_controller.js b/assets/controllers/analytics/unique_visitors_per_day_chart_controller.js similarity index 100% rename from assets/controllers/unique_visitors_per_day_chart_controller.js rename to assets/controllers/analytics/unique_visitors_per_day_chart_controller.js diff --git a/assets/controllers/visits_per_day_chart_controller.js b/assets/controllers/analytics/visits_per_day_chart_controller.js similarity index 100% rename from assets/controllers/visits_per_day_chart_controller.js rename to assets/controllers/analytics/visits_per_day_chart_controller.js diff --git a/assets/controllers/advanced_metadata_controller.js b/assets/controllers/content/advanced_metadata_controller.js similarity index 100% rename from assets/controllers/advanced_metadata_controller.js rename to assets/controllers/content/advanced_metadata_controller.js diff --git a/assets/controllers/author_articles_controller.js b/assets/controllers/content/author_articles_controller.js similarity index 100% rename from assets/controllers/author_articles_controller.js rename to assets/controllers/content/author_articles_controller.js diff --git a/assets/controllers/comments_mercure_controller.js b/assets/controllers/content/comments_mercure_controller.js similarity index 100% rename from assets/controllers/comments_mercure_controller.js rename to assets/controllers/content/comments_mercure_controller.js diff --git a/assets/controllers/discover-scroll_controller.js b/assets/controllers/content/discover-scroll_controller.js similarity index 100% rename from assets/controllers/discover-scroll_controller.js rename to assets/controllers/content/discover-scroll_controller.js diff --git a/assets/controllers/reading_list_dropdown_controller.js b/assets/controllers/content/reading_list_dropdown_controller.js similarity index 99% rename from assets/controllers/reading_list_dropdown_controller.js rename to assets/controllers/content/reading_list_dropdown_controller.js index 687f81e..c4f79c5 100644 --- a/assets/controllers/reading_list_dropdown_controller.js +++ b/assets/controllers/content/reading_list_dropdown_controller.js @@ -1,5 +1,5 @@ import { Controller } from '@hotwired/stimulus'; -import { getSigner } from './signer_manager.js'; +import { getSigner } from '../nostr/signer_manager.js'; export default class extends Controller { static targets = ['dropdown', 'status', 'menu']; diff --git a/assets/controllers/image-loader_controller.js b/assets/controllers/media/image-loader_controller.js similarity index 100% rename from assets/controllers/image-loader_controller.js rename to assets/controllers/media/image-loader_controller.js diff --git a/assets/controllers/media-loader_controller.js b/assets/controllers/media/media-loader_controller.js similarity index 100% rename from assets/controllers/media-loader_controller.js rename to assets/controllers/media/media-loader_controller.js diff --git a/assets/controllers/amber_connect_controller.js b/assets/controllers/nostr/amber_connect_controller.js similarity index 91% rename from assets/controllers/amber_connect_controller.js rename to assets/controllers/nostr/amber_connect_controller.js index 55f6a03..cf5f936 100644 --- a/assets/controllers/amber_connect_controller.js +++ b/assets/controllers/nostr/amber_connect_controller.js @@ -73,7 +73,9 @@ export default class extends Controller { async _createSigner() { this._pool = new SimplePool(); this._setStatus('Waiting for remote signer…'); - // fromURI resolves only after remote bunker connects & authorizes (handshake done inside nostr-tools) + // INITIAL CONNECTION: fromURI() waits for Amber to accept connection (NIP-46 connect handshake) + // After this succeeds, the session (privkey, uri, relays, secret) is persisted to localStorage + // Subsequent calls to BunkerSigner.fromURI() with same credentials should work without waiting for approval this._signer = await BunkerSigner.fromURI(this._localSecretKey, this._uri, { pool: this._pool }); } @@ -101,6 +103,7 @@ export default class extends Controller { }); if (resp.ok) { // Persist remote signer session for reuse after reload + // Note: Reconnection with Amber may require user approval each time setRemoteSignerSession({ privkey: this._localSecretKey, uri: this._uri, diff --git a/assets/controllers/nostr/manual_nip46_session.js b/assets/controllers/nostr/manual_nip46_session.js new file mode 100644 index 0000000..e69de29 diff --git a/assets/controllers/nostr-utils.ts b/assets/controllers/nostr/nostr-utils.ts similarity index 100% rename from assets/controllers/nostr-utils.ts rename to assets/controllers/nostr/nostr-utils.ts diff --git a/assets/controllers/nostr_comment_controller.js b/assets/controllers/nostr/nostr_comment_controller.js similarity index 100% rename from assets/controllers/nostr_comment_controller.js rename to assets/controllers/nostr/nostr_comment_controller.js diff --git a/assets/controllers/nostr_index_sign_controller.js b/assets/controllers/nostr/nostr_index_sign_controller.js similarity index 100% rename from assets/controllers/nostr_index_sign_controller.js rename to assets/controllers/nostr/nostr_index_sign_controller.js diff --git a/assets/controllers/nostr_preview_controller.js b/assets/controllers/nostr/nostr_preview_controller.js similarity index 100% rename from assets/controllers/nostr_preview_controller.js rename to assets/controllers/nostr/nostr_preview_controller.js diff --git a/assets/controllers/nostr_publish_controller.js b/assets/controllers/nostr/nostr_publish_controller.js similarity index 100% rename from assets/controllers/nostr_publish_controller.js rename to assets/controllers/nostr/nostr_publish_controller.js diff --git a/assets/controllers/nostr_single_sign_controller.js b/assets/controllers/nostr/nostr_single_sign_controller.js similarity index 70% rename from assets/controllers/nostr_single_sign_controller.js rename to assets/controllers/nostr/nostr_single_sign_controller.js index f8fca57..e9ab61a 100644 --- a/assets/controllers/nostr_single_sign_controller.js +++ b/assets/controllers/nostr/nostr_single_sign_controller.js @@ -1,5 +1,5 @@ import { Controller } from '@hotwired/stimulus'; -import { getSigner } from './signer_manager.js'; +import { getSigner, getRemoteSignerSession } from './signer_manager.js'; export default class extends Controller { static targets = ['status', 'publishButton', 'computedPreview']; @@ -19,10 +19,21 @@ export default class extends Controller { try { const skeleton = JSON.parse(this.eventValue || '{}'); let pubkey = ''; - try { - const signer = await getSigner(); - pubkey = await signer.getPublicKey(); - } catch (_) {} + + // Only try to get pubkey if extension is available + // Don't attempt remote signer connection during preview (it would timeout) + if (window.nostr && typeof window.nostr.getPublicKey === 'function') { + try { + pubkey = await window.nostr.getPublicKey(); + } catch (_) {} + } else { + // If remote signer session exists, show placeholder + const session = getRemoteSignerSession(); + if (session) { + pubkey = ''; + } + } + const preview = JSON.parse(JSON.stringify(skeleton)); preview.pubkey = pubkey; // Update content from textarea if present @@ -40,16 +51,33 @@ export default class extends Controller { event.preventDefault(); console.log('[nostr_single_sign] Sign and publish triggered'); + const session = getRemoteSignerSession(); + console.log('[nostr_single_sign] Remote signer session:', session); + let signer; try { this.showStatus('Connecting to signer...'); + console.log('[nostr_single_sign] Calling getSigner()...'); + + // getSigner() handles caching and reuses existing connection if available signer = await getSigner(); console.log('[nostr_single_sign] Signer obtained successfully'); + + // Verify connection works + const testPubkey = await signer.getPublicKey(); + console.log('[nostr_single_sign] Signer verified, pubkey:', testPubkey); + } catch (e) { console.error('[nostr_single_sign] Failed to get signer:', e); - this.showError(`No Nostr signer available: ${e.message}. Please connect Amber or install a Nostr signer extension.`); + const session = getRemoteSignerSession(); + if (session && e.message.includes('unavailable')) { + this.showError('Amber connection lost. Please use a Nostr browser extension (like nos2x or Alby) to sign, or reconnect Amber from the login page.'); + } else { + this.showError(`No Nostr signer available: ${e.message}. Please connect Amber or install a Nostr signer extension.`); + } return; } + if (!this.publishUrlValue || !this.csrfTokenValue) { console.error('[nostr_single_sign] Missing config', { publishUrl: this.publishUrlValue, csrf: !!this.csrfTokenValue }); this.showError('Missing config'); @@ -58,12 +86,12 @@ export default class extends Controller { this.publishButtonTarget.disabled = true; try { - this.showStatus('Getting public key...'); + this.showStatus('Preparing event...'); const pubkey = await signer.getPublicKey(); console.log('[nostr_single_sign] Public key obtained:', pubkey); const skeleton = JSON.parse(this.eventValue || '{}'); - // Update content from textarea before signing + // Update content from textarea if present const textarea = this.element.querySelector('textarea'); if (textarea) { skeleton.content = textarea.value; @@ -72,10 +100,10 @@ export default class extends Controller { this.ensureContent(skeleton); skeleton.pubkey = pubkey; - this.showStatus('Signing event…'); + this.showStatus('Sending event to signer for signature...'); console.log('[nostr_single_sign] Signing event:', skeleton); const signed = await signer.signEvent(skeleton); - console.log('[nostr_single_sign] Event signed successfully'); + console.log('[nostr_single_sign] Event signed successfully:', signed); this.showStatus('Publishing…'); await this.publishSigned(signed); diff --git a/assets/controllers/nostr/signer_manager.js b/assets/controllers/nostr/signer_manager.js new file mode 100644 index 0000000..bfe51aa --- /dev/null +++ b/assets/controllers/nostr/signer_manager.js @@ -0,0 +1,127 @@ +// Shared signer manager for Nostr signers (remote and extension) +import { SimplePool } from 'nostr-tools'; +import { BunkerSigner } from 'nostr-tools/nip46'; + +const REMOTE_SIGNER_KEY = 'amber_remote_signer'; + +let remoteSigner = null; +let remoteSignerPromise = null; +let remoteSignerPool = null; + +export async function getSigner(_retrying = 0) { + // If remote signer session is active, use it + const session = getRemoteSignerSession(); + console.log('[signer_manager] getSigner called, session exists:', !!session); + if (session) { + if (remoteSigner) { + console.log('[signer_manager] Returning cached remote signer'); + return remoteSigner; + } + if (remoteSignerPromise) { + console.log('[signer_manager] Returning existing connection promise'); + return remoteSignerPromise; + } + + console.log('[signer_manager] Recreating BunkerSigner from stored session (no connect needed)...'); + // According to nostr-tools docs: BunkerSigner.fromURI() returns immediately + // After initial connect() during login, we can reuse the signer without reconnecting + remoteSignerPromise = createRemoteSignerFromSession(session) + .then(signer => { + remoteSigner = signer; + console.log('[signer_manager] Remote signer successfully recreated and cached'); + return signer; + }) + .catch((error) => { + console.error('[signer_manager] Remote signer creation failed:', error); + remoteSignerPromise = null; + // Clear stale session + console.log('[signer_manager] Clearing stale remote signer session'); + clearRemoteSignerSession(); + // Fallback to browser extension if available + if (window.nostr && typeof window.nostr.signEvent === 'function') { + console.log('[signer_manager] Falling back to browser extension'); + return window.nostr; + } + throw new Error('Remote signer unavailable. Please reconnect Amber or use a browser extension.'); + }); + return remoteSignerPromise; + } + // Fallback to browser extension ONLY if no remote session + console.log('[signer_manager] No remote session, checking for browser extension'); + if (window.nostr && typeof window.nostr.signEvent === 'function') { + console.log('[signer_manager] Using browser extension'); + return window.nostr; + } + throw new Error('No signer available'); +} + +export function setRemoteSignerSession(session) { + localStorage.setItem(REMOTE_SIGNER_KEY, JSON.stringify(session)); +} + +export function clearRemoteSignerSession() { + localStorage.removeItem(REMOTE_SIGNER_KEY); + remoteSigner = null; + remoteSignerPromise = null; + if (remoteSignerPool) { + try { remoteSignerPool.close?.([]); } catch (_) {} + remoteSignerPool = null; + } +} + +export function getRemoteSignerSession() { + const raw = localStorage.getItem(REMOTE_SIGNER_KEY); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +// Create BunkerSigner from stored session +// According to nostr-tools: fromURI() returns immediately, no waiting for handshake +// The connect() was already done during initial login, so we can use the signer right away +async function createRemoteSignerFromSession(session) { + console.log('[signer_manager] ===== Recreating BunkerSigner from session ====='); + console.log('[signer_manager] Session URI:', session.uri); + console.log('[signer_manager] Session relays:', session.relays); + + // Reuse existing pool if available, otherwise create new one + if (!remoteSignerPool) { + console.log('[signer_manager] Creating new SimplePool for relays:', session.relays); + remoteSignerPool = new SimplePool(); + } else { + console.log('[signer_manager] Reusing existing SimplePool'); + } + + try { + console.log('[signer_manager] Creating BunkerSigner from stored session...'); + // fromURI returns a Promise - await it to get the signer + const signer = await BunkerSigner.fromURI(session.privkey, session.uri, { pool: remoteSignerPool }); + console.log('[signer_manager] ✅ BunkerSigner created! Testing with getPublicKey...'); + + // Test the signer to make sure it works + try { + const pubkey = await signer.getPublicKey(); + console.log('[signer_manager] ✅ Signer verified! Pubkey:', pubkey); + return signer; + } catch (testError) { + console.error('[signer_manager] ❌ Signer test failed:', testError); + throw new Error('Signer created but failed verification: ' + testError.message); + } + } catch (error) { + console.error('[signer_manager] ❌ Failed to create signer:', error); + // Clean up on error + if (remoteSignerPool) { + try { + console.log('[signer_manager] Closing pool after error'); + remoteSignerPool.close?.([]); + } catch (_) {} + remoteSignerPool = null; + } + remoteSigner = null; + remoteSignerPromise = null; + throw error; + } +} diff --git a/assets/controllers/image_upload_controller.js b/assets/controllers/publishing/image_upload_controller.js similarity index 100% rename from assets/controllers/image_upload_controller.js rename to assets/controllers/publishing/image_upload_controller.js diff --git a/assets/controllers/nzine_magazine_publish_controller.js b/assets/controllers/publishing/nzine_magazine_publish_controller.js similarity index 100% rename from assets/controllers/nzine_magazine_publish_controller.js rename to assets/controllers/publishing/nzine_magazine_publish_controller.js diff --git a/assets/controllers/quill_controller.js b/assets/controllers/publishing/quill_controller.js similarity index 100% rename from assets/controllers/quill_controller.js rename to assets/controllers/publishing/quill_controller.js diff --git a/assets/controllers/tabular_publish_controller.js b/assets/controllers/publishing/tabular_publish_controller.js similarity index 100% rename from assets/controllers/tabular_publish_controller.js rename to assets/controllers/publishing/tabular_publish_controller.js diff --git a/assets/controllers/workflow_progress_controller.js b/assets/controllers/publishing/workflow_progress_controller.js similarity index 100% rename from assets/controllers/workflow_progress_controller.js rename to assets/controllers/publishing/workflow_progress_controller.js diff --git a/assets/controllers/search_broadcast_controller.js b/assets/controllers/search/search_broadcast_controller.js similarity index 100% rename from assets/controllers/search_broadcast_controller.js rename to assets/controllers/search/search_broadcast_controller.js diff --git a/assets/controllers/search_visibility_controller.js b/assets/controllers/search/search_visibility_controller.js similarity index 100% rename from assets/controllers/search_visibility_controller.js rename to assets/controllers/search/search_visibility_controller.js diff --git a/assets/controllers/topic-filter_controller.js b/assets/controllers/search/topic-filter_controller.js similarity index 100% rename from assets/controllers/topic-filter_controller.js rename to assets/controllers/search/topic-filter_controller.js diff --git a/assets/controllers/signer_manager.js b/assets/controllers/signer_manager.js deleted file mode 100644 index f3d4552..0000000 --- a/assets/controllers/signer_manager.js +++ /dev/null @@ -1,85 +0,0 @@ -// Shared signer manager for Nostr signers (remote and extension) -import { SimplePool } from 'nostr-tools'; -import { BunkerSigner } from 'nostr-tools/nip46'; - -const REMOTE_SIGNER_KEY = 'amber_remote_signer'; - -let remoteSigner = null; -let remoteSignerPromise = null; -let remoteSignerPool = null; - -export async function getSigner() { - // If remote signer session is active, use it - const session = getRemoteSignerSession(); - if (session) { - if (remoteSigner) return remoteSigner; - if (remoteSignerPromise) return remoteSignerPromise; - - remoteSignerPromise = createRemoteSigner(session) - .then(signer => { - remoteSigner = signer; - return signer; - }) - .catch(error => { - // Reset promise on failure so next call can retry - remoteSignerPromise = null; - throw error; - }); - return remoteSignerPromise; - } - // Fallback to browser extension - if (window.nostr && typeof window.nostr.signEvent === 'function') { - return window.nostr; - } - throw new Error('No signer available'); -} - -export function setRemoteSignerSession(session) { - localStorage.setItem(REMOTE_SIGNER_KEY, JSON.stringify(session)); -} - -export function clearRemoteSignerSession() { - localStorage.removeItem(REMOTE_SIGNER_KEY); - remoteSigner = null; - remoteSignerPromise = null; - if (remoteSignerPool) { - try { remoteSignerPool.close?.([]); } catch (_) {} - remoteSignerPool = null; - } -} - -export function getRemoteSignerSession() { - const raw = localStorage.getItem(REMOTE_SIGNER_KEY); - if (!raw) return null; - try { - return JSON.parse(raw); - } catch { - return null; - } -} - -async function createRemoteSigner(session) { - remoteSignerPool = new SimplePool(); - - // Add timeout to prevent hanging indefinitely - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('Remote signer connection timeout')), 10000); - }); - - try { - return await Promise.race([ - BunkerSigner.fromURI(session.privkey, session.uri, { pool: remoteSignerPool }), - timeoutPromise - ]); - } catch (error) { - // Clean up on error - if (remoteSignerPool) { - try { remoteSignerPool.close?.([]); } catch (_) {} - remoteSignerPool = null; - } - remoteSigner = null; - remoteSignerPromise = null; - throw error; - } -} - diff --git a/assets/controllers/form-collection_controller.js b/assets/controllers/ui/form-collection_controller.js similarity index 100% rename from assets/controllers/form-collection_controller.js rename to assets/controllers/ui/form-collection_controller.js diff --git a/assets/controllers/gallery_controller.js b/assets/controllers/ui/gallery_controller.js similarity index 100% rename from assets/controllers/gallery_controller.js rename to assets/controllers/ui/gallery_controller.js diff --git a/assets/controllers/highlights_toggle_controller.js b/assets/controllers/ui/highlights_toggle_controller.js similarity index 100% rename from assets/controllers/highlights_toggle_controller.js rename to assets/controllers/ui/highlights_toggle_controller.js diff --git a/assets/controllers/menu_controller.js b/assets/controllers/ui/menu_controller.js similarity index 100% rename from assets/controllers/menu_controller.js rename to assets/controllers/ui/menu_controller.js diff --git a/assets/controllers/progress_bar_controller.js b/assets/controllers/ui/progress_bar_controller.js similarity index 100% rename from assets/controllers/progress_bar_controller.js rename to assets/controllers/ui/progress_bar_controller.js diff --git a/assets/controllers/sidebar_toggle_controller.js b/assets/controllers/ui/sidebar_toggle_controller.js similarity index 100% rename from assets/controllers/sidebar_toggle_controller.js rename to assets/controllers/ui/sidebar_toggle_controller.js diff --git a/assets/controllers/copy_to_clipboard_controller.js b/assets/controllers/utility/copy_to_clipboard_controller.js similarity index 100% rename from assets/controllers/copy_to_clipboard_controller.js rename to assets/controllers/utility/copy_to_clipboard_controller.js diff --git a/assets/controllers/install-prompt_controller.js b/assets/controllers/utility/install-prompt_controller.js similarity index 100% rename from assets/controllers/install-prompt_controller.js rename to assets/controllers/utility/install-prompt_controller.js diff --git a/assets/controllers/login_controller.js b/assets/controllers/utility/login_controller.js similarity index 100% rename from assets/controllers/login_controller.js rename to assets/controllers/utility/login_controller.js diff --git a/assets/controllers/service-worker_controller.js b/assets/controllers/utility/service-worker_controller.js similarity index 100% rename from assets/controllers/service-worker_controller.js rename to assets/controllers/utility/service-worker_controller.js diff --git a/assets/controllers/share_dropdown_controller.js b/assets/controllers/utility/share_dropdown_controller.js similarity index 100% rename from assets/controllers/share_dropdown_controller.js rename to assets/controllers/utility/share_dropdown_controller.js diff --git a/config/services.yaml b/config/services.yaml index 57ecffa..6c771e3 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -7,6 +7,7 @@ parameters: encryption_key: '%env(APP_ENCRYPTION_KEY)%' mercure_public_hub_url: '%env(MERCURE_PUBLIC_URL)%' nostr_default_relay: '%env(default::NOSTR_DEFAULT_RELAY)%' + elasticsearch_enabled: '%env(ELASTICSEARCH_ENABLED)%' services: # default configuration for services in *this* file @@ -79,3 +80,22 @@ services: App\Util\NostrPhp\TweakedRequest: ~ swentel\nostr\Request\Request: '@App\Util\NostrPhp\TweakedRequest' + + # Search services - Elasticsearch implementation + App\Service\Search\ElasticsearchArticleSearch: + arguments: + $finder: '@fos_elastica.finder.articles' + $enabled: '%elasticsearch_enabled%' + + # Search services - Database implementation + App\Service\Search\DatabaseArticleSearch: ~ + + # Search service factory + App\Service\Search\ArticleSearchFactory: + arguments: + $elasticsearchEnabled: '%elasticsearch_enabled%' + + # Main search service - uses Elasticsearch if enabled, otherwise database + App\Service\Search\ArticleSearchInterface: + factory: ['@App\Service\Search\ArticleSearchFactory', 'create'] + diff --git a/src/Controller/Administration/ArticleManagementController.php b/src/Controller/Administration/ArticleManagementController.php index 4feb260..394e1c3 100644 --- a/src/Controller/Administration/ArticleManagementController.php +++ b/src/Controller/Administration/ArticleManagementController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Controller\Administration; use App\Service\RedisCacheService; +use App\Service\Search\ArticleSearchInterface; use Elastica\Query; use FOS\ElasticaBundle\Finder\PaginatedFinderInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 5dceaf6..7fd7168 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -13,12 +13,9 @@ use App\Service\Nostr\NostrEventParser; use App\Service\RedisCacheService; use App\Util\CommonMark\Converter; use Doctrine\ORM\EntityManagerInterface; -use FOS\ElasticaBundle\Finder\PaginatedFinderInterface; -use League\CommonMark\Exception\CommonMarkException; use nostriphant\NIP19\Bech32; use nostriphant\NIP19\Data\NAddr; use Psr\Cache\CacheItemPoolInterface; -use Psr\Cache\InvalidArgumentException; use Psr\Log\LoggerInterface; use swentel\nostr\Event\Event; use swentel\nostr\Key\Key; diff --git a/src/Controller/AuthorController.php b/src/Controller/AuthorController.php index 8f8aee7..25e276d 100644 --- a/src/Controller/AuthorController.php +++ b/src/Controller/AuthorController.php @@ -10,6 +10,8 @@ use App\Enum\KindsEnum; use App\Message\FetchAuthorArticlesMessage; use App\Service\NostrClient; use App\Service\RedisCacheService; +use App\Service\RedisViewStore; +use App\ReadModel\RedisView\RedisViewFactory; use App\Util\NostrKeyUtil; use Doctrine\ORM\EntityManagerInterface; use Elastica\Query\BoolQuery; @@ -254,15 +256,8 @@ class AuthorController extends AbstractController } $fromCache = true; } else { - // Cache miss - query from Elasticsearch - $boolQuery = new BoolQuery(); - $boolQuery->addMust(new Term(['pubkey' => $pubkey])); - $query = new \Elastica\Query($boolQuery); - $query->setSort(['createdAt' => ['order' => 'desc']]); - $collapse = new Collapse(); - $collapse->setFieldname('slug'); - $query->setCollapse($collapse); - $articles = $finder->find($query); + // Cache miss - query using search service + $articles = $articleSearch->findByPubkey($pubkey, 100, 0); // Build and cache Redis views for next time if (!empty($articles)) { diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php index 82609ae..1b8ddcd 100644 --- a/src/Controller/DefaultController.php +++ b/src/Controller/DefaultController.php @@ -24,8 +24,6 @@ use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\InvalidArgumentException; use swentel\nostr\Key\Key; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Component\DependencyInjection\Tests\Compiler\K; -use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -335,21 +333,15 @@ class DefaultController extends AbstractController } if (!empty($coordinates)) { - // Extract slugs for elasticsearch query + // Extract slugs for query $slugs = array_map(function($coordinate) { $parts = explode(':', $coordinate, 3); return end($parts); }, $coordinates); $slugs = array_filter($slugs); // Remove empty values - // First filter to only include articles with the slugs we want - $termsQuery = new Terms('slug', array_values($slugs)); - - // Create a Query object to set the size parameter - $query = new Query($termsQuery); - $query->setSize(200); // Set size to exceed the number of articles we expect - - $articles = $finder->find($query); + // Use the search service to find articles by slugs + $articles = $articleSearch->findBySlugs(array_values($slugs), 200); // Create a map of slug => item to remove duplicates $slugMap = []; diff --git a/src/Controller/ForumController.php b/src/Controller/ForumController.php index 2807348..23e6270 100644 --- a/src/Controller/ForumController.php +++ b/src/Controller/ForumController.php @@ -6,6 +6,7 @@ namespace App\Controller; use App\Entity\User; use App\Service\NostrClient; +use App\Service\Search\ArticleSearchInterface; use App\Util\ForumTopics; use App\Util\NostrKeyUtil; use Elastica\Aggregation\Filters as FiltersAgg; diff --git a/src/Repository/ArticleRepository.php b/src/Repository/ArticleRepository.php index 9b619b4..d84a48b 100644 --- a/src/Repository/ArticleRepository.php +++ b/src/Repository/ArticleRepository.php @@ -15,4 +15,176 @@ class ArticleRepository extends ServiceEntityRepository parent::__construct($registry, Article::class); } + /** + * Search articles by query string using database full-text search + * + * @param string $query + * @param int $limit + * @param int $offset + * @return Article[] + */ + public function searchByQuery(string $query, int $limit = 12, int $offset = 0): array + { + $qb = $this->createQueryBuilder('a'); + + // Use LIKE for basic text search (works with PostgreSQL and MySQL) + // For better performance, consider using PostgreSQL full-text search or MySQL FULLTEXT indexes + $searchTerm = '%' . $query . '%'; + + $qb->where( + $qb->expr()->orX( + $qb->expr()->like('a.title', ':search'), + $qb->expr()->like('a.content', ':search'), + $qb->expr()->like('a.summary', ':search') + ) + ) + ->andWhere($qb->expr()->notLike('a.slug', ':slugPattern')) + ->setParameter('search', $searchTerm) + ->setParameter('slugPattern', '%/%') + ->orderBy('a.createdAt', 'DESC') + ->setFirstResult($offset) + ->setMaxResults($limit); + + return $qb->getQuery()->getResult(); + } + + /** + * Find articles by slugs + * + * @param array $slugs + * @param int $limit + * @return Article[] + */ + public function findBySlugs(array $slugs, int $limit = 200): array + { + if (empty($slugs)) { + return []; + } + + $qb = $this->createQueryBuilder('a'); + + $qb->where($qb->expr()->in('a.slug', ':slugs')) + ->setParameter('slugs', $slugs) + ->orderBy('a.createdAt', 'DESC') + ->setMaxResults($limit); + + $results = $qb->getQuery()->getResult(); + + // Group by slug and keep the most recent version of each + $slugMap = []; + foreach ($results as $article) { + $slug = $article->getSlug(); + if (!isset($slugMap[$slug]) || $article->getCreatedAt() > $slugMap[$slug]->getCreatedAt()) { + $slugMap[$slug] = $article; + } + } + + return array_values($slugMap); + } + + /** + * Find articles by topics + * + * @param array $topics + * @param int $limit + * @param int $offset + * @return Article[] + */ + public function findByTopics(array $topics, int $limit = 12, int $offset = 0): array + { + if (empty($topics)) { + return []; + } + + $qb = $this->createQueryBuilder('a'); + + // Use JSON contains for topics (PostgreSQL) + // Note: This assumes topics is stored as a JSON field + $orX = $qb->expr()->orX(); + foreach ($topics as $index => $topic) { + $orX->add("JSONB_CONTAINS(a.topics, :topic$index) = true"); + $qb->setParameter("topic$index", json_encode([$topic])); + } + + $qb->where($orX) + ->andWhere($qb->expr()->notLike('a.slug', ':slugPattern')) + ->setParameter('slugPattern', '%/%') + ->orderBy('a.createdAt', 'DESC') + ->setFirstResult($offset) + ->setMaxResults($limit); + + return $qb->getQuery()->getResult(); + } + + /** + * Find articles by pubkey (author) + * + * @param string $pubkey + * @param int $limit + * @param int $offset + * @return Article[] + */ + public function findByPubkey(string $pubkey, int $limit = 12, int $offset = 0): array + { + $qb = $this->createQueryBuilder('a'); + + $qb->where('a.pubkey = :pubkey') + ->andWhere($qb->expr()->notLike('a.slug', ':slugPattern')) + ->setParameter('pubkey', $pubkey) + ->setParameter('slugPattern', '%/%') + ->orderBy('a.createdAt', 'DESC') + ->setFirstResult($offset) + ->setMaxResults($limit); + + return $qb->getQuery()->getResult(); + } + + /** + * Search articles with PostgreSQL full-text search (ts_vector) + * This is an optimized version for PostgreSQL if you have full-text indexes set up + * + * @param string $query + * @param int $limit + * @param int $offset + * @return Article[] + */ + public function searchByQueryPostgreSQL(string $query, int $limit = 12, int $offset = 0): array + { + // This requires a tsvector column in your database + // You can uncomment and use this if you set up PostgreSQL full-text search + /* + $conn = $this->getEntityManager()->getConnection(); + + $sql = " + SELECT a.* FROM article a + WHERE + to_tsvector('english', COALESCE(a.title, '') || ' ' || COALESCE(a.content, '') || ' ' || COALESCE(a.summary, '')) + @@ plainto_tsquery('english', :query) + AND a.slug NOT LIKE '%/%' + ORDER BY + ts_rank(to_tsvector('english', COALESCE(a.title, '') || ' ' || COALESCE(a.content, '') || ' ' || COALESCE(a.summary, '')), plainto_tsquery('english', :query)) DESC, + a.created_at DESC + LIMIT :limit OFFSET :offset + "; + + $stmt = $conn->prepare($sql); + $result = $stmt->executeQuery([ + 'query' => $query, + 'limit' => $limit, + 'offset' => $offset + ]); + + $articles = []; + foreach ($result->fetchAllAssociative() as $row) { + $articles[] = $this->find($row['id']); + } + + return $articles; + */ + + // Fallback to simple search + return $this->searchByQuery($query, $limit, $offset); + } } + + diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index de75d59..2d5fa0a 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -234,6 +234,20 @@ class NostrClient */ public function getLongFormFromNaddr($slug, $relayList, $author, $kind): void { + $this->logger->info('Getting long form from ' . $slug, [ + 'relay_list' => $relayList, + 'author' => $author, + 'kind' => $kind + ]); + + $topAuthorRelays = $this->getTopReputableRelaysForAuthor($author); + $authorRelaySet = $this->createRelaySet($topAuthorRelays); + $this->logger->info('Author relays for long form fetch', [ + 'author' => $author, + 'from_event' => $this->getNpubRelays($author), + 'relays' => $topAuthorRelays + ]); + if (empty($relayList)) { $topAuthorRelays = $this->getTopReputableRelaysForAuthor($author); $authorRelaySet = $this->createRelaySet($topAuthorRelays); @@ -464,11 +478,14 @@ class NostrClient $this->logger->warning('Cache error', ['error' => $e->getMessage()]); } + $relays = new RelaySet(); + $relays->createFromUrls(self::REPUTABLE_RELAYS); + // Get relays $request = $this->createNostrRequest( kinds: [KindsEnum::RELAY_LIST->value], filters: ['authors' => [$npub]], - relaySet: $this->defaultRelaySet + relaySet: $relays ); $response = $this->processResponse($request->send(), function($received) { return $received; diff --git a/src/Service/Search/ArticleSearchFactory.php b/src/Service/Search/ArticleSearchFactory.php new file mode 100644 index 0000000..0a083cc --- /dev/null +++ b/src/Service/Search/ArticleSearchFactory.php @@ -0,0 +1,28 @@ +elasticsearchEnabled && $this->elasticsearchSearch->isAvailable()) { + $this->logger->info('Using Elasticsearch for article search'); + return $this->elasticsearchSearch; + } + + $this->logger->info('Using database for article search'); + return $this->databaseSearch; + } +} + diff --git a/src/Service/Search/ArticleSearchInterface.php b/src/Service/Search/ArticleSearchInterface.php new file mode 100644 index 0000000..e0d7150 --- /dev/null +++ b/src/Service/Search/ArticleSearchInterface.php @@ -0,0 +1,55 @@ +articleRepository->searchByQuery($query, $limit, $offset); + $this->logger->info('Database search results count: ' . count($results)); + return $results; + } catch (\Exception $e) { + $this->logger->error('Database search error: ' . $e->getMessage()); + return []; + } + } + + public function findBySlugs(array $slugs, int $limit = 200): array + { + if (empty($slugs)) { + return []; + } + + try { + return $this->articleRepository->findBySlugs($slugs, $limit); + } catch (\Exception $e) { + $this->logger->error('Database findBySlugs error: ' . $e->getMessage()); + return []; + } + } + + public function findByTopics(array $topics, int $limit = 12, int $offset = 0): array + { + if (empty($topics)) { + return []; + } + + try { + return $this->articleRepository->findByTopics($topics, $limit, $offset); + } catch (\Exception $e) { + $this->logger->error('Database findByTopics error: ' . $e->getMessage()); + return []; + } + } + + public function findByPubkey(string $pubkey, int $limit = 12, int $offset = 0): array + { + if (empty($pubkey)) { + return []; + } + + try { + return $this->articleRepository->findByPubkey($pubkey, $limit, $offset); + } catch (\Exception $e) { + $this->logger->error('Database findByPubkey error: ' . $e->getMessage()); + return []; + } + } + + public function isAvailable(): bool + { + return true; // Database is always available + } +} + diff --git a/src/Service/Search/ElasticsearchArticleSearch.php b/src/Service/Search/ElasticsearchArticleSearch.php new file mode 100644 index 0000000..c238cc7 --- /dev/null +++ b/src/Service/Search/ElasticsearchArticleSearch.php @@ -0,0 +1,160 @@ +enabled) { + return []; + } + + try { + $mainQuery = new Query(); + $boolQuery = new BoolQuery(); + + // Add phrase match for exact matches (high boost) + $phraseMatch = new Query\MatchPhrase(); + $phraseMatch->setField('search_combined', [ + 'query' => $query, + 'boost' => 10 + ]); + $boolQuery->addShould($phraseMatch); + + // Main multi-match query with optimized settings + $multiMatch = new MultiMatch(); + $multiMatch->setQuery($query); + $multiMatch->setFields(['search_combined']); + $multiMatch->setFuzziness('AUTO'); + $boolQuery->addMust($multiMatch); + + // Exclude specific patterns + $boolQuery->addMustNot(new Query\Wildcard('slug', '*/*')); + + $mainQuery->setQuery($boolQuery); + + // Simplified collapse - no inner_hits for better performance + $mainQuery->setParam('collapse', [ + 'field' => 'slug' + ]); + + // Lower minimum score for better recall + $mainQuery->setMinScore(0.25); + + // Sort by score first, then date + $mainQuery->setSort([ + '_score' => ['order' => 'desc'], + 'createdAt' => ['order' => 'desc'] + ]); + + $mainQuery->setFrom($offset); + $mainQuery->setSize($limit); + + // Execute the search + $results = $this->finder->find($mainQuery); + $this->logger->info('Elasticsearch search results count: ' . count($results)); + + return $results; + } catch (\Exception $e) { + $this->logger->error('Elasticsearch search error: ' . $e->getMessage()); + return []; + } + } + + public function findBySlugs(array $slugs, int $limit = 200): array + { + if (!$this->enabled || empty($slugs)) { + return []; + } + + try { + $termsQuery = new Terms('slug', array_values($slugs)); + $query = new Query($termsQuery); + $query->setSize($limit); + + return $this->finder->find($query); + } catch (\Exception $e) { + $this->logger->error('Elasticsearch findBySlugs error: ' . $e->getMessage()); + return []; + } + } + + public function findByTopics(array $topics, int $limit = 12, int $offset = 0): array + { + if (!$this->enabled || empty($topics)) { + return []; + } + + try { + $boolQuery = new BoolQuery(); + $termsQuery = new Terms('topics', $topics); + $boolQuery->addMust($termsQuery); + + // Exclude specific patterns + $boolQuery->addMustNot(new Query\Wildcard('slug', '*/*')); + + $mainQuery = new Query($boolQuery); + $mainQuery->setSort([ + 'createdAt' => ['order' => 'desc'] + ]); + $mainQuery->setFrom($offset); + $mainQuery->setSize($limit); + + return $this->finder->find($mainQuery); + } catch (\Exception $e) { + $this->logger->error('Elasticsearch findByTopics error: ' . $e->getMessage()); + return []; + } + } + + public function findByPubkey(string $pubkey, int $limit = 12, int $offset = 0): array + { + if (!$this->enabled || empty($pubkey)) { + return []; + } + + try { + $boolQuery = new BoolQuery(); + $termQuery = new Query\Term(); + $termQuery->setTerm('pubkey', $pubkey); + $boolQuery->addMust($termQuery); + + // Exclude specific patterns + $boolQuery->addMustNot(new Query\Wildcard('slug', '*/*')); + + $mainQuery = new Query($boolQuery); + $mainQuery->setSort([ + 'createdAt' => ['order' => 'desc'] + ]); + $mainQuery->setFrom($offset); + $mainQuery->setSize($limit); + + return $this->finder->find($mainQuery); + } catch (\Exception $e) { + $this->logger->error('Elasticsearch findByPubkey error: ' . $e->getMessage()); + return []; + } + } + + public function isAvailable(): bool + { + return $this->enabled; + } +} + diff --git a/src/Twig/Components/Organisms/FeaturedList.php b/src/Twig/Components/Organisms/FeaturedList.php index 27a753f..5fb71dc 100644 --- a/src/Twig/Components/Organisms/FeaturedList.php +++ b/src/Twig/Components/Organisms/FeaturedList.php @@ -67,10 +67,7 @@ final class FeaturedList } } - $termsQuery = new Terms('slug', array_values($slugs)); - $query = new Query($termsQuery); - $query->setSize(200); // Set size to exceed the number of articles we expect - $articles = $this->finder->find($query); + $articles = $this->articleSearch->findBySlugs(array_values($slugs), 200); // Create a map of slug => item $slugMap = []; diff --git a/src/Twig/Components/SearchComponent.php b/src/Twig/Components/SearchComponent.php index 4a81f7f..2871ec3 100644 --- a/src/Twig/Components/SearchComponent.php +++ b/src/Twig/Components/SearchComponent.php @@ -215,51 +215,12 @@ final class SearchComponent */ private function performOptimizedSearch(string $query, ?int $maxResults = null): array { - $mainQuery = new Query(); - $boolQuery = new BoolQuery(); - - // Add phrase match for exact matches (high boost) - $phraseMatch = new Query\MatchPhrase(); - $phraseMatch->setField('search_combined', [ - 'query' => $query, - 'boost' => 10 - ]); - $boolQuery->addShould($phraseMatch); - - // Main multi-match query with optimized settings - $multiMatch = new MultiMatch(); - $multiMatch->setQuery($query); - $multiMatch->setFields(['search_combined']); - $multiMatch->setFuzziness('AUTO'); - $boolQuery->addMust($multiMatch); - - // Exclude specific patterns - $boolQuery->addMustNot(new Query\Wildcard('slug', '*/*')); - - $mainQuery->setQuery($boolQuery); - - // Simplified collapse - no inner_hits for better performance - $mainQuery->setParam('collapse', [ - 'field' => 'slug' - ]); - - // Lower minimum score for better recall - $mainQuery->setMinScore(0.25); - - // Sort by score first, then date - $mainQuery->setSort([ - '_score' => ['order' => 'desc'], - 'createdAt' => ['order' => 'desc'] - ]); - // Pagination - use maxResults if provided, otherwise use default resultsPerPage $effectiveResultsPerPage = $maxResults ?? $this->resultsPerPage; $offset = ($this->page - 1) * $effectiveResultsPerPage; - $mainQuery->setFrom($offset); - $mainQuery->setSize($effectiveResultsPerPage); - // Execute the search - $results = $this->finder->find($mainQuery); + // Execute the search using the configured search service + $results = $this->articleSearch->search($query, $effectiveResultsPerPage, $offset); $this->logger->info('Search results count: ' . count($results)); return $results; diff --git a/templates/admin/analytics.html.twig b/templates/admin/analytics.html.twig index c92e33d..45a1047 100644 --- a/templates/admin/analytics.html.twig +++ b/templates/admin/analytics.html.twig @@ -106,9 +106,9 @@

Visits Per Day (Last 30 Days)

{% if dailyVisitCountsLast30Days|length > 0 %}