Skip to content

Commit 872f362

Browse files
Merge pull request #26 from Sebobo/improvement-suggestions
TASK: Improve search suggestion handling
2 parents 047feb2 + 82755c9 commit 872f362

9 files changed

Lines changed: 286 additions & 17 deletions

File tree

Classes/Controller/SuggestController.php

Lines changed: 132 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,36 @@
1111
* source code.
1212
*/
1313

14+
use Flowpack\ElasticSearch\ContentRepositoryAdaptor\Eel\ElasticSearchQueryBuilder;
1415
use Flowpack\ElasticSearch\ContentRepositoryAdaptor\ElasticSearchClient;
16+
use Neos\Cache\Frontend\VariableFrontend;
1517
use Neos\Flow\Annotations as Flow;
1618
use Neos\Flow\Mvc\Controller\ActionController;
1719
use Neos\Flow\Mvc\View\JsonView;
20+
use Neos\Neos\Controller\CreateContentContextTrait;
1821

1922
class SuggestController extends ActionController
2023
{
24+
use CreateContentContextTrait;
25+
2126
/**
2227
* @Flow\Inject
2328
* @var ElasticSearchClient
2429
*/
2530
protected $elasticSearchClient;
2631

32+
/**
33+
* @Flow\Inject
34+
* @var ElasticSearchQueryBuilder
35+
*/
36+
protected $elasticSearchQueryBuilder;
37+
38+
/**
39+
* @Flow\Inject
40+
* @var VariableFrontend
41+
*/
42+
protected $elasticSearchQueryTemplateCache;
43+
2744
/**
2845
* @var array
2946
*/
@@ -32,26 +49,127 @@ class SuggestController extends ActionController
3249
];
3350

3451
/**
52+
* @param string $contextNodeIdentifier
53+
* @param string $dimensionCombination
3554
* @param string $term
36-
*
3755
* @return void
3856
*/
39-
public function indexAction($term)
57+
public function indexAction($contextNodeIdentifier, $dimensionCombination, $term)
4058
{
41-
$request = [
42-
'suggests' => [
43-
'text' => $term,
44-
'term' => [
45-
'field' => '_all'
46-
]
47-
]
59+
$result = [
60+
'completions' => [],
61+
'suggestions' => []
4862
];
4963

50-
$response = $this->elasticSearchClient->getIndex()->request('POST', '/_suggest', [], json_encode($request))->getTreatedContent();
51-
$suggestions = array_map(function ($option) {
52-
return $option['text'];
53-
}, $response['suggests'][0]['options']);
64+
if (!is_string($term)) {
65+
$result['errors'] = ['term has to be a string'];
66+
$this->view->assign('value', $result);
67+
return;
68+
}
69+
70+
$requestJson = $this->buildRequestForTerm($contextNodeIdentifier, $dimensionCombination, $term);
71+
72+
try {
73+
$response = $this->elasticSearchClient->getIndex()->request('POST', '/_search', [], $requestJson)->getTreatedContent();
74+
$result['completions'] = $this->extractCompletions($response);
75+
$result['suggestions'] = $this->extractSuggestions($response);
76+
} catch (\Exception $e) {
77+
$result['errors'] = ['Could not execute query'];
78+
}
79+
80+
$this->view->assign('value', $result);
81+
}
82+
83+
/**
84+
* @param string $term
85+
* @param string $contextNodeIdentifier
86+
* @param string $dimensionCombination
87+
* @return ElasticSearchQueryBuilder
88+
*/
89+
protected function buildRequestForTerm($contextNodeIdentifier, $dimensionCombination, $term)
90+
{
91+
$cacheKey = $contextNodeIdentifier . '-' . md5($dimensionCombination);
92+
$termPlaceholder = '---term-soh2gufuNi---';
93+
$term = strtolower($term);
94+
95+
// The suggest function only works well with one word
96+
// and the term is trimmed to alnum characters to avoid errors
97+
$suggestTerm = preg_replace('/[[:^alnum:]]/', '', explode(' ', $term)[0]);
98+
99+
if(!$this->elasticSearchQueryTemplateCache->has($cacheKey)) {
100+
$contentContext = $this->createContentContext('live', json_decode($dimensionCombination, true));
101+
$contextNode = $contentContext->getNodeByIdentifier($contextNodeIdentifier);
102+
103+
/** @var ElasticSearchQueryBuilder $query */
104+
$query = $this->elasticSearchQueryBuilder->query($contextNode);
105+
$query
106+
->queryFilter('prefix', [
107+
'__completion' => $termPlaceholder
108+
])
109+
->limit(1)
110+
->aggregation('autocomplete', [
111+
'terms' => [
112+
'field' => '__completion',
113+
'order' => [
114+
'_count' => 'desc'
115+
],
116+
'include' => [
117+
'pattern' => $termPlaceholder . '.*'
118+
]
119+
]
120+
])
121+
->suggestions('suggestions', [
122+
'text' => $termPlaceholder,
123+
'completion' => [
124+
'field' => '__suggestions',
125+
'fuzzy' => true,
126+
'size' => 10,
127+
'context' => [
128+
'parentPath' => $contextNode->getPath(),
129+
'workspace' => 'live',
130+
'dimensionCombinationHash' => md5(json_encode($contextNode->getContext()->getDimensions())),
131+
]
132+
]
133+
]);
134+
135+
$requestTemplate = $query->getRequest()->getRequestAsJson();
136+
137+
$this->elasticSearchQueryTemplateCache->set($contextNodeIdentifier, $requestTemplate);
138+
} else {
139+
$requestTemplate = $this->elasticSearchQueryTemplateCache->get($cacheKey);
140+
}
141+
142+
return str_replace($termPlaceholder, $suggestTerm, $requestTemplate);
143+
}
144+
145+
/**
146+
* Extract autocomplete options
147+
*
148+
* @param $response
149+
* @return array
150+
*/
151+
protected function extractCompletions($response)
152+
{
153+
$aggregations = isset($response['aggregations']) ? $response['aggregations'] : [];
154+
155+
return array_map(function ($option) {
156+
return $option['key'];
157+
}, $aggregations['autocomplete']['buckets']);
158+
}
159+
160+
/**
161+
* Extract suggestion options
162+
*
163+
* @param $response
164+
* @return array
165+
*/
166+
protected function extractSuggestions($response)
167+
{
168+
$suggestionOptions = isset($response['suggest']) ? $response['suggest'] : [];
169+
if (count($suggestionOptions['suggestions'][0]['options']) > 0) {
170+
return $suggestionOptions['suggestions'][0]['options'];
171+
}
54172

55-
$this->view->assign('value', $suggestions);
173+
return [];
56174
}
57175
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
namespace Flowpack\SearchPlugin\EelHelper;
3+
4+
/*
5+
* This file is part of the Flowpack.SearchPlugin package.
6+
*
7+
* (c) Contributors of the Flowpack Team - flowpack.org
8+
*
9+
* This package is Open Source Software. For the full copyright and license
10+
* information, please view the LICENSE file which was distributed with this
11+
* source code.
12+
*/
13+
14+
use Neos\Eel\ProtectedContextAwareInterface;
15+
use Neos\Flow\Annotations as Flow;
16+
17+
/**
18+
* Helper for building suggestion configurations
19+
*
20+
* @Flow\Proxy(false)
21+
*/
22+
class SuggestionIndexHelper implements ProtectedContextAwareInterface
23+
{
24+
25+
/**
26+
* @param string $input The input to store, this can be a an array of strings or just a string. This field is mandatory.
27+
* @param string $output The result is de-duplicated if several documents have the same output, i.e. only one is returned as part of the suggest result.
28+
* @param array $payload An arbitrary JSON object, which is simply returned in the suggest option.
29+
* @param int $weight A positive integer or a string containing a positive integer, which defines a weight and allows you to rank your suggestions.
30+
* @return array
31+
*/
32+
public function build($input, $output = '', array $payload = [], $weight = 1)
33+
{
34+
return [
35+
'input' => $this->prepareInput($input),
36+
'output' => $this->prepareOutput($output),
37+
'payload' => json_encode($payload),
38+
'weight' => $weight
39+
];
40+
}
41+
42+
/**
43+
* @param string $input
44+
* @return array
45+
*/
46+
protected function prepareInput($input)
47+
{
48+
$input = preg_replace( "/\r|\n/", '', $input);
49+
return array_values(array_filter(explode(' ', preg_replace("/[^[:alnum:][:space:]]/u", ' ', strip_tags($input)))));
50+
}
51+
52+
/**
53+
* @param string $input
54+
* @return array
55+
*/
56+
protected function prepareOutput($input)
57+
{
58+
return strip_tags($input);
59+
}
60+
61+
/**
62+
* All methods are considered safe
63+
*
64+
* @param string $methodName
65+
* @return boolean
66+
*/
67+
public function allowsCallOfMethod($methodName)
68+
{
69+
return true;
70+
}
71+
}

Configuration/Caches.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
FlowPackSearchPlugin_ElasticSearchQueryTemplateCache:
2+
frontend: Neos\Cache\Frontend\VariableFrontend
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
'Flowpack.SearchPlugin:SuggestableMixin':
2+
abstract: true
3+
properties:
4+
'__suggestions':
5+
search:
6+
elasticSearchMapping:
7+
type: completion
8+
payloads: true
9+
context:
10+
workspace:
11+
type: category
12+
path: '__workspace'
13+
parentPath:
14+
type: category
15+
path: '__parentPath'
16+
dimensionCombinationHash:
17+
type: category
18+
path: '__dimensionCombinationHash'
19+
indexing: "${Flowpack.SearchPlugin.Suggestion.build(q(node).property('title'), q(node).is('[instanceof Neos.Neos:Document]') ? node.identifier : q(node).parents('[instanceof Neos.Neos:Document]').get(0).identifier, {nodeIdentifier: node.identifier}, 20)}"
20+
21+
'Flowpack.SearchPlugin:AutocompletableMixin':
22+
abstract: true
23+
properties:
24+
'__completion':
25+
search:
26+
elasticSearchMapping:
27+
type: string
28+
analyzer: autocomplete
29+
indexing: "${String.stripTags(q(node).property('title'))}"

Configuration/NodeTypes.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,18 @@
44
ui:
55
label: 'Search'
66
icon: 'icon-search'
7+
8+
'Neos.Neos:Document':
9+
superTypes:
10+
'Flowpack.SearchPlugin:SuggestableMixin': true
11+
'Flowpack.SearchPlugin:AutocompletableMixin': true
12+
13+
'Neos.Neos:Shortcut':
14+
superTypes:
15+
'Flowpack.SearchPlugin:SuggestableMixin': false
16+
'Flowpack.SearchPlugin:AutocompletableMixin': false
17+
18+
'Neos.NodeTypes:TitleMixin':
19+
superTypes:
20+
'Flowpack.SearchPlugin:SuggestableMixin': true
21+
'Flowpack.SearchPlugin:AutocompletableMixin': true

Configuration/Objects.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Flowpack\SearchPlugin\Controller\SuggestController:
2+
properties:
3+
elasticSearchQueryTemplateCache:
4+
object:
5+
factoryObjectName: Neos\Flow\Cache\CacheManager
6+
factoryMethodName: getCache
7+
arguments:
8+
1:
9+
value: FlowPackSearchPlugin_ElasticSearchQueryTemplateCache

Configuration/Settings.yaml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,25 @@ Neos:
1111
fusion:
1212
autoInclude:
1313
Flowpack.SearchPlugin: true
14+
ContentRepository:
15+
Search:
16+
defaultContext:
17+
Flowpack.SearchPlugin.Suggestion: Flowpack\SearchPlugin\EelHelper\SuggestionIndexHelper
18+
19+
Flowpack:
20+
ElasticSearch:
21+
indexes:
22+
default:
23+
typo3cr:
24+
analysis:
25+
filter:
26+
autocompleteFilter:
27+
max_shingle_size: 5
28+
min_shingle_size: 2
29+
type: 'shingle'
30+
analyzer:
31+
autocomplete:
32+
filter: [ 'lowercase', 'autocompleteFilter' ]
33+
char_filter: [ 'html_strip' ]
34+
type: 'custom'
35+
tokenizer: 'standard'

Resources/Private/Fusion/SearchPlugin.fusion

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ prototype(Flowpack.SearchPlugin:Search) < prototype(Neos.Neos:Content) {
3333

3434
prototype(Flowpack.SearchPlugin:Search.Form) < prototype(Neos.Fusion:Template) {
3535
node = ${site}
36-
36+
dimensionCombination = ${Json.stringify(this.node.context.dimensions)}
3737
templatePath = 'resource://Flowpack.SearchPlugin/Private/Templates/NodeTypes/Search.Form.html'
38+
inputClassNames = ''
3839
searchWord = ${request.arguments.search}
3940
}
4041

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
<form method="GET">
2-
<input type="search" name="search" value="{searchWord}" placeholder="{f:translate(id: 'search', package: 'Flowpack.SearchPlugin')}" data-autocomplete-source="{f:uri.action(action: 'index', controller: 'Suggest', package: 'Flowpack.SearchPlugin', format: 'json', absolute: 1)}"/>
2+
<input type="search" name="search" value="{searchWord}" class="{inputClassNames}" autocomplete="off"
3+
placeholder="{f:translate(id: 'search', package: 'Flowpack.SearchPlugin')}"
4+
data-autocomplete-source="{f:uri.action(action: 'index', controller: 'Suggest', package: 'Flowpack.SearchPlugin', format: 'json', absolute: 1, arguments: {contextNodeIdentifier: node.identifier, dimensionCombination: dimensionCombination})}"/>
35
<button type="submit">{f:translate(id: 'search', package: 'Flowpack.SearchPlugin')}</button>
4-
</form>
6+
</form>

0 commit comments

Comments
 (0)