@@ -0,0 +1,49 @@ | |||
<?php | |||
// {{{ License | |||
// This file is part of GNU social - https://www.gnu.org/software/social | |||
// | |||
// GNU social 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. | |||
// | |||
// GNU social 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 GNU social. If not, see <http://www.gnu.org/licenses/>. | |||
// }}} | |||
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, | |||
]; | |||
} | |||
} |
@@ -0,0 +1,60 @@ | |||
<?php | |||
// {{{ License | |||
// This file is part of GNU social - https://www.gnu.org/software/social | |||
// | |||
// GNU social 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. | |||
// | |||
// GNU social 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 GNU social. If not, see <http://www.gnu.org/licenses/>. | |||
// }}} | |||
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; | |||
} | |||
} |
@@ -0,0 +1,101 @@ | |||
<?php | |||
// {{{ License | |||
// This file is part of GNU social - https://www.gnu.org/software/social | |||
// | |||
// GNU social 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. | |||
// | |||
// GNU social 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 GNU social. If not, see <http://www.gnu.org/licenses/>. | |||
// }}} | |||
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)); | |||
} | |||
} |
@@ -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 %} |
@@ -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'); | |||
} | |||
} |
@@ -77,6 +77,13 @@ | |||
<h1>{{ icon('logo', 'icon icon-logo') | raw }}{{ config('site', 'name') }} </h1> | |||
</a> | |||
<div> | |||
{% set extra = handle_event('AddHeaderElements', request) %} | |||
{% for el in extra %} | |||
{{ form(el) }} | |||
{% endfor %} | |||
</div> | |||
{% if app.user %} | |||
{{ block("rightpanel", "stdgrid.html.twig") }} | |||
{% endif %} | |||