diff --git a/src/Controller/Admin/BlogAdminSearchController.php b/src/Controller/Admin/BlogAdminSearchController.php new file mode 100644 index 00000000..e2980604 --- /dev/null +++ b/src/Controller/Admin/BlogAdminSearchController.php @@ -0,0 +1,140 @@ + ['administration']])] + public function search(Request $request, Context $context): JsonResponse + { + $term = trim($request->request->getString('term', '')); + + if ($term === '') { + return new JsonResponse(['data' => []]); + } + + $limit = (int) $request->get('limit', 10); + + // Search blog entries + $entryResults = $this->searchHandler->search($term, $context, $limit); + + // Search blog categories + $categoryResults = $this->searchHandler->searchCategories($term, $context, $limit); + + // Encode results + $data = [ + 'blog_entries' => $this->encodeResults( + $entryResults->getEntities(), + $this->searchHandler->getEntryDefinition(), + '/api' + ), + 'blog_categories' => $this->encodeResults( + $categoryResults->getEntities(), + $this->searchHandler->getCategoryDefinition(), + '/api' + ), + ]; + + return new JsonResponse([ + 'data' => $data, + 'total' => [ + 'blog_entries' => $entryResults->getTotal(), + 'blog_categories' => $categoryResults->getTotal(), + ], + ]); + } + + /** + * Get a single blog entry by ID + */ + #[Route(path: '/api/blog-entry/{id}', name: 'api.blog_entry.detail', methods: ['GET'], defaults: ['_routeScope' => ['administration']])] + public function getBlogEntry(string $id, Context $context): JsonResponse + { + if (!Uuid::isValid($id)) { + return new JsonResponse(['error' => 'Invalid ID'], 400); + } + + $criteria = new Criteria([$id]); + + // Load translations + $criteria->addAssociations([ + 'translations', + 'blogAuthor', + 'blogCategories', + 'tags', + ]); + + $result = $this->searchHandler->getEntryDefinition() + ->getRepository() + ->search($criteria, $context); + + if ($result->getTotal() === 0) { + return new JsonResponse(['error' => 'Blog entry not found'], 404); + } + + $entity = $result->getEntities()->first(); + + return new JsonResponse([ + 'data' => $this->encodeResult($entity, $this->searchHandler->getEntryDefinition(), '/api'), + ]); + } + + /** + * Encode a collection of entities + * + * @param EntityCollection $entities + */ + private function encodeResults(EntityCollection $entities, object $definition, string $prefix): array + { + $criteria = new Criteria(); + $encoded = []; + + foreach ($entities->getElements() as $key => $entity) { + $encoded[$key] = $this->encodeResult($entity, $definition, $prefix); + } + + return $encoded; + } + + /** + * Encode a single entity + */ + private function encodeResult(object $entity, object $definition, string $prefix): array + { + return $this->entityEncoder->encode( + new Criteria(), + $definition, + $entity, + $prefix + ); + } +} \ No newline at end of file diff --git a/src/Framework/Search/Admin/BlogEntryAdminSearchHandler.php b/src/Framework/Search/Admin/BlogEntryAdminSearchHandler.php new file mode 100644 index 00000000..e43429fc --- /dev/null +++ b/src/Framework/Search/Admin/BlogEntryAdminSearchHandler.php @@ -0,0 +1,180 @@ +buildCriteria($term, $limit); + + return $this->blogEntryDefinition->getRepository() + ->search($criteria, $context); + } + + /** + * Search for blog categories matching the given term + */ + public function searchCategories(string $term, Context $context, int $limit = 10): EntitySearchResult + { + $criteria = $this->buildCategoryCriteria($term, $limit); + + return $this->blogCategoryDefinition->getRepository() + ->search($criteria, $context); + } + + /** + * Build search criteria for blog entries + */ + private function buildCriteria(string $term, int $limit): Criteria + { + $criteria = new Criteria(); + + // Parse the search term using Shopware's interpreter + $parsedTerms = $this->interpreter->interpret($term); + + if (!empty($parsedTerms)) { + // Build score query from parsed terms using ScoreQueryBuilder + $scoreFields = [ + 'title' => 500, + 'slug' => 400, + 'teaser' => 300, + 'metaTitle' => 200, + 'metaDescription' => 150, + ]; + + // Use MultiFieldQuery for multi-field search + $queries = []; + foreach ($parsedTerms as $parsedTerm) { + $term = $parsedTerm->getTerm(); + + foreach ($scoreFields as $field => $boost) { + $queries[] = new \Shopware\Core\Framework\DataAbstractionLayer\Search\Term\MultiFieldScoreFilter( + $term, + [$field => (float) $boost], + $parsedTerm->isAnd() + ); + } + } + + if (!empty($queries)) { + $criteria->addFilter(new OrFilter($queries)); + } + } else { + // Fallback: simple contains filter for each searchable field + $filters = [ + new \Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\ContainsFilter('title', $term), + new \Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\ContainsFilter('slug', $term), + new \Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\ContainsFilter('teaser', $term), + ]; + + $criteria->addFilter(new OrFilter($filters)); + } + + // Only show active entries + $criteria->addFilter( + new \Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter('active', true) + ); + + // Only show published entries + $criteria->addFilter( + new \Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\RangeFilter('publishedAt', [ + 'lte' => (new \DateTime())->format('Y-m-d H:i:s'), + ]) + ); + + $criteria->setLimit($limit); + + return $criteria; + } + + /** + * Build search criteria for blog categories + */ + private function buildCategoryCriteria(string $term, int $limit): Criteria + { + $criteria = new Criteria(); + + $parsedTerms = $this->interpreter->interpret($term); + + if (!empty($parsedTerms)) { + $scoreFields = [ + 'name' => 500, + 'description' => 300, + ]; + + $queries = []; + foreach ($parsedTerms as $parsedTerm) { + $termValue = $parsedTerm->getTerm(); + + foreach ($scoreFields as $field => $boost) { + $queries[] = new \Shopware\Core\Framework\DataAbstractionLayer\Search\Term\MultiFieldScoreFilter( + $termValue, + [$field => (float) $boost], + $parsedTerm->isAnd() + ); + } + } + + if (!empty($queries)) { + $criteria->addFilter(new OrFilter($queries)); + } + } else { + $filters = [ + new \Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\ContainsFilter('name', $term), + new \Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\ContainsFilter('description', $term), + ]; + + $criteria->addFilter(new OrFilter($filters)); + } + + $criteria->setLimit($limit); + + return $criteria; + } + + /** + * Get the entity definition for blog entries + */ + public function getEntryDefinition(): EntityDefinition + { + return $this->blogEntryDefinition; + } + + /** + * Get the entity definition for blog categories + */ + public function getCategoryDefinition(): EntityDefinition + { + return $this->blogCategoryDefinition; + } +} \ No newline at end of file diff --git a/src/Resources/app/administration/src/main.js b/src/Resources/app/administration/src/main.js index bb4d9ad9..0ff033b8 100755 --- a/src/Resources/app/administration/src/main.js +++ b/src/Resources/app/administration/src/main.js @@ -1,2 +1,36 @@ import './init/cms-page-type.init'; import './module/blog-module'; + +/** + * Register blog search service and types + * This adds blog entries to the global admin search (without Elasticsearch) + */ +import BlogSearchApiService from './service/blog-search-api.service'; + +// Register the Blog Search API Service +Shopware.Service().register('blogSearchService', (serviceContainer) => { + const httpClient = serviceContainer.httpClient; + const loginService = serviceContainer.loginService; + + return new BlogSearchApiService(httpClient, loginService); +}); + +// Register search types for blog entries and categories +const searchTypeService = Shopware.Service('searchTypeService'); +if (searchTypeService) { + searchTypeService.registerType('werkl_blog_entry', { + entityName: 'werkl_blog_entry', + placeholderSnippet: 'open-blogware.search.placeholder', + labelSnippet: 'open-blogware.search.label', + entityLabel: 'Blog Entry', + iconName: 'regular-file-text', + }); + + searchTypeService.registerType('werkl_blog_category', { + entityName: 'werkl_blog_category', + placeholderSnippet: 'open-blogware.search.categoryPlaceholder', + labelSnippet: 'open-blogware.search.categoryLabel', + entityLabel: 'Blog Category', + iconName: 'regular-folder', + }); +} diff --git a/src/Resources/app/administration/src/mixin/blog-search.mixin.js b/src/Resources/app/administration/src/mixin/blog-search.mixin.js new file mode 100644 index 00000000..9790e66f --- /dev/null +++ b/src/Resources/app/administration/src/mixin/blog-search.mixin.js @@ -0,0 +1,100 @@ +/** + * @sw-package inventory + * + * Mixin that extends the search bar to include blog entries in global admin search. + * Works without Elasticsearch by using the custom Blog Search API. + */ +import { debounce } from 'lodash'; + +const BlogSearchMixin = { + inject: ['blogSearchService'], + + data() { + return { + blogSearchResults: [], + isBlogSearchLoading: false, + }; + }, + + methods: { + /** + * Extend the search to include blog entries + */ + async extendedSearch(term, originalResults) { + if (!term || term.length < 2) { + this.blogSearchResults = []; + return originalResults; + } + + // Check if we should include blog entries in global search + const types = this.searchTypeService?.getTypes() ?? {}; + + // Always try to add blog results for global search + if (!this.currentSearchType || this.currentSearchType === 'all') { + try { + this.isBlogSearchLoading = true; + + const blogResponse = await this.blogSearchService.search(term, 5); + + if (blogResponse?.data) { + const blogResults = []; + + // Add blog entries + if (blogResponse.data.blog_entries && Object.keys(blogResponse.data.blog_entries).length > 0) { + blogResults.push({ + entity: 'werkl_blog_entry', + entityLabel: this.$tc('open-blogware.search.label', 2) || 'Blog Entries', + total: blogResponse.total?.blog_entries ?? Object.keys(blogResponse.data.blog_entries).length, + entities: Object.values(blogResponse.data.blog_entries), + }); + } + + // Add blog categories + if (blogResponse.data.blog_categories && Object.keys(blogResponse.data.blog_categories).length > 0) { + blogResults.push({ + entity: 'werkl_blog_category', + entityLabel: this.$tc('open-blogware.search.categoryLabel', 2) || 'Blog Categories', + total: blogResponse.total?.blog_categories ?? Object.keys(blogResponse.data.blog_categories).length, + entities: Object.values(blogResponse.data.blog_categories), + }); + } + + this.blogSearchResults = blogResults; + } else { + this.blogSearchResults = []; + } + } catch (error) { + console.warn('Blog search failed:', error); + this.blogSearchResults = []; + } finally { + this.isBlogSearchLoading = false; + } + } + + // Merge blog results with original results + if (this.blogSearchResults.length > 0) { + return [...originalResults, ...this.blogSearchResults]; + } + + return originalResults; + }, + + /** + * Hook to extend search results + */ + onSearchComplete(results) { + if (!this.blogSearchResults.length) { + return results; + } + + // The blog search is already integrated in extendedSearch + return results; + }, + }, +}; + +// Register the mixin globally +Shopware.Mixin.register('blogSearchMixin', BlogSearchMixin); + +// Export for use in components +export default BlogSearchMixin; \ No newline at end of file diff --git a/src/Resources/app/administration/src/module/blog-module/snippet/de-DE.json b/src/Resources/app/administration/src/module/blog-module/snippet/de-DE.json index fedca910..b47662ac 100644 --- a/src/Resources/app/administration/src/module/blog-module/snippet/de-DE.json +++ b/src/Resources/app/administration/src/module/blog-module/snippet/de-DE.json @@ -1,4 +1,12 @@ { + "open-blogware": { + "search": { + "label": "Blog Eintrag", + "placeholder": "Blog-Einträge suchen...", + "categoryLabel": "Blog Kategorie", + "categoryPlaceholder": "Blog-Kategorien suchen..." + } + }, "werkl-blog": { "general": { "mainMenuItemGeneral": "Blog", diff --git a/src/Resources/app/administration/src/module/blog-module/snippet/en-GB.json b/src/Resources/app/administration/src/module/blog-module/snippet/en-GB.json index 55736748..9e175223 100644 --- a/src/Resources/app/administration/src/module/blog-module/snippet/en-GB.json +++ b/src/Resources/app/administration/src/module/blog-module/snippet/en-GB.json @@ -1,4 +1,12 @@ { + "open-blogware": { + "search": { + "label": "Blog Entry", + "placeholder": "Search blog entries...", + "categoryLabel": "Blog Category", + "categoryPlaceholder": "Search blog categories..." + } + }, "werkl-blog": { "general": { "mainMenuItemGeneral": "Blog", diff --git a/src/Resources/app/administration/src/service/blog-search-api.service.js b/src/Resources/app/administration/src/service/blog-search-api.service.js new file mode 100644 index 00000000..8c93969f --- /dev/null +++ b/src/Resources/app/administration/src/service/blog-search-api.service.js @@ -0,0 +1,74 @@ +/** + * @sw-package inventory + */ + +import ApiService from '../../service/api.service'; + +/** + * Blog Search API Service for searching blog entries in the admin + * This service provides an interface to search blog entries and categories + * without requiring Elasticsearch + */ +class BlogSearchApiService extends ApiService { + constructor(httpClient, loginService, apiEndpoint = '_admin') { + super(httpClient, loginService, apiEndpoint); + this.name = 'blogSearchService'; + } + + /** + * Search for blog entries and categories + * + * @param {string} term - The search term + * @param {number} limit - Maximum number of results (default: 10) + * @param {object} additionalHeaders - Optional additional headers + * @returns {Promise} Search results containing blog_entries and blog_categories + */ + search(term, limit = 10, additionalHeaders = {}) { + const headers = this.getBasicHeaders(additionalHeaders); + + return this.httpClient + .post( + `${this.getApiBasePath()}/blog-search`, + { term, limit }, + { headers } + ) + .then((response) => { + return ApiService.handleResponse(response); + }) + .catch((error) => { + console.error('Blog search error:', error); + return { data: [], total: 0 }; + }); + } + + /** + * Search only blog entries + * + * @param {string} term - The search term + * @param {number} limit - Maximum number of results + * @returns {Promise} Blog entries search result + */ + searchEntries(term, limit = 10) { + return this.search(term, limit).then(result => ({ + entries: result.data?.blog_entries || [], + total: result.total?.blog_entries || 0 + })); + } + + /** + * Search only blog categories + * + * @param {string} term - The search term + * @param {number} limit - Maximum number of results + * @returns {Promise} Blog categories search result + */ + searchCategories(term, limit = 10) { + return this.search(term, limit).then(result => ({ + categories: result.data?.blog_categories || [], + total: result.total?.blog_categories || 0 + })); + } +} + +// eslint-disable-next-line sw-deprecation-rules/private-feature-declarations +export default BlogSearchApiService; \ No newline at end of file diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index 67c27133..d5df7a95 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -89,6 +89,24 @@ + + + + + + + + + + + + + + + + + +