diff --git a/assets/controllers/elements/search_config_controller.js b/assets/controllers/elements/search_config_controller.js
new file mode 100644
index 00000000..87577589
--- /dev/null
+++ b/assets/controllers/elements/search_config_controller.js
@@ -0,0 +1,54 @@
+/*
+ * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
+ *
+ * Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import {Controller} from "@hotwired/stimulus";
+
+
+/**
+ * Used to disable the extensive and wildcard search options, when Regex search is enabled.
+ */
+export default class extends Controller
+{
+ static targets = ["regex", "extensive", "wildcard"]
+
+ connect() {
+
+ this.regexTarget.addEventListener('change', () => {
+ this.updateOthers()
+ });
+ }
+
+ updateOthers() {
+ let value = this.regexTarget.checked;
+ let ext = this.extensiveTarget;
+ let wild = this.wildcardTarget;
+ if (value === null) {
+ return;
+ }
+
+ if (value) {
+ ext.disabled = 'disabled'
+ wild.disabled = 'disabled'
+ }
+ else {
+ ext.removeAttribute('disabled')
+ wild.removeAttribute('disabled')
+ }
+ }
+}
diff --git a/assets/js/register_events.js b/assets/js/register_events.js
index 1c6ed09b..2a8f5ef4 100644
--- a/assets/js/register_events.js
+++ b/assets/js/register_events.js
@@ -83,7 +83,7 @@ class RegisterEventHelper {
document.querySelectorAll('.tooltip').forEach(el => el.remove());
//Exclude dropdown buttons from tooltips, otherwise we run into endless errors from bootstrap (bootstrap.esm.js:614 Bootstrap doesn't allow more than one instance per element. Bound instance: bs.dropdown.)
- const tooltipSelector = 'a[title], label[title], button[title]:not([data-bs-toggle="dropdown"]), p[title], span[title], h6[title], h3[title], i[title], small[title]';
+ const tooltipSelector = 'a[title], label[title], button[title]:not([data-bs-toggle="dropdown"]), p[title], span[title], h6[title], h3[title], i[title], small[title], div[title]';
document.querySelectorAll(tooltipSelector).forEach(el => {
const existing = Tooltip.getInstance(el);
if (existing) {
diff --git a/src/Controller/PartListsController.php b/src/Controller/PartListsController.php
index 2210fc18..87d48a4a 100644
--- a/src/Controller/PartListsController.php
+++ b/src/Controller/PartListsController.php
@@ -334,6 +334,8 @@ class PartListsController extends AbstractController
$filter->setRegex($request->query->getBoolean('regex'));
+ $filter->setExtensive($request->query->getBoolean('extensive'));
+ $filter->setWildcard($request->query->getBoolean('wildcard'));
return $filter;
}
diff --git a/src/DataTables/Filters/PartSearchFilter.php b/src/DataTables/Filters/PartSearchFilter.php
index 9f6734e5..777f9c8e 100644
--- a/src/DataTables/Filters/PartSearchFilter.php
+++ b/src/DataTables/Filters/PartSearchFilter.php
@@ -22,7 +22,9 @@ declare(strict_types=1);
*/
namespace App\DataTables\Filters;
use App\DataTables\Filters\Constraints\AbstractConstraint;
+use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\QueryBuilder;
+use Doctrine\ORM\Query\Parameter;
use Doctrine\DBAL\ParameterType;
class PartSearchFilter implements FilterInterface
@@ -30,6 +32,12 @@ class PartSearchFilter implements FilterInterface
/** @var boolean Whether to use regex for searching */
protected bool $regex = false;
+
+ /** @var boolean Whether to use extensive matching for searching */
+ protected bool $extensive = false;
+
+ /** @var boolean Whether to use wildcards for searching */
+ protected bool $wildcard = false;
/** @var bool Use name field for searching */
protected bool $name = true;
@@ -70,11 +78,14 @@ class PartSearchFilter implements FilterInterface
/** @var bool Use Internal Part number for searching */
protected bool $ipn = true;
+ /** @var int array_map iteration helper variable */
+ protected int $it = 0;
+
public function __construct(
/** @var string The string to query for */
protected string $keyword
- )
- {
+ ) {
+
}
protected function getFieldsToSearch(): array
@@ -124,43 +135,72 @@ class PartSearchFilter implements FilterInterface
public function apply(QueryBuilder $queryBuilder): void
{
$fields_to_search = $this->getFieldsToSearch();
- $is_numeric = preg_match('/^\d+$/', $this->keyword) === 1;
+ $is_numeric = preg_match('/^\d+$/', trim($this->keyword)) === 1;
// Add exact ID match only when the keyword is numeric
$search_dbId = $is_numeric && (bool)$this->dbId;
- //If we have nothing to search for, do nothing
- if (($fields_to_search === [] && !$search_dbId) || $this->keyword === '') {
+ $tokens = [];
+ if ($this->extensive) {
+ //Transform keyword and trim excess spaces
+ $this->keyword = trim(str_replace('+', ' ', $this->keyword));
+ //Split keyword on spaces, but limit token count to 5
+ $tokens = explode(' ', $this->keyword, 5);
+ //Throw away array elements which are null or have zero length
+ $tokens = array_filter($tokens, fn($x) => (strlen($x) > 0));
+ }
+ else {
+ //Pass the whole keyword into the (empty) tokens array as is,
+ //retaining the original search behavior
+ $tokens[] = $this->keyword;
+ }
+
+ //If we have nothing to search for...
+ if (($fields_to_search === [] && !$search_dbId) || $this->keyword === '' || empty($tokens)) {
+ // ...enforce returning no results
+ $queryBuilder->add('where','1 = 0');
return;
}
$expressions = [];
-
- if($fields_to_search !== []) {
- //Convert the fields to search to a list of expressions
- $expressions = array_map(function (string $field): string {
- if ($this->regex) {
- return sprintf("REGEXP(%s, :search_query) = TRUE", $field);
- }
+ $expressions2 = [];
+ $params = [];
- return sprintf("ILIKE(%s, :search_query) = TRUE", $field);
- }, $fields_to_search);
-
- //For regex, we pass the query as is, for like we add % to the start and end as wildcards
+ //Search in selected fields, either based on regex or on tokenized keyword
+ if ($fields_to_search !== []) {
+ //For regex, we pass the query as is
if ($this->regex) {
- $queryBuilder->setParameter('search_query', $this->keyword);
+ //Convert the fields to search to a list of expressions
+ $expressions = array_merge($expressions, array_map(function (string $field): string {
+ return sprintf("REGEXP(%s, :search_query) = TRUE", $field);
+ }, $fields_to_search));
+ $params[] = new Parameter('search_query', $this->keyword);
} else {
- //Escape % and _ characters in the keyword
- $this->keyword = str_replace(['%', '_'], ['\%', '\_'], $this->keyword);
- $queryBuilder->setParameter('search_query', '%' . $this->keyword . '%');
- }
- }
+ //Add a new expression and parameter set to the query for each token
+ foreach ($tokens as $i => $token) {
+ //Conditionally escape % and _ characters
+ if (!$this->wildcard)
+ $token = str_replace(['%', '_'], ['\%', '\_'], $token);
- //Use equal expression to just search for exact numeric matches
- if ($search_dbId) {
- $expressions[] = $queryBuilder->expr()->eq('part.id', ':id_exact');
- $queryBuilder->setParameter('id_exact', (int) $this->keyword,
- ParameterType::INTEGER);
+ //Convert the fields to search to a list of expressions
+ $tmp = array_fill_keys($fields_to_search, $i);
+ $expressions2 = array_map(function (string $field, int $idx): string {
+ return sprintf("ILIKE(%s, :search_query%u) = TRUE", $field, $idx);
+ }, array_keys($tmp), array_values($tmp));
+
+ //Aggregate the parameters for consolidated commission at the end
+ //For like, we add % to the start and end as wildcards
+ $params[] = new Parameter('search_query' . $i, '%' . $token . '%');
+
+ //Guard condition
+ if (!empty($expressions2)) {
+ //Add Or concatenation of the expressions to our query
+ $queryBuilder->andWhere(
+ $queryBuilder->expr()->orX(...$expressions2)
+ );
+ }
+ }
+ }
}
//Guard condition
@@ -169,7 +209,16 @@ class PartSearchFilter implements FilterInterface
$queryBuilder->andWhere(
$queryBuilder->expr()->orX(...$expressions)
);
+ }
+ //Use equal expression to search for exact numeric matches
+ if ($search_dbId) {
+ $queryBuilder->orWhere($queryBuilder->expr()->eq('part.id', ':id_exact'));
+ $params[] = new Parameter('id_exact', (int)$this->keyword,
+ ParameterType::INTEGER);
}
+ $queryBuilder->setParameters(
+ new ArrayCollection($params)
+ );
}
public function getKeyword(): string
@@ -194,6 +243,30 @@ class PartSearchFilter implements FilterInterface
return $this;
}
+ public function isExtensive(): bool
+ {
+ return $this->extensive;
+ }
+
+ public function setExtensive(bool $extensive): PartSearchFilter
+ {
+ $this->extensive = $extensive;
+ return $this;
+ }
+
+
+ public function isWildcard(): bool
+ {
+ return $this->wildcard;
+ }
+
+ public function setWildcard(bool $wildcard): PartSearchFilter
+ {
+ $this->wildcard = $wildcard;
+ return $this;
+ }
+
+
public function isName(): bool
{
return $this->name;
diff --git a/templates/components/search.macro.html.twig b/templates/components/search.macro.html.twig
index 90c01876..d4b25590 100644
--- a/templates/components/search.macro.html.twig
+++ b/templates/components/search.macro.html.twig
@@ -1,11 +1,11 @@
-{% macro settings_drodown(show_label_instead_icon = true) %}
+{% macro settings_dropdown(show_label_instead_icon = true) %}
-
+
@@ -66,10 +66,18 @@
{% endif %}
-
-
+
+
+
+
+
+
+
+
+
+
@@ -85,7 +93,7 @@
{# Show the options left in navbar #}
{% if is_navbar %}
- {{ _self.settings_drodown(is_navbar) }}
+ {{ _self.settings_dropdown(is_navbar) }}
{% endif %}