diff --git a/components/Search/Controller/Search.php b/components/Search/Controller/Search.php new file mode 100644 index 0000000000..92797aa3ab --- /dev/null +++ b/components/Search/Controller/Search.php @@ -0,0 +1,49 @@ +. + +// }}} + +namespace Component\Search\Controller; + +use App\Core\Controller; +use App\Core\DB\DB; +use App\Core\Event; +use Component\Search\Util\Parser; +use Symfony\Component\HttpFoundation\Request; + +class Search extends Controller +{ + public function handle(Request $request) + { + $q = $this->string('q'); + $criteria = Parser::parse($q); + + $qb = DB::createQueryBuilder(); + $qb->select('note')->from('App\Entity\Note', 'note'); + Event::handle('SeachQueryAddJoins', [&$qb]); + $qb->addCriteria($criteria); + $query = $qb->getQuery(); + $results = $query->execute(); + + return [ + '_template' => 'search_results.html.twig', + 'results' => $results, + ]; + } +} diff --git a/components/Search/Search.php b/components/Search/Search.php new file mode 100644 index 0000000000..9fbe81f0ed --- /dev/null +++ b/components/Search/Search.php @@ -0,0 +1,60 @@ +. + +// }}} + +namespace Component\Search; + +use App\Core\Event; +use App\Core\Form; +use App\Core\Modules\Component; +use App\Util\Exception\RedirectException; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\HttpFoundation\Request; + +class Search extends Component +{ + public function onAddRoute($r) + { + $r->connect('search', '/search', Controller\Search::class); + } + + /** + * Add the search form to the site header + */ + public function onAddHeaderElements(Request $request, array &$elements) + { + $form = Form::create([ + ['query', TextType::class, []], + [$form_name = 'submit_search', SubmitType::class, []], + ]); + + if ('POST' === $request->getMethod() && $request->request->has($form_name)) { + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + $data = $form->getData(); + throw new RedirectException('search', ['q' => $data['query']]); + } + } + + $elements[] = $form->createView(); + return Event::next; + } +} diff --git a/components/Search/Util/Parser.php b/components/Search/Util/Parser.php new file mode 100644 index 0000000000..ff31055781 --- /dev/null +++ b/components/Search/Util/Parser.php @@ -0,0 +1,101 @@ +. + +// }}} + +namespace Component\Search\Util; + +use App\Core\Event; +use App\Util\Exception\ServerException; +use Doctrine\Common\Collections\Criteria; + +abstract class Parser +{ + /** + * Parse $input string into a Doctrine query Criteria + * + * Currently doesn't support nesting with parenthesis and + * recognises either spaces (currently `or`, should be fuzzy match), `OR` or `|` (`or`) and `AND` or `&` (`and`) + * + * TODO Better fuzzy match, implement exact match with quotes and nesting with parens + */ + public static function parse(string $input, int $level = 0): Criteria + { + if ($level === 0) { + $input = trim(preg_replace(['/\s+/', '/\s+AND\s+/', '/\s+OR\s+/'], [' ', '&', '|'], $input), ' |&'); + } + + $left = $right = 0; + $lenght = mb_strlen($input); + $stack = []; + $eb = Criteria::expr(); + $criteria = []; + $parts = []; + $last_op = null; + + $connect_parts = /** + * Merge $parts into $criteria + */ + function (bool $force = false) use ($eb, &$parts, $last_op, &$criteria) { + foreach ([' ' => 'orX', '|' => 'orX', '&' => 'andX'] as $op => $func) { + if ($last_op === $op || $force) { + $criteria[] = $eb->{$func}(...$parts); + $parts = []; + break; + } + } + }; + + for ($index = 0; $index < $lenght; ++$index) { + $end = false; + $match = false; + + foreach (['&', '|', ' '] as $delimiter) { + if ($input[$index] === $delimiter || $end = ($index === $lenght - 1)) { + $term = substr($input, $left, $end ? null : $right - $left); + $res = null; + $ret = Event::handle('SearchCreateExpression', [$eb, $term, &$res]); + if (is_null($res) || $ret == Event::next) { + throw new ServerException("No one claimed responsibility for a match term: {$term}"); + } + $parts[] = $res; + + $right = $left = $index + 1; + + if (!is_null($last_op) && $last_op !== $delimiter) { + $connect_parts(force: false); + } else { + $last_op = $delimiter; + } + $match = true; + continue 2; + } + } + if (!$match) { + ++$right; + } + } + + if (!empty($parts)) { + $connect_parts(force: true); + } + + return new Criteria($eb->orX(...$criteria)); + } +} diff --git a/components/Search/templates/search_results.html.twig b/components/Search/templates/search_results.html.twig new file mode 100644 index 0000000000..e888104b13 --- /dev/null +++ b/components/Search/templates/search_results.html.twig @@ -0,0 +1,7 @@ +{% for res in results %} + {% if res is instanceof('App\\Entity\\Note') %} + {% include 'note/view.html.twig' with {'note': res} %} + {% else %} + {{ dump(res) }} + {% endif %} +{% endfor %} diff --git a/components/Tag/Tag.php b/components/Tag/Tag.php index 4dedb33be9..8bad61d58e 100644 --- a/components/Tag/Tag.php +++ b/components/Tag/Tag.php @@ -30,6 +30,9 @@ use App\Entity\Note; use App\Entity\NoteTag; use App\Util\Formatting; use App\Util\HTML; +use Doctrine\Common\Collections\ExpressionBuilder; +use Doctrine\ORM\Query\Expr; +use Doctrine\ORM\QueryBuilder; /** * Component responsible for extracting tags from posted notes, as well as normalizing them @@ -86,4 +89,19 @@ class Tag extends Component { return substr(Formatting::slugify($tag), 0, self::MAX_TAG_LENGTH); } + + public function onSearchCreateExpression(ExpressionBuilder $eb, string $term, &$expr) + { + if (preg_match(self::TAG_REGEX, $term)) { + $expr = $eb->eq('note_tag.tag', $term); + return Event::stop; + } else { + return Event::next; + } + } + + public function onSeachQueryAddJoins(QueryBuilder &$qb) + { + $qb->join('App\Entity\NoteTag', 'note_tag', Expr\Join::WITH, 'note_tag.note_id = note.id'); + } } diff --git a/templates/base.html.twig b/templates/base.html.twig index 325f508e6b..a0686c850e 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -77,6 +77,13 @@

{{ icon('logo', 'icon icon-logo') | raw }}{{ config('site', 'name') }}

+
+ {% set extra = handle_event('AddHeaderElements', request) %} + {% for el in extra %} + {{ form(el) }} + {% endfor %} +
+ {% if app.user %} {{ block("rightpanel", "stdgrid.html.twig") }} {% endif %}