diff --git a/include/ajax.orgs.php b/include/ajax.orgs.php index c4d73187bf6d2c85c2bf1dc4a7c0f6650174e233..0cc098a50ea44a9bb3863b243c2617690cdbdee4 100644 --- a/include/ajax.orgs.php +++ b/include/ajax.orgs.php @@ -26,26 +26,32 @@ class OrgsAjaxAPI extends AjaxController { Http::response(400, 'Query argument is required'); } + $q = $_REQUEST['q']; $limit = isset($_REQUEST['limit']) ? (int) $_REQUEST['limit']:25; - $orgs=array(); - - $escaped = db_input(strtolower($_REQUEST['q']), false); - $sql='SELECT DISTINCT org.id, org.name ' - .' FROM '.ORGANIZATION_TABLE.' org ' - .' LEFT JOIN '.FORM_ENTRY_TABLE.' entry ON (entry.object_type=\'O\' AND entry.object_id = org.id) - LEFT JOIN '.FORM_ANSWER_TABLE.' value ON (value.entry_id=entry.id) ' - .' WHERE org.name LIKE \'%'.$escaped.'%\' OR value.value LIKE \'%'.$escaped.'%\'' - .' ORDER BY org.created ' - .' LIMIT '.$limit; - - if(($res=db_query($sql)) && db_num_rows($res)){ - while(list($id, $name)=db_fetch_row($res)) { - $orgs[] = array('name' => Format::htmlchars($name), 'info' => $name, - 'id' => $id, '/bin/true' => $_REQUEST['q']); - } + + $orgs = Organization::objects() + ->values_flat('id', 'name') + ->limit($limit); + + global $ost; + $orgs = $ost->searcher->find($q, $orgs); + $orgs->order_by(new SqlCode('__relevance__'), QuerySet::DESC) + ->distinct('id'); + + if (!count($orgs) && substr($q, strlen($q)-1) != '*') { + // Do wildcard full-text search + $_REQUEST['q'] = $q."*"; + return $this->search($type); + } + + $matched = array(); + foreach ($orgs as $O) { + list($id, $name) = $O; + $matched[] = array('name' => Format::htmlchars($name), 'info' => $name, + 'id' => $id, '/bin/true' => $_REQUEST['q']); } - return $this->json_encode(array_values($orgs)); + return $this->json_encode(array_values($matched)); } diff --git a/include/ajax.tickets.php b/include/ajax.tickets.php index 36e0f6aa8dd209427c2d896621be6985c05ffdfc..30a8b74d74075d8e044f80d5a07ac733adfbc94e 100644 --- a/include/ajax.tickets.php +++ b/include/ajax.tickets.php @@ -26,9 +26,6 @@ class TicketsAjaxAPI extends AjaxController { function lookup() { global $thisstaff; - if(!is_numeric($_REQUEST['q'])) - return self::lookupByEmail(); - $limit = isset($_REQUEST['limit']) ? (int) $_REQUEST['limit']:25; $tickets=array(); @@ -41,60 +38,52 @@ class TicketsAjaxAPI extends AjaxController { $visibility->add(array('dept_id__in' => $depts)); } - $hits = TicketModel::objects() - ->filter(Q::any(array( - 'number__startswith' => $_REQUEST['q'], - ))) ->filter($visibility) - ->values('number', 'user__emails__address') - ->annotate(array('tickets' => SqlAggregate::COUNT('ticket_id'))) - ->order_by('-created') + ->values('user__default_email__address') + ->annotate(array( + 'number' => new SqlCode('null'), + 'tickets' => SqlAggregate::COUNT('ticket_id', true))) ->limit($limit); - foreach ($hits as $T) { - $tickets[] = array('id'=>$T['number'], 'value'=>$T['number'], - 'info'=>"{$T['number']} — {$T['user__emails__address']}", - 'matches'=>$_REQUEST['q']); + $q = $_REQUEST['q']; + // Drop at sign in email addresses + $q = str_replace('@', ' ', $q); + + global $ost; + $hits = $ost->searcher->find($q, $hits) + ->order_by(new SqlCode('__relevance__'), QuerySet::DESC); + + if (preg_match('/\d{2,}[^*]/', $q, $T = array())) { + $hits = TicketModel::objects() + ->values('user__default_email__address', 'number') + ->annotate(array( + 'tickets' => new SqlCode('1'), + '__relevance__' => new SqlCode(1) + )) + ->filter($visibility) + ->filter(array('number__startswith' => $q)) + ->limit($limit) + ->union($hits); } - if (!$tickets) - return self::lookupByEmail(); - - return $this->json_encode($tickets); - } - - function lookupByEmail() { - global $thisstaff; - - - $limit = isset($_REQUEST['limit']) ? (int) $_REQUEST['limit']:25; - $tickets=array(); - - $visibility = Q::any(array( - 'staff_id' => $thisstaff->getId(), - 'team_id__in' => $thisstaff->teams->values_flat('team_id'), - )); - if (!$thisstaff->showAssignedOnly() && ($depts=$thisstaff->getDepts())) { - $visibility->add(array('dept_id__in' => $depts)); + elseif (!count($hits) && $q[strlen($q)-1] != '*') { + // Do wild-card fulltext search + $_REQUEST['q'] = $q.'*'; + return $this->lookup(); } - $hits = TicketModel::objects() - ->filter(Q::any(array( - 'user__emails__address__contains' => $_REQUEST['q'], - 'user__name__contains' => $_REQUEST['q'], - 'user__account__username' => $_REQUEST['q'], - 'user__org__name__contains' => $_REQUEST['q'], - ))) - ->filter($visibility) - ->values('user__emails__address') - ->annotate(array('tickets' => SqlAggregate::COUNT('ticket_id'))) - ->limit($limit); - foreach ($hits as $T) { - $email = $T['user__emails__address']; + $email = $T['user__default_email__address']; $count = $T['tickets']; - $tickets[] = array('email'=>$email, 'value'=>$email, - 'info'=>"$email ($count)", 'matches'=>$_REQUEST['q']); + if ($T['number']) { + $tickets[] = array('id'=>$T['number'], 'value'=>$T['number'], + 'info'=>"{$T['number']} — {$email}", + 'matches'=>$_REQUEST['q']); + } + else { + $tickets[] = array('email'=>$email, 'value'=>$email, + 'info'=>"$email ($count)", 'matches'=>$_REQUEST['q']); + } } return $this->json_encode($tickets); diff --git a/include/ajax.users.php b/include/ajax.users.php index 6cfed17c8c09ddc6d2c0ccffe7bc9f5822e7bf53..0dd6da74b3384f14d16800c6c36ca657220cf7d6 100644 --- a/include/ajax.users.php +++ b/include/ajax.users.php @@ -29,54 +29,61 @@ class UsersAjaxAPI extends AjaxController { Http::response(400, __('Query argument is required')); } + $q = $_REQUEST['q']; $limit = isset($_REQUEST['limit']) ? (int) $_REQUEST['limit']:25; $users=array(); $emails=array(); if (!$type || !strcasecmp($type, 'remote')) { - foreach (AuthenticationBackend::searchUsers($_REQUEST['q']) as $u) { + foreach (AuthenticationBackend::searchUsers($q) as $u) { $name = new UsersName(array('first' => $u['first'], 'last' => $u['last'])); $users[] = array('email' => $u['email'], 'name'=>(string) $name, 'info' => "{$u['email']} - $name (remote)", - 'id' => "auth:".$u['id'], "/bin/true" => $_REQUEST['q']); + 'id' => "auth:".$u['id'], "/bin/true" => $q); $emails[] = $u['email']; } } if (!$type || !strcasecmp($type, 'local')) { - $remote_emails = ($emails = array_filter($emails)) - ? ' OR email.address IN ('.implode(',',db_input($emails)).') ' - : ''; - - $q = str_replace(' ', '%', $_REQUEST['q']); - $escaped = db_input($q, false); - $sql='SELECT DISTINCT user.id, email.address, name ' - .' FROM '.USER_TABLE.' user ' - .' JOIN '.USER_EMAIL_TABLE.' email ON user.id = email.user_id ' - .' LEFT JOIN '.FORM_ENTRY_TABLE.' entry ON (entry.object_type=\'U\' AND entry.object_id = user.id) - LEFT JOIN '.FORM_ANSWER_TABLE.' value ON (value.entry_id=entry.id) ' - .' WHERE email.address LIKE \'%'.$escaped.'%\' - OR user.name LIKE \'%'.$escaped.'%\' - OR value.value LIKE \'%'.$escaped.'%\''.$remote_emails - .' LIMIT '.$limit; - - if(($res=db_query($sql)) && db_num_rows($res)){ - while(list($id,$email,$name)=db_fetch_row($res)) { - foreach ($users as $i=>$u) { - if ($u['email'] == $email) { - unset($users[$i]); - break; - } + + $users = User::objects() + ->values_flat('id', 'emails__address', 'name') + ->limit($limit); + + global $ost; + $users = $ost->searcher->find($q, $users); + $users->order_by(new SqlCode('__relevance__'), QuerySet::DESC) + ->distinct('id'); + + if (!count($emails) && !count($users) && substr($q, strlen($q)-1) != '*') { + // Do wildcard full-text search + $_REQUEST['q'] = $q."*"; + return $this->search($type); + } + + if ($emails = array_filter($emails)) { + $users->chain(User::objects()->filter(array( + 'emails__address__in' => $emails + ))); + } + + $matches = array(); + foreach ($users as $U) { + list($id,$email,$name) = $U; + foreach ($matches as $i=>$u) { + if ($u['email'] == $email) { + unset($matches[$i]); + break; } - $name = Format::htmlchars(new UsersName($name)); - $users[] = array('email'=>$email, 'name'=>$name, 'info'=>"$email - $name", - "id" => $id, "/bin/true" => $_REQUEST['q']); } + $name = Format::htmlchars(new UsersName($name)); + $matches[] = array('email'=>$email, 'name'=>$name, 'info'=>"$email - $name", + "id" => $id, "/bin/true" => $_REQUEST['q']); } - usort($users, function($a, $b) { return strcmp($a['name'], $b['name']); }); + usort($matches, function($a, $b) { return strcmp($a['name'], $b['name']); }); } - return $this->json_encode(array_values($users)); + return $this->json_encode(array_values($matches)); } diff --git a/include/class.search.php b/include/class.search.php index 28a5633c1f63baa28db8c600225e71f37ab11671..07f0688946697d86528477005a351eb49dee467b 100644 --- a/include/class.search.php +++ b/include/class.search.php @@ -41,7 +41,7 @@ abstract class SearchBackend { ); abstract function update($model, $id, $content, $new=false, $attrs=array()); - abstract function find($query, QuerySet $criteria); + abstract function find($query, QuerySet $criteria, $addRelevance=true); static function register($backend=false) { $backend = $backend ?: get_called_class(); @@ -76,9 +76,9 @@ class SearchInterface { $this->bootstrap(); } - function find($query, QuerySet $criteria) { + function find($query, QuerySet $criteria, $addRelevance=true) { $query = Format::searchable($query); - return $this->backend->find($query, $criteria); + return $this->backend->find($query, $criteria, $addRelevance); } function update($model, $id, $content, $new=false, $attrs=array()) { @@ -324,7 +324,7 @@ class MysqlSearchBackend extends SearchBackend { return implode(' ', $results); } - function find($query, QuerySet $criteria) { + function find($query, QuerySet $criteria, $addRelevance=true) { global $thisstaff; $criteria = clone $criteria; @@ -333,26 +333,57 @@ class MysqlSearchBackend extends SearchBackend { // If using boolean operators, search in boolean mode. This regex // will ensure proper placement of operators, whitespace, and quotes // in an effort to avoid crashing the query at MySQL - if (preg_match('/^(?:[(+<>~-]*(\w+[*]?|"[^"]+")[)]?(\s+|$))+$/u', $query, $T = array())) + $query = $this->quote($query); + if (preg_match('/(?=(?:^|\s)[()"+<>~-])\s*(?:[(+<>~-]*(\w+[*]?|"[^"]+")[)]?(\s+|$))+$|\w[*]/u', $query, $T = array())) $mode = ' IN BOOLEAN MODE'; #elseif (count(explode(' ', $query)) == 1) # $mode = ' WITH QUERY EXPANSION'; - $query = $this->quote($query); $search = 'MATCH (Z1.title, Z1.content) AGAINST ('.db_input($query).$mode.')'; switch ($criteria->model) { case false: case 'TicketModel': + if ($addRelevance) { + $criteria = $criteria->extra(array( + 'select' => array( + '__relevance__' => 'Z1.`relevance`', + ), + )); + } + $criteria->extra(array( + 'tables' => array( + str_replace(array(':', '{}'), array(TABLE_PREFIX, $search), + "(SELECT COALESCE(Z3.`object_id`, Z5.`ticket_id`, Z8.`ticket_id`) as `ticket_id`, {} AS `relevance` FROM `:_search` Z1 LEFT JOIN `:thread_entry` Z2 ON (Z1.`object_type` = 'H' AND Z1.`object_id` = Z2.`id`) LEFT JOIN `:thread` Z3 ON (Z2.`thread_id` = Z3.`id` AND Z3.`object_type` = 'T') LEFT JOIN `:ticket` Z5 ON (Z1.`object_type` = 'T' AND Z1.`object_id` = Z5.`ticket_id`) LEFT JOIN `:user` Z6 ON (Z6.`id` = Z1.`object_id` and Z1.`object_type` = 'U') LEFT JOIN `:organization` Z7 ON (Z7.`id` = Z1.`object_id` AND Z7.`id` = Z6.`org_id` AND Z1.`object_type` = 'O') LEFT JOIN :ticket Z8 ON (Z8.`user_id` = Z6.`id`) WHERE {}) Z1"), + ) + )); + $criteria->filter(array('ticket_id'=>new SqlCode('Z1.`ticket_id`'))); + break; + + case 'User': $criteria->extra(array( 'select' => array( '__relevance__' => 'Z1.`relevance`', ), 'tables' => array( str_replace(array(':', '{}'), array(TABLE_PREFIX, $search), - "(SELECT COALESCE(Z3.`object_id`, Z5.`ticket_id`) as `ticket_id`, {} AS `relevance` FROM `:_search` Z1 LEFT JOIN `:thread_entry` Z2 ON (Z1.`object_type` = 'H' AND Z1.`object_id` = Z2.`id`) LEFT JOIN `:thread` Z3 ON (Z2.`thread_id` = Z3.`id` AND Z3.`object_type` = 'T') LEFT JOIN `:ticket` Z5 ON (Z1.`object_type` = 'T' AND Z1.`object_id` = Z5.`ticket_id`) WHERE {}) Z1"), + "(SELECT Z6.`id` as `user_id`, {} AS `relevance` FROM `:_search` Z1 LEFT JOIN `:user` Z6 ON (Z6.`id` = Z1.`object_id` and Z1.`object_type` = 'U') LEFT JOIN `:organization` Z7 ON (Z7.`id` = Z1.`object_id` AND Z7.`id` = Z6.`org_id` AND Z1.`object_type` = 'O') WHERE {}) Z1"), ) )); - $criteria->filter(array('ticket_id'=>new SqlCode('Z1.`ticket_id`')))->distinct('ticket_id'); + $criteria->filter(array('id'=>new SqlCode('Z1.`user_id`'))); + break; + + case 'Organization': + $criteria->extra(array( + 'select' => array( + '__relevance__' => 'Z1.`relevance`', + ), + 'tables' => array( + str_replace(array(':', '{}'), array(TABLE_PREFIX, $search), + "(SELECT Z2.`id` as `org_id`, {} AS `relevance` FROM `:_search` Z1 LEFT JOIN `:organization` Z2 ON (Z2.`id` = Z1.`object_id` AND Z1.`object_type` = 'O') WHERE {}) Z1"), + ) + )); + $criteria->filter(array('id'=>new SqlCode('Z1.`org_id`'))); + break; } // TODO: Ensure search table exists; @@ -1121,11 +1152,11 @@ class TicketFlagChoiceField extends ChoiceField { class TicketSourceChoiceField extends ChoiceField { function getChoices() { return array( - 'w' => __('Web'), - 'e' => __('Email'), - 'p' => __('Phone'), - 'a' => __('API'), - 'o' => __('Other'), + 'web' => __('Web'), + 'email' => __('Email'), + 'phone' => __('Phone'), + 'api' => __('API'), + 'other' => __('Other'), ); } diff --git a/include/staff/tickets.inc.php b/include/staff/tickets.inc.php index 171675afce0c2498e615c06f273f120aa20db8db..a388e5cad52f243bf16189a66e3b0df7f45eb49f 100644 --- a/include/staff/tickets.inc.php +++ b/include/staff/tickets.inc.php @@ -81,54 +81,42 @@ case 'search': if ($_REQUEST['query']) { $results_type=__('Search Results'); // Use an index if possible - if ($_REQUEST['search-type'] == 'typeahead' && Validator::is_email($_REQUEST['query'])) { - $tickets = $tickets->filter(array( - 'user__emails__address' => $_REQUEST['query'], - )); + if ($_REQUEST['search-type'] == 'typeahead') { + if (Validator::is_email($_REQUEST['query'])) { + $tickets = $tickets->filter(array( + 'user__emails__address' => $_REQUEST['query'], + )); + } + elseif ($_REQUEST['query']) { + $tickets = $tickets->filter(array( + 'number' => $_REQUEST['query'], + )); + } } - else { - $basic_search = Q::any(array( - 'number__startswith' => $_REQUEST['query'], - 'user__name__contains' => $_REQUEST['query'], - 'user__emails__address__contains' => $_REQUEST['query'], - 'user__org__name__contains' => $_REQUEST['query'], - )); - $tickets->filter($basic_search); - if (!$_REQUEST['search-type']) { - // [Search] click, consider keywords too. This is a - // relatively ugly hack. SearchBackend::find() add in a - // constraint for the search. We need to pop that off and - // include it as an OR with the above constraints - $keywords = TicketModel::objects(); - $keywords->extra(array('select' => array('ticket_id' => 'Z1.ticket_id'))); - $keywords = $ost->searcher->find($_REQUEST['query'], $keywords); - $tickets->values('ticket_id')->annotate(array('__relevance__' => new SqlCode(0.5))); - $keywords->aggregated = true; // Hack to prevent select ticket.* - $tickets->union($keywords)->order_by(new SqlCode('__relevance__'), QuerySet::DESC); + elseif ($_REQUEST['query']) { + // [Search] click, consider keywords + $__tickets = $ost->searcher->find($_REQUEST['query'], $tickets); + if (!count($__tickets)) { + // Do wildcard search if no hits + $__tickets = $ost->searcher->find($_REQUEST['query'].'*', $tickets); } + $tickets = $__tickets->distinct('ticket_id'); + $has_relevance = true; + } + if (count($tickets) == 1) { + // Redirect to ticket page } // Clear sticky search queue unset($_SESSION[$queue_key]); break; - } elseif (isset($_SESSION['advsearch'])) { - $form = $search->getFormFromSession('advsearch'); - $tickets = $search->mangleQuerySet($tickets, $form); - $view_all_tickets = $thisstaff->hasPerm(SearchBackend::PERM_EVERYTHING); - $results_type=__('Advanced Search') - . '<a class="action-button" style="font-size: 15px;" href="?clear_filter"><i style="top:0" class="icon-ban-circle"></i> <em>' . __('clear') . '</em></a>'; - foreach ($form->getFields() as $sf) { - if ($sf->get('name') == 'keywords' && $sf->getClean()) { - $has_relevance = true; - break; - } - } - break; } // Apply user filter elseif (isset($_GET['uid']) && ($user = User::lookup($_GET['uid']))) { $tickets->filter(array('user__id'=>$_GET['uid'])); $results_type = sprintf('%s — %s', __('Search Results'), $user->getName()); + if (isset($_GET['status'])) + $status = $_GET['status']; // Don't apply normal open ticket break; } @@ -136,8 +124,23 @@ case 'search': $tickets->filter(array('user__org_id'=>$_GET['orgid'])); $results_type = sprintf('%s — %s', __('Search Results'), $org->getName()); + if (isset($_GET['status'])) + $status = $_GET['status']; // Don't apply normal open ticket break; + } elseif (isset($_SESSION['advsearch'])) { + $form = $search->getFormFromSession('advsearch'); + $tickets = $search->mangleQuerySet($tickets, $form); + $view_all_tickets = $thisstaff->hasPerm(SearchBackend::PERM_EVERYTHING); + $results_type=__('Advanced Search') + . '<a class="action-button" style="font-size: 15px;" href="?clear_filter"><i style="top:0" class="icon-ban-circle"></i> <em>' . __('clear') . '</em></a>'; + foreach ($form->getFields() as $sf) { + if ($sf->get('name') == 'keywords' && $sf->getClean()) { + $has_relevance = true; + break; + } + } + break; } // Fall-through and show open tickets case 'open': diff --git a/scp/js/bootstrap-typeahead.js b/scp/js/bootstrap-typeahead.js index d53630e4bcaa48f6295c813bb0d6d6bba36131cf..fffa189ecb1875a847f5c174b8fa676016d605bb 100644 --- a/scp/js/bootstrap-typeahead.js +++ b/scp/js/bootstrap-typeahead.js @@ -158,9 +158,8 @@ , highlighter: function (item) { if (!this.query) return item; - return item.replace(new RegExp('(' + this.query + ')', 'ig'), function ($1, match) { - return '<strong>' + match + '</strong>' - }) + var exp = this.query.replace(/[.?*+^$[\]\\(){}|-]/g, "\\$&").replace(' ', '|') + return item.replace(new RegExp(exp, 'ig'), '<strong>$&</strong>') } , render: function (items) { diff --git a/scp/js/scp.js b/scp/js/scp.js index 705388edb44172b5fe4e7b66ca97f29d7e360f82..94b3b699371c1208ddb25932dbeeef6bdfb25c5e 100644 --- a/scp/js/scp.js +++ b/scp/js/scp.js @@ -256,7 +256,7 @@ var scp_prep = function() { source: function (typeahead, query) { if (last_req) last_req.abort(); var $el = this.$element; - var url = $el.data('url')+'?q='+query; + var url = $el.data('url')+'?q='+encodeURIComponent(query); last_req = $.ajax({ url: url, dataType: 'json', @@ -746,7 +746,7 @@ $.confirm = function(message, title, options) { .append($('<input type="button" class="close"/>') .attr('value', __('Cancel')) .click(function() { hide(); }) - )).append($('<span class="buttons pull-right">test</span>') + )).append($('<span class="buttons pull-right"></span>') .append($('<input type="button"/>') .attr('value', __('OK')) .click(function() { hide(); D.resolve(body.find('input').serializeArray()); }) diff --git a/scp/tickets.php b/scp/tickets.php index 8ae7ca004d88ae3b22d1e93d1013bd006aa516fc..e4c1fc10345bcd549ed8ca1e2fbbcbfa862f17aa 100644 --- a/scp/tickets.php +++ b/scp/tickets.php @@ -37,9 +37,10 @@ if($_REQUEST['id']) { if ($_REQUEST['uid']) { $user = User::lookup($_REQUEST['uid']); -} elseif (!$ticket) { +} +if (!$ticket) { $queue_key = sprintf('::Q:%s', ObjectModel::OBJECT_TYPE_TICKET); - $queue_name = strtolower($_GET['status'] ?: $_GET['a']); //Status is overloaded + $queue_name = strtolower($_GET['a'] ?: $_GET['status']); //Status is overloaded if (!$queue_name && isset($_SESSION[$queue_key])) $queue_name = $_SESSION[$queue_key];