Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions src/Controller/Admin/BlogAdminSearchController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?php

declare(strict_types=1);

namespace Werkl\OpenBlogware\Controller\Admin;

use Shopware\Core\Framework\Api\Serializer\JsonEntityEncoder;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityCollection;
use Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\Framework\Uuid\Uuid;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Werkl\OpenBlogware\Framework\Search\Admin\BlogEntryAdminSearchHandler;

/**
* Controller for blog-related admin search functionality
* Provides an API endpoint to integrate blog entries into the global admin search
*/
#[Package('inventory')]
class BlogAdminSearchController extends AbstractController
{
public function __construct(
private readonly BlogEntryAdminSearchHandler $searchHandler,
private readonly DefinitionInstanceRegistry $definitionRegistry,
private readonly JsonEntityEncoder $entityEncoder
) {}

/**
* Search for blog entries and categories in the admin
*/
#[Route(path: '/api/_admin/blog-search', name: 'api.admin.blog-search', methods: ['POST'], defaults: ['_routeScope' => ['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
);
}
}
180 changes: 180 additions & 0 deletions src/Framework/Search/Admin/BlogEntryAdminSearchHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
<?php

declare(strict_types=1);

namespace Werkl\OpenBlogware\Framework\Search\Admin;

use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\MultiFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\OrFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Term\MultiFieldScoreFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Term\SearchTermInterpreter;
use Werkl\OpenBlogware\Content\Blog\BlogEntryDefinition;
use Werkl\OpenBlogware\Content\BlogCategory\BlogCategoryDefinition;

/**
* Search handler for blog entries in the Shopware Admin global search.
* Uses DAL-based searching without requiring Elasticsearch.
*
* This handler enables blog entries and categories to be searchable
* through a custom API endpoint that can be integrated with the admin search.
*/
class BlogEntryAdminSearchHandler
{
public function __construct(
private readonly BlogEntryDefinition $blogEntryDefinition,
private readonly BlogCategoryDefinition $blogCategoryDefinition,
private readonly SearchTermInterpreter $interpreter
) {}

/**
* Search for blog entries matching the given term
*/
public function search(string $term, Context $context, int $limit = 10): EntitySearchResult
{
$criteria = $this->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;
}
}
34 changes: 34 additions & 0 deletions src/Resources/app/administration/src/main.js
Original file line number Diff line number Diff line change
@@ -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',
});
}
Loading