diff --git a/Module.php b/Module.php index 39a5c64..10c3e83 100644 --- a/Module.php +++ b/Module.php @@ -3,6 +3,8 @@ require_once __DIR__ . '/src/Module/AbstractGenericModule.php'; +use Doctrine\ORM\QueryBuilder; +use Omeka\Api\Adapter\AbstractResourceEntityAdapter; use Next\Module\AbstractGenericModule; use Zend\EventManager\Event; use Zend\EventManager\SharedEventManagerInterface; @@ -61,12 +63,17 @@ public function attachListeners(SharedEventManagerInterface $sharedEventManager) public function apiSearchQuery(Event $event) { - // Add the random sort. + $adapter = $event->getTarget(); + $qb = $event->getParam('queryBuilder'); $query = $event->getParam('request')->getContent(); + + // Add the random sort. if (isset($query['sort_by']) && $query['sort_by'] === 'random') { - $qb = $event->getParam('queryBuilder'); $qb->orderBy('RAND()'); } + + // Advanced property sorts. + $this->buildPropertyQuery($qb, $query, $adapter); } /** @@ -218,4 +225,191 @@ public function formAddElementsResourceBatchUpdateForm(Event $event) ], ]); } + + /** + * Build query on value. + * + * Complete \Omeka\Api\Adapter\AbstractResourceEntityAdapter::buildPropertyQuery() + * + * Note: because this filter is separate from the core one, all the + * properties are rechecked to avoid a issue with the joiner (or/and). + * @todo Find a way to not recheck all arguments used to search properties as long as it's not in the core. + * + * Query format: + * + * - property[{index}][joiner]: "and" OR "or" joiner with previous query + * - property[{index}][property]: property ID + * - property[{index}][text]: search text + * - property[{index}][type]: search type + * - eq: is exactly + * - neq: is not exactly + * - in: contains + * - nin: does not contain + * - ex: has any value + * - nex: has no value + * - list: is in list + * - nlist: is not in list + * - sw: starts with + * - nsw: does not start with + * - ew: ends with + * - new: does not end with + * - res: has resource + * - nres: has no resource + * + * @param QueryBuilder $qb + * @param array $query + * @param AbstractResourceEntityAdapter $adapter + */ + protected function buildPropertyQuery(QueryBuilder $qb, array $query, AbstractResourceEntityAdapter $adapter) + { + if (!isset($query['property']) || !is_array($query['property'])) { + return; + } + $valuesJoin = $adapter->getEntityClass() . '.values'; + $where = ''; + $expr = $qb->expr(); + + foreach ($query['property'] as $queryRow) { + if (!(is_array($queryRow) + && array_key_exists('property', $queryRow) + && array_key_exists('type', $queryRow) + )) { + continue; + } + $propertyId = $queryRow['property']; + $queryType = $queryRow['type']; + $joiner = isset($queryRow['joiner']) ? $queryRow['joiner'] : null; + $value = isset($queryRow['text']) ? $queryRow['text'] : null; + + if (!strlen($value) && $queryType !== 'nex' && $queryType !== 'ex') { + continue; + } + + $valuesAlias = $adapter->createAlias(); + $positive = true; + + switch ($queryType) { + case 'neq': + $positive = false; + // No break. + case 'eq': + $param = $adapter->createNamedParameter($qb, $value); + $predicateExpr = $expr->orX( + $expr->eq("$valuesAlias.value", $param), + $expr->eq("$valuesAlias.uri", $param) + ); + break; + + case 'nin': + $positive = false; + // No break. + case 'in': + $param = $adapter->createNamedParameter($qb, "%$value%"); + $predicateExpr = $expr->orX( + $expr->like("$valuesAlias.value", $param), + $expr->like("$valuesAlias.uri", $param) + ); + break; + + case 'nlist': + $positive = false; + // No break. + case 'list': + $list = is_array($value) ? $value : explode("\n", $value); + $list = array_filter(array_map('trim', $list), 'strlen'); + if (empty($list)) { + continue 2; + } + $param = $adapter->createNamedParameter($qb, $list); + $predicateExpr = $expr->orX( + $expr->in("$valuesAlias.value", $param), + $expr->in("$valuesAlias.uri", $param) + ); + break; + + case 'nsw': + $positive = false; + // No break. + case 'sw': + $param = $adapter->createNamedParameter($qb, "$value%"); + $predicateExpr = $expr->orX( + $expr->like("$valuesAlias.value", $param), + $expr->like("$valuesAlias.uri", $param) + ); + break; + + case 'new': + $positive = false; + // No break. + case 'ew': + $param = $adapter->createNamedParameter($qb, "%$value"); + $predicateExpr = $expr->orX( + $expr->like("$valuesAlias.value", $param), + $expr->like("$valuesAlias.uri", $param) + ); + break; + + case 'nres': + $positive = false; + // No break. + case 'res': + $predicateExpr = $expr->eq( + "$valuesAlias.valueResource", + $adapter->createNamedParameter($qb, $value) + ); + break; + + case 'nex': + $positive = false; + // No break. + case 'ex': + $predicateExpr = $expr->isNotNull("$valuesAlias.id"); + break; + + default: + continue 2; + } + + $joinConditions = []; + // Narrow to specific property, if one is selected + if ($propertyId) { + if (is_numeric($propertyId)) { + $propertyId = (int) $propertyId; + } else { + $property = $adapter->getPropertyByTerm($propertyId); + if ($property) { + $propertyId = $property->getId(); + } else { + $propertyId = 0; + } + } + $joinConditions[] = $expr->eq("$valuesAlias.property", (int) $propertyId); + } + + if ($positive) { + $whereClause = '(' . $predicateExpr . ')'; + } else { + $joinConditions[] = $predicateExpr; + $whereClause = $expr->isNull("$valuesAlias.id"); + } + + if ($joinConditions) { + $qb->leftJoin($valuesJoin, $valuesAlias, 'WITH', $expr->andX(...$joinConditions)); + } else { + $qb->leftJoin($valuesJoin, $valuesAlias); + } + + if ($where == '') { + $where = $whereClause; + } elseif ($joiner == 'or') { + $where .= " OR $whereClause"; + } else { + $where .= " AND $whereClause"; + } + } + + if ($where) { + $qb->andWhere($where); + } + } } diff --git a/README.md b/README.md index 536c36f..ff4b7ae 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,11 @@ Display resources in a random order, for example for a carousel or a featured resource. Simply add `sort_by=random` to the query when needed, in particular in the page block `Browse preview` (fix [#1281]). +#### Advanced search by start with, end with, or in list + +Allow to do more advanced search in public or admin board on values of the +properties: start with, end with, in list (fix [#1274], [#1276]). + ### Admin #### Trim property values @@ -136,8 +141,11 @@ Copyright [Omeka S]: https://omeka.org/s [Next]: https://github.com/Daniel-KM/Omeka-S-module-Next [shortcode as a page]: https://github.com/omeka/plugin-SimplePages/pull/24 -[#1283]: https://github.com/omeka/omeka-s/issues/1283 [#1258]: https://github.com/omeka/omeka-s/issues/1258 +[#1274]: https://github.com/omeka/omeka-s/issues/1274 +[#1276]: https://github.com/omeka/omeka-s/issues/1276 +[#1281]: https://github.com/omeka/omeka-s/issues/1281 +[#1283]: https://github.com/omeka/omeka-s/issues/1283 [Installing a module]: http://dev.omeka.org/docs/s/user-manual/modules/#installing-modules [module issues]: https://github.com/Daniel-KM/Omeka-S-module-Next/issues [CeCILL v2.1]: https://www.cecill.info/licences/Licence_CeCILL_V2.1-en.html diff --git a/config/module.config.php b/config/module.config.php index e9480e3..3a97662 100644 --- a/config/module.config.php +++ b/config/module.config.php @@ -12,6 +12,7 @@ ], 'view_helpers' => [ 'invokables' => [ + 'searchFilters' => View\Helper\SearchFilters::class, 'userBar' => View\Helper\UserBar::class, ], 'factories' => [ diff --git a/src/View/Helper/SearchFilters.php b/src/View/Helper/SearchFilters.php new file mode 100644 index 0000000..a984629 --- /dev/null +++ b/src/View/Helper/SearchFilters.php @@ -0,0 +1,202 @@ +getView()->plugin('translate'); + + $filters = []; + $api = $this->getView()->api(); + $query = $this->getView()->params()->fromQuery(); + $queryTypes = [ + 'eq' => $translate('is exactly'), + 'neq' => $translate('is not exactly'), + 'in' => $translate('contains'), + 'nin' => $translate('does not contain'), + 'sw' => $translate('starts with'), + 'nsw' => $translate('does not start with'), + 'ew' => $translate('ends with'), + 'new' => $translate('does not end with'), + 'res' => $translate('is resource with ID'), + 'nres' => $translate('is not resource with ID'), + 'ex' => $translate('has any value'), + 'nex' => $translate('has no values'), + ]; + + foreach ($query as $key => $value) { + if ($value != null) { + switch ($key) { + // Search by class + case 'resource_class_id': + if (!is_array($value)) { + $value = [$value]; + } + foreach ($value as $subValue) { + if (!is_numeric($subValue)) { + continue; + } + $filterLabel = $translate('Class'); + try { + $filterValue = $api->read('resource_classes', $subValue)->getContent()->label(); + } catch (NotFoundException $e) { + $filterValue = $translate('Unknown class'); + } + $filters[$filterLabel][] = $filterValue; + } + break; + + // Search values (by property or all) + case 'property': + $index = 0; + foreach ($value as $queryRow) { + if (!(is_array($queryRow) + && array_key_exists('property', $queryRow) + && array_key_exists('type', $queryRow) + )) { + continue; + } + $propertyId = $queryRow['property']; + $queryType = $queryRow['type']; + $joiner = isset($queryRow['joiner']) ? $queryRow['joiner'] : null; + $value = isset($queryRow['text']) ? $queryRow['text'] : null; + + if (!$value && $queryType !== 'nex' && $queryType !== 'ex') { + continue; + } + if ($propertyId) { + if (is_numeric($propertyId)) { + try { + $property = $api->read('properties', $propertyId)->getContent(); + } catch (NotFoundException $e) { + $property = null; + } + } else { + $property = $api->searchOne('properties', ['term' => $propertyId])->getContent(); + } + + if ($property) { + $propertyLabel = $translate($property->label()); + } else { + $propertyLabel = $translate('Unknown property'); + } + } else { + $propertyLabel = $translate('[Any property]'); + } + if (!isset($queryTypes[$queryType])) { + continue; + } + $filterLabel = $propertyLabel . ' ' . $queryTypes[$queryType]; + if ($index > 0) { + if ($joiner === 'or') { + $filterLabel = $translate('OR') . ' ' . $filterLabel; + } else { + $filterLabel = $translate('AND') . ' ' . $filterLabel; + } + } + + $filters[$filterLabel][] = $value; + $index++; + } + break; + case 'search': + $filterLabel = $translate('Search'); + $filters[$filterLabel][] = $value; + break; + + // Search resource template + case 'resource_template_id': + if (!is_array($value)) { + $value = [$value]; + } + foreach ($value as $subValue) { + if (!is_numeric($subValue)) { + continue; + } + $filterLabel = $translate('Template'); + try { + $filterValue = $api->read('resource_templates', $subValue)->getContent()->label(); + } catch (NotFoundException $e) { + $filterValue = $translate('Unknown template'); + } + $filters[$filterLabel][] = $filterValue; + } + break; + + // Search item set + case 'item_set_id': + if (!is_array($value)) { + $value = [$value]; + } + foreach ($value as $subValue) { + if (!is_numeric($subValue)) { + continue; + } + $filterLabel = $translate('Item set'); + try { + $filterValue = $api->read('item_sets', $subValue)->getContent()->displayTitle(); + } catch (NotFoundException $e) { + $filterValue = $translate('Unknown item set'); + } + $filters[$filterLabel][] = $filterValue; + } + break; + + // Search user + case 'owner_id': + $filterLabel = $translate('User'); + try { + $filterValue = $api->read('users', $value)->getContent()->name(); + } catch (NotFoundException $e) { + $filterValue = $translate('Unknown user'); + } + $filters[$filterLabel][] = $filterValue; + break; + + case 'site_id': + $filterLabel = $translate('Site'); + try { + $filterValue = $api->read('sites', $value)->getContent()->title(); + } catch (NotFoundException $e) { + $filterValue = $translate('Unknown site'); + } + $filters[$filterLabel][] = $filterValue; + break; + } + } + } + + $result = $this->getView()->trigger( + 'view.search.filters', + ['filters' => $filters, 'query' => $query], + true + ); + $filters = $result['filters']; + + return $this->getView()->partial( + $partialName, + [ + 'filters' => $filters, + ] + ); + } +} diff --git a/view/common/advanced-search/properties.phtml b/view/common/advanced-search/properties.phtml new file mode 100644 index 0000000..8e67afe --- /dev/null +++ b/view/common/advanced-search/properties.phtml @@ -0,0 +1,99 @@ +plugin('translate'); +$escape = $this->plugin('escapeHtml'); +$hyperlink = $this->plugin('hyperlink'); + +$isSiteRequest = version_compare(\Omeka\Module::VERSION, '1.3.0', '<') ? $this->params()->fromRoute('__SITE__') : $this->status()->isSiteRequest(); +$applyTemplates = $isSiteRequest ? $this->siteSetting('search_apply_templates') : false; + +// Prepare the property queries. +$properties = isset($query['property']) ? $query['property'] : []; +$properties = array_filter($properties, function ($value) { + return isset($value['text']) ? '' !== trim($value['text']) : true; +}); + if (!$properties) { + $properties[] = []; + } + + if (isset($query['search'])) { + unset($properties[0]['joiner']); + array_unshift($properties, [ + 'property' => '', + 'type' => 'in', + 'text' => $query['search'] + ]); + } + + $queryOption = function($value, array $search, $key, $text) { + $selected = null; + if (isset($search[$key]) && $value === $search[$key]) { + $selected = ' selected'; + } + return sprintf('', $value, $selected, $text); + }; + $queryText = function(array $search, $index) { + $text = isset($search['text']) ? $search['text'] : null; + return sprintf('', + $this->escapeHtml("property[$index][text]"), + $this->escapeHtml($text), + $this->escapeHtml($this->translate('Query text'))); + } + ?> + +
+
+ +
+
+ +
+ + propertySelect([ + 'name' => $stem . '[property]', + 'attributes' => [ + 'class' => 'query-property', + 'value' => isset($property['property']) ? $property['property'] : null, + 'aria-label' => $translate('Property'), + ], + 'options' => [ + 'empty_option' => '[Any Property]', // @translate + 'apply_templates' => $applyTemplates, + ] + ]); ?> + + + +
+ 'add-value button']); + ?> +
+