diff --git a/bootstrap.php b/bootstrap.php index c53935b13d0eb8a61ac362a29de77a6ef9546e61..03007ba9b7f9f9e6db4d97afecc6a13560188833 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -194,6 +194,7 @@ class Bootstrap { require(INCLUDE_DIR.'class.mailer.php'); require_once INCLUDE_DIR.'mysqli.php'; require_once INCLUDE_DIR.'class.i18n.php'; + require_once INCLUDE_DIR.'class.search.php'; } function i18n_prep() { diff --git a/include/ajax.tickets.php b/include/ajax.tickets.php index 18d49aca3217d362a090e35d02712354de4ca4ae..5834b2377744ebbba350aa6a50947e1e56c7d4af 100644 --- a/include/ajax.tickets.php +++ b/include/ajax.tickets.php @@ -98,7 +98,7 @@ class TicketsAjaxAPI extends AjaxController { } function _search($req) { - global $thisstaff, $cfg; + global $thisstaff, $cfg, $ost; $result=array(); $select = 'SELECT ticket.ticket_id'; @@ -189,27 +189,15 @@ class TicketsAjaxAPI extends AjaxController { $queryterm=db_real_escape($req['query'], false); // Setup sets of joins and queries + if ($s = $ost->searcher) + $ids = $s->find($req['query'], null, 'Ticket'); + + if (!$ids) + return array(); + $joins[] = array( - 'from' => - 'LEFT JOIN '.TICKET_THREAD_TABLE.' thread ON (ticket.ticket_id=thread.ticket_id )', - 'where' => "thread.title LIKE '%$queryterm%' OR thread.body LIKE '%$queryterm%'" - ); - $joins[] = array( - 'from' => - 'LEFT JOIN '.FORM_ENTRY_TABLE.' tentry ON (tentry.object_id = ticket.ticket_id AND tentry.object_type="T") - LEFT JOIN '.FORM_ANSWER_TABLE.' tans ON (tans.entry_id = tentry.id AND tans.value_id IS NULL)', - 'where' => "tans.value LIKE '%$queryterm%'" - ); - $joins[] = array( - 'from' => - 'LEFT JOIN '.FORM_ENTRY_TABLE.' uentry ON (uentry.object_id = ticket.user_id - AND uentry.object_type="U") - LEFT JOIN '.FORM_ANSWER_TABLE.' uans ON (uans.entry_id = uentry.id - AND uans.value_id IS NULL) - LEFT JOIN '.USER_TABLE.' user ON (ticket.user_id = user.id) - LEFT JOIN '.USER_EMAIL_TABLE.' uemail ON (user.id = uemail.user_id)', - 'where' => - "uemail.address LIKE '%$queryterm%' OR user.name LIKE '%$queryterm%' OR uans.value LIKE '%$queryterm%'", + 'from' => '', + 'where' => 'ticket.ticket_id IN (' . implode(',', $ids) . ')' ); } diff --git a/include/class.dynamic_forms.php b/include/class.dynamic_forms.php index 81c33a6e8741c2932653aa90d9792cf6089bf248..93b9da001a3b9dffcaa7ae5fa1949f1046319f34 100644 --- a/include/class.dynamic_forms.php +++ b/include/class.dynamic_forms.php @@ -911,6 +911,14 @@ class DynamicFormEntryAnswer extends VerySimpleModel { return $this->getField()->display($this->getValue()); } + function getSearchable($include_label=false) { + if ($include_label) + $label = Format::searchable($this->getField()->getLabel()) . " "; + return sprintf("%s%s", $label, + $this->getField()->searchable($this->getValue()) + ); + } + function asVar() { return (is_object($this->getValue())) ? $this->getValue() : $this->toString(); diff --git a/include/class.faq.php b/include/class.faq.php index 1786c79f66e91166075dd3ae4fe19faa7083dd25..78d221c21b1e7ec2ea267da70401aa7ce57ac612 100644 --- a/include/class.faq.php +++ b/include/class.faq.php @@ -63,6 +63,10 @@ class FAQ { function getAnswerWithImages() { return Format::viewableImages($this->ht['answer'], ROOT_PATH.'image.php'); } + function getSearchableAnswer() { + return ThreadBody::fromFormattedText($this->ht['answer'], 'html') + ->getSearchable(); + } function getNotes() { return $this->ht['notes']; } function getNumAttachments() { return $this->ht['attachments']; } @@ -153,9 +157,10 @@ class FAQ { if($ids) $sql.=' AND topic_id NOT IN('.implode(',', db_input($ids)).')'; - db_query($sql); + if (!db_query($sql)) + return false; - return true; + Signal::send('model.updated', $this); } function update($vars, &$errors) { @@ -186,6 +191,7 @@ class FAQ { $this->reload(); + Signal::send('model.updated', $this); return true; } @@ -319,8 +325,10 @@ class FAQ { } else { $sql='INSERT INTO '.FAQ_TABLE.' SET '.$sql.',created=NOW()'; - if(db_query($sql) && ($id=db_insert_id())) + if (db_query($sql) && ($id=db_insert_id())) { + Signal::send('model.created', FAQ::lookup($id)); return $id; + } $errors['err']=sprintf(__('Unable to create %s.'), __('this FAQ article')) .' '.__('Internal error occurred'); diff --git a/include/class.format.php b/include/class.format.php index 0aa52abccbd6bdf91e98aa136b0bfd473fa9085d..d0e4ba203ecac6ff329045fa43be9d1c51bc307e 100644 --- a/include/class.format.php +++ b/include/class.format.php @@ -569,5 +569,58 @@ class Format { ); } + // Performs Unicode normalization (where possible) and splits words at + // difficult word boundaries (for far eastern languages) + function searchable($text) { + if (function_exists('normalizer_normalize')) { + // Normalize text input :: remove diacritics and such + $text = normalizer_normalize($text, Normalizer::FORM_C); + } + else { + // As a lightweight compatiblity, use a lightweight C + // normalizer with diacritic removal, thanks + // http://ahinea.com/en/tech/accented-translate.html + $tr = array( + "ä" => "a", "ñ" => "n", "ö" => "o", "ü" => "u", "ÿ" => "y" + ); + $text = strtr($text, $tr); + } + // Decompose compatible versions of characters (ä => ae) + $tr = array( + "ß" => "ss", "Æ" => "AE", "æ" => "ae", "IJ" => "IJ", + "ij" => "ij", "Å’" => "OE", "Å“" => "oe", "Ã" => "D", + "Ä" => "D", "ð" => "d", "Ä‘" => "d", "Ħ" => "H", "ħ" => "h", + "ı" => "i", "ĸ" => "k", "Ä¿" => "L", "Å" => "L", "Å€" => "l", + "Å‚" => "l", "ÅŠ" => "N", "ʼn" => "n", "Å‹" => "n", "Ø" => "O", + "ø" => "o", "Å¿" => "s", "Þ" => "T", "Ŧ" => "T", "þ" => "t", + "ŧ" => "t", "ä" => "ae", "ö" => "oe", "ü" => "ue", + "Ä" => "AE", "Ö" => "OE", "Ü" => "UE", + ); + $text = strtr($text, $tr); + + // Drop separated diacritics + $text = preg_replace('/\p{M}/u', '', $text); + + // Drop extraneous whitespace + $text = preg_replace('/(\s)\s+/u', '$1', $text); + + // Drop leading and trailing whitespace + $text = trim($text); + + if (class_exists('IntlBreakIterator')) { + // Split by word boundaries + if ($tokenizer = IntlBreakIterator::createWordInstance()) { + $tokenizer->setText($text); + $text = implode(' ', $tokenizer); + } + } + else { + // Approximate word boundaries from Unicode chart at + // http://www.unicode.org/reports/tr29/#Word_Boundaries + + // Punt for now + } + return $text; + } } ?> diff --git a/include/class.forms.php b/include/class.forms.php index 8611145013804cfa606abf957a59c6c137066f1b..ebc828bb252106c6b69c114dd4d50c1888a36738 100644 --- a/include/class.forms.php +++ b/include/class.forms.php @@ -338,6 +338,10 @@ class FormField { return $this->toString($this->getClean()); } + function searchable($value) { + return Format::searchable($this->toString($value)); + } + function getLabel() { return $this->get('label'); } /** @@ -621,6 +625,12 @@ class TextareaField extends FormField { return nl2br(Format::htmlchars($value)); } + function searchable($value) { + $value = preg_replace(array('`<br(\s*)?/?>`i', '`</div>`i'), "\n", $value); + $value = Format::htmldecode(Format::striptags($value)); + return Format::searchable($value); + } + function export($value) { return (!$value) ? $value : Format::html2text($value); } @@ -987,6 +997,11 @@ class PriorityField extends ChoiceField { return ($value instanceof Priority) ? $value->getDesc() : $value; } + function searchable($value) { + // Priority isn't searchable this way + return null; + } + function getConfigurationOptions() { return array( 'prompt' => new TextboxField(array( diff --git a/include/class.organization.php b/include/class.organization.php index 37cedebdf2dc395f13c06f0c857b9d50c2495e20..617c480311394d5ca6ba0e568cc01cd61aa34cca 100644 --- a/include/class.organization.php +++ b/include/class.organization.php @@ -318,6 +318,11 @@ class Organization extends OrganizationModel { } } + // Send signal for search engine updating if not modifying the + // fields specific to the organization + if (count($this->dirty) === 0) + Signal::send('model.updated', $this); + return $this->save(); } diff --git a/include/class.osticket.php b/include/class.osticket.php index 9f8870547699b8b2ac8d67fa4fc933921fe24c32..e3901f25d34b1748db66f4a2fa416e9c786c5a87 100644 --- a/include/class.osticket.php +++ b/include/class.osticket.php @@ -457,6 +457,9 @@ class osTicket { // Bootstrap installed plugins $ost->plugins->bootstrap(); + // Mirror content updates to the search backend + $ost->searcher = new SearchInterface(); + return $ost; } } diff --git a/include/class.search.php b/include/class.search.php new file mode 100644 index 0000000000000000000000000000000000000000..0d1a210fac10ef4005ed6cb003b4314945976025 --- /dev/null +++ b/include/class.search.php @@ -0,0 +1,517 @@ +<?php +/********************************************************************* + module.search.php + + Search Engine for osTicket + + This module defines the pieces for a search engine for osTicket. + Searching can be performed by various search engine backends which can + make use of the features of various search providers. + + A reference search engine backend is provided which uses MySQL MyISAM + tables. This default backend should not be used on Galera clusters. + + Jared Hancock <jared@osticket.com> + Peter Rotich <peter@osticket.com> + Copyright (c) 2006-2013 osTicket + http://www.osticket.com + + Released under the GNU General Public License WITHOUT ANY WARRANTY. + See LICENSE.TXT for details. + + vim: expandtab sw=4 ts=4 sts=4: +**********************************************************************/ + +abstract class SearchBackend { + static $id = false; + static $registry = array(); + + const SORT_RELEVANCE = 1; + const SORT_RECENT = 2; + const SORT_OLDEST = 3; + + abstract function update($model, $id, $content, $new=false, $attrs=array()); + abstract function find($query, $criteria, $model=false, $sort=array()); + + function register($backend=false) { + $backend = $backend ?: get_called_class(); + + if ($backend::$id == false) + throw new Exception('SearchBackend must define an ID'); + + static::$registry[$backend::$id] = $backend; + } + + function getInstance($id) { + if (!isset(self::$registry[$id])) + return null; + + return new self::$registry[$id](); + } +} + +// Register signals to intercept saving of various content throughout the +// system + +class SearchInterface { + + var $backend; + + function __construct() { + $this->bootstrap(); + } + + function find($query, $criteria, $model=false, $sort=array()) { + return $this->backend->find($query, $criteria, $model, $sort); + } + + function update($model, $id, $content, $new=false, $attrs=array()) { + if (!$this->backend) + return; + + $this->backend->update($model, $id, $content, $new, $attrs); + } + + function createModel($model) { + return $this->updateModel($model, true); + } + + function updateModel($model, $new=false) { + // The MySQL backend does not need to index attributes of the + // various models, because those other attributes are available in + // the local database in other tables. + switch (true) { + case $model instanceof ThreadEntry: + // Only index an entry for threads if a human created the + // content + if (!$model->getUserId() && !$model->getStaffId()) + break; + + $this->update($model, $model->getId(), + $model->getBody()->getSearchable(), $new, + array( + 'title' => $model->getTitle(), + 'ticket_id' => $model->getTicketId(), + 'created' => $model->getCreateDate(), + ) + ); + break; + + case $model instanceof Ticket: + $cdata = array(); + foreach ($model->loadDynamicData() as $a) + if ($v = $a->getSearchable()) + $cdata[] = $v; + $this->update($model, $model->getId(), + trim(implode("\n", $cdata)), + $new, + array( + 'title'=> Format::searchable($model->getSubject()), + 'status'=> $model->getStatus(), + 'topic_id'=> $model->getTopicId(), + 'priority_id'=> $model->getPriorityId(), + // Stats (comments, attachments) + // Access constraints + 'dept_id'=> $model->getDeptId(), + 'staff_id'=> $model->getStaffId(), + 'team_id'=> $model->getTeamId(), + // Sorting and ranging preferences + 'created'=> $model->getCreateDate(), + // Add last-updated timestamp + ) + ); + break; + + case $model instanceof User: + $cdata = array(); + foreach ($model->getDynamicData() as $e) + foreach ($e->getAnswers() as $tag=>$a) + if ($tag != 'subject' && ($v = $a->getSearchable())) + $cdata[] = $v; + $this->update($model, $model->getId(), + trim(implode("\n", $cdata)), + $new, + array( + 'title'=> Format::searchable($model->getFullName()), + 'emails'=> $model->emails->asArray(), + 'org_id'=> $model->getOrgId(), + 'created'=> $model->getCreateDate(), + ) + ); + break; + + case $model instanceof Organization: + $cdata = array(); + foreach ($model->getDynamicData() as $e) + foreach ($e->getAnswers() as $a) + if ($v = $a->getSearchable()) + $cdata[] = $v; + $this->update($model, $model->getId(), + trim(implode("\n", $cdata)), + $new, + array( + 'title'=> Format::searchable($model->getName()), + 'created'=> $model->getCreateDate(), + ) + ); + break; + + case $model instanceof FAQ: + $this->update($model, $model->getId(), + $model->getSearchableAnswer(), + $new, + array( + 'title'=> Format::searchable($model->getQuestion()), + 'keywords'=> $model->getKeywords(), + 'topics'=> $model->getHelpTopicsIds(), + 'category_id'=> $model->getCategoryId(), + 'created'=> $model->getCreateDate(), + ) + ); + break; + + default: + // Not indexed + break; + } + } + + function bootstrap() { + // Determine the backend + if (defined('SEARCH_BACKEND')) + $bk = SearchBackend::getInstance(SEARCH_BACKEND); + + if (!$bk && !($bk = SearchBackend::getInstance('mysql'))) + // No backend registered or defined + return false; + + $this->backend = $bk; + $this->backend->bootstrap(); + + $self = $this; + + // Thread entries + // Tickets, which can be edited as well + // Knowledgebase articles (FAQ and canned responses) + // Users, organizations + Signal::connect('model.created', array($this, 'createModel')); + Signal::connect('model.updated', array($this, 'updateModel')); + Signal::connect('model.deleted', array($this, 'deleteModel')); + } +} + +class MysqlSearchBackend extends SearchBackend { + static $id = 'mysql'; + static $BATCH_SIZE = 30; + + // Only index 20 batches per cron run + var $max_batches = 60; + + function __construct() { + $this->SEARCH_TABLE = TABLE_PREFIX . '_search'; + } + + function bootstrap() { + Signal::connect('cron', array($this, 'IndexOldStuff')); + } + + function update($model, $id, $content, $new=false, $attrs=array()) { + switch (true) { + case $model instanceof ThreadEntry: + $type = 'H'; + break; + case $model instanceof Ticket: + $type = 'T'; + break; + case $model instanceof User: + $content .= implode("\n", $attrs['emails']); + $type = 'U'; + break; + case $model instanceof Organization: + $type = 'O'; + break; + case $model instanceof FAQ: + $type = 'K'; + break; + case $model instanceof AttachmentFile: + $type = 'F'; + break; + default: + // Not indexed + return; + } + + $title = $attrs['title'] ?: ''; + + if (!$content && !$title) + return; + + $sql = 'REPLACE INTO '.$this->SEARCH_TABLE + . ' SET object_type='.db_input($type) + . ', object_id='.db_input($id) + . ', content='.db_input($content) + . ', title='.db_input($title); + return db_query($sql); + } + + function find($query, $criteria=array(), $model=false, $sort=array()) { + global $thisstaff; + + $tables = array(); + $mode = ' IN BOOLEAN MODE'; + #if (count(explode(' ', $query)) == 1) + # $mode = ' WITH QUERY EXPANSION'; + $where = array('MATCH (search.title, search.content) AGAINST (' + .db_input($query) + .$mode.') '); + $fields = array($where[0] . ' AS `relevance`'); + + switch ($model) { + case false: + case 'Ticket': + $tables[] = ORGANIZATION_TABLE . " A4 ON (A4.id = `search`.object_id + AND `search`.object_type = 'O')"; + $tables[] = USER_TABLE . " A3 ON ((A3.id = `search`.object_id + AND `search`.object_type = 'U') OR A3.org_id = A4.id)"; + $tables[] = TICKET_THREAD_TABLE . " A2 ON (A2.id = `search`.object_id + AND `search`.object_type = 'H')"; + $tables[] = TICKET_TABLE . " A1 ON ((A1.ticket_id = `search`.object_id + AND `search`.object_type='T') + OR (A4.id = A3.org_id AND A1.user_id = A3.id) + OR A3.id = A1.user_id + OR A2.ticket_id = A1.ticket_id)"; + $fields[] = 'A1.`ticket_id`'; + $key = 'ticket_id'; + + if ($criteria) { + foreach ($criteria as $name=>$value) { + switch ($name) { + case 'status': + case 'topic_id': + case 'staff_id': + case 'dept_id': + case 'user_id': + $where[] = sprintf('A1.%s = %s', $name, db_input($value)); + break; + case 'email': + case 'org_id': + case 'form_id': + } + } + } + + // Always consider the current staff's access + $thisstaff->getDepts(); + $access = array(); + $access[] = '(A1.staff_id=' . db_input($thisstaff->getId()) + .' AND A1.status="open")'; + + if (!$thisstaff->showAssignedOnly() && ($depts=$thisstaff->getDepts())) + $access[] = 'A1.dept_id IN (' + . ($depts ? implode(',', db_input($depts)) : 0) + . ')'; + + if (($teams = $thisstaff->getTeams()) && count(array_filter($teams))) + $access[] = 'A1.team_id IN (' + .implode(',', db_input(array_filter($teams))) + .') AND A1.status="open"'; + + $where[] = '(' . implode(' OR ', $access) . ')'; + + $sql = 'SELECT DISTINCT ' + . $key + . ' FROM ( SELECT ' + . implode(', ', $fields) + . ' FROM `'.TABLE_PREFIX.'_search` `search` ' + . (count($tables) ? ' LEFT JOIN ' : '') + . implode(' LEFT JOIN ', $tables) + . ' WHERE ' . implode(' AND ', $where) + // TODO: Consider sorting preferences + . ' ORDER BY `relevance` DESC' + . ') __ LIMIT 500'; + } + + $res = db_query($sql); + $object_ids = array(); + + while ($row = db_fetch_row($res)) + $object_ids[] = $row[0]; + + return $object_ids; + } + + static function createSearchTable() { + $sql = 'CREATE TABLE '.TABLE_PREFIX.'_search ( + `object_type` varchar(8) not null, + `object_id` int(11) unsigned not null, + `title` text collate utf8_general_ci, + `content` text collate utf8_general_ci, + primary key `object` (`object_type`, `object_id`), + fulltext key `search` (`title`, `content`) + ) ENGINE=MyISAM CHARSET=utf8'; + return db_query($sql); + } + + /** + * Cooperates with the cron system to automatically find content that is + * not index in the _search table and add it to the index. + */ + function IndexOldStuff() { + print 'Indexing old stuff!'; + + $class = get_class(); + $auto_create = function($db_error) use ($class) { + + if ($db_error != 1146) + // Perform the standard error handling + return true; + + // Create the search table automatically + $class::createSearchTable(); + }; + + // THREADS ---------------------------------- + + $sql = "SELECT A1.`id`, A1.`title`, A1.`body`, A1.`format` FROM `".TICKET_THREAD_TABLE."` A1 + LEFT JOIN `".TABLE_PREFIX."_search` A2 ON (A1.`id` = A2.`object_id` AND A2.`object_type`='H') + WHERE A2.`object_id` IS NULL AND (A1.poster <> 'SYSTEM') + AND (LENGTH(A1.`title`) + LENGTH(A1.`body`) > 0) + ORDER BY A1.`id` DESC"; + if (!($res = db_query_unbuffered($sql, $auto_create))) + return false; + + while ($row = db_fetch_row($res)) { + $body = ThreadBody::fromFormattedText($row[2], $row[3]); + $body = $body->getSearchable(); + $title = Format::searchable($row[1]); + if (!$body && !$title) + continue; + $records[] = array('H', $row[0], $title, $body); + if (count($records) > self::$BATCH_SIZE) + if (null === ($records = self::__searchFlush($records))) + return; + } + $records = self::__searchFlush($records); + + // TICKETS ---------------------------------- + + $sql = "SELECT A1.`ticket_id` FROM `".TICKET_TABLE."` A1 + LEFT JOIN `".TABLE_PREFIX."_search` A2 ON (A1.`ticket_id` = A2.`object_id` AND A2.`object_type`='T') + WHERE A2.`object_id` IS NULL + ORDER BY A1.`ticket_id` DESC"; + if (!($res = db_query_unbuffered($sql, $auto_create))) + return false; + + while ($row = db_fetch_row($res)) { + $ticket = Ticket::lookup($row[0]); + $cdata = $ticket->loadDynamicData(); + $content = array(); + foreach ($cdata as $k=>$a) + if ($k != 'subject' && ($v = $a->getSearchable())) + $content[] = $v; + $records[] = array('T', $ticket->getId(), + Format::searchable($ticket->getSubject()), + implode("\n", $content)); + if (count($records) > self::$BATCH_SIZE) + if (null === ($records = self::__searchFlush($records))) + return; + } + $records = self::__searchFlush($records); + + // USERS ------------------------------------ + + $sql = "SELECT A1.`id` FROM `".USER_TABLE."` A1 + LEFT JOIN `".TABLE_PREFIX."_search` A2 ON (A1.`id` = A2.`object_id` AND A2.`object_type`='U') + WHERE A2.`object_id` IS NULL + ORDER BY A1.`id` DESC"; + if (!($res = db_query_unbuffered($sql, $auto_create))) + return false; + + while ($row = db_fetch_row($res)) { + $user = User::lookup($row[0]); + $cdata = $user->getDynamicData(); + $content = array(); + foreach ($user->emails as $e) + $content[] = $e->address; + foreach ($cdata as $e) + foreach ($e->getAnswers() as $a) + if ($c = $a->getSearchable()) + $content[] = $c; + $records[] = array('U', $user->getId(), + Format::searchable($user->getFullName()), + trim(implode("\n", $content))); + if (count($records) > self::$BATCH_SIZE) + if (null === ($records = self::__searchFlush($records))) + return; + } + $records = self::__searchFlush($records); + + // ORGANIZATIONS ---------------------------- + + $sql = "SELECT A1.`id` FROM `".ORGANIZATION_TABLE."` A1 + LEFT JOIN `".TABLE_PREFIX."_search` A2 ON (A1.`id` = A2.`object_id` AND A2.`object_type`='O') + WHERE A2.`object_id` IS NULL + ORDER BY A1.`id` DESC"; + if (!($res = db_query_unbuffered($sql, $auto_create))) + return false; + + while ($row = db_fetch_row($res)) { + $org = Organization::lookup($row[0]); + $cdata = $org->getDynamicData(); + $content = array(); + foreach ($cdata as $e) + foreach ($e->getAnswers() as $a) + if ($c = $a->getSearchable()) + $content[] = $c; + $records[] = array('O', $org->getId(), + Format::searchable($org->getName()), + trim(implode("\n", $content))); + if (count($records) > self::$BATCH_SIZE) + $records = self::__searchFlush($records); + } + $records = self::__searchFlush($records); + + // KNOWLEDGEBASE ---------------------------- + + require_once INCLUDE_DIR . 'class.faq.php'; + $sql = "SELECT A1.`faq_id` FROM `".FAQ_TABLE."` A1 + LEFT JOIN `".TABLE_PREFIX."_search` A2 ON (A1.`faq_id` = A2.`object_id` AND A2.`object_type`='K') + WHERE A2.`object_id` IS NULL + ORDER BY A1.`faq_id` DESC"; + if (!($res = db_query_unbuffered($sql, $auto_create))) + return false; + + while ($row = db_fetch_row($res)) { + $faq = FAQ::lookup($row[0]); + $records[] = array('K', $faq->getId(), + Format::searchable($faq->getQuestion()), + $faq->getSearchableAnswer()); + if (count($records) > self::$BATCH_SIZE) + if (null === ($records = self::__searchFlush($records))) + return; + } + $records = self::__searchFlush($records); + + // FILES ------------------------------------ + } + + function __searchFlush($records) { + if (!$records) + return $records; + + foreach ($records as &$r) + $r = sprintf('(%s)', implode(',', db_input($r))); + unset($r); + + $sql = 'INSERT INTO `'.TABLE_PREFIX.'_search` (`object_type`, `object_id`, `title`, `content`) + VALUES '.implode(',', $records); + if (!db_query($sql) || count($records) != db_affected_rows()) + throw new Exception('Unable to index content'); + + if (!--$this->max_batches) + return null; + + return array(); + } +} +MysqlSearchBackend::register(); diff --git a/include/class.thread.php b/include/class.thread.php index 4a920df4831040c6c67d2345caab25432129afb2..ad9752d0a8572a847fb4db014d9caa2ea05e937d 100644 --- a/include/class.thread.php +++ b/include/class.thread.php @@ -1096,6 +1096,8 @@ Class ThreadEntry { // Inline images (attached to the draft) $entry->saveAttachments(Draft::getAttachmentIds($body)); + Signal::send('model.created', $entry); + return $entry; } @@ -1363,8 +1365,7 @@ class ThreadBody /* extends SplString */ { } function getSearchable() { - return $this->body; - // TODO: Normalize Unicode string + return Format::searchable($this->body); } static function fromFormattedText($text, $format=false) { @@ -1436,9 +1437,9 @@ class HtmlThreadBody extends ThreadBody { function getSearchable() { // <br> -> \n - $body = preg_replace('/\<br(\s*)?\/?\>/i', "\n", $this->body); - return Format::striptags($body); - // TODO: Normalize Unicode string + $body = preg_replace(array('`<br(\s*)?/?>`i', '`</div>`i'), "\n", $this->body); + $body = Format::htmldecode(Format::striptags($body)); + return Format::searchable($body); } function display($output=false) { diff --git a/include/class.ticket.php b/include/class.ticket.php index 06ffa295e1eeccd2a1ad221a2a84ea9dd0bcadde..b10f78097376e255c844045e095da3dcf1727921 100644 --- a/include/class.ticket.php +++ b/include/class.ticket.php @@ -115,11 +115,15 @@ class Ticket { function loadDynamicData() { if (!$this->_answers) { - foreach (DynamicFormEntry::forTicket($this->getId(), true) as $form) - foreach ($form->getAnswers() as $answer) - if ($tag = mb_strtolower($answer->getField()->get('name'))) + foreach (DynamicFormEntry::forTicket($this->getId(), true) as $form) { + foreach ($form->getAnswers() as $answer) { + $tag = mb_strtolower($answer->getField()->get('name')) + ?: 'field.' . $answer->getField()->get('id'); $this->_answers[$tag] = $answer; + } + } } + return $this->_answers; } function reload() { @@ -2062,6 +2066,17 @@ class Ticket { $errors['duedate']=__('Due date must be in the future'); } + // Validate dynamic meta-data + $forms = DynamicFormEntry::forTicket($this->getId()); + foreach ($forms as $form) { + // Don't validate deleted forms + if (!in_array($form->getId(), $vars['forms'])) + continue; + $form->setSource($_POST); + if (!$form->isValid()) + $errors = array_merge($errors, $form->errors()); + } + if($errors) return false; $sql='UPDATE '.TICKET_TABLE.' SET updated=NOW() ' @@ -2089,6 +2104,19 @@ class Ticket { // Decide if we need to keep the just selected SLA $keepSLA = ($this->getSLAId() != $vars['slaId']); + // Update dynamic meta-data + foreach ($forms as $f) { + // Drop deleted forms + $idx = array_search($f->getId(), $vars['forms']); + if ($idx === false) { + $f->delete(); + } + else { + $f->set('sort', $idx); + $f->save(); + } + } + // Reload the ticket so we can do further checking $this->reload(); @@ -2105,6 +2133,7 @@ class Ticket { $this->clearOverdue(); } + Signal::send('model.updated', $this); return true; } @@ -2302,6 +2331,8 @@ class Ticket { return 0; }; + Signal::send('ticket.create.before', null, $vars); + // Create and verify the dynamic form entry for the new ticket $form = TicketForm::getNewInstance(); // If submitting via email, ensure we have a subject and such @@ -2464,6 +2495,8 @@ class Ticket { if ($errors) return 0; + Signal::send('ticket.create.validated', null, $vars); + # Some things will need to be unpacked back into the scope of this # function if (isset($vars['autorespond'])) @@ -2677,6 +2710,9 @@ class Ticket { /* Start tracking ticket lifecycle events */ $ticket->logEvent('created'); + // Fire post-create signal (for extra email sending, searching) + Signal::send('model.created', $ticket); + /* Phew! ... time for tea (KETEPA) */ return $ticket; diff --git a/include/class.user.php b/include/class.user.php index 0d13cef637a8bc6c2f3a5071f86abc21b5dbd860..0ec1179fafa03282816a1393c8eefcbf737e02a6 100644 --- a/include/class.user.php +++ b/include/class.user.php @@ -26,6 +26,10 @@ class UserEmailModel extends VerySimpleModel { ) ) ); + + function __toString() { + return $this->address; + } } class TicketModel extends VerySimpleModel { diff --git a/include/mysqli.php b/include/mysqli.php index 6b6d25c4ecaeb73bd98154e9f6d13592e016d119..71681975cd8bdd8e1281a3290eac9234690fa6af 100644 --- a/include/mysqli.php +++ b/include/mysqli.php @@ -189,6 +189,10 @@ function db_query($query, $logError=true, $buffered=true) { return $res; } +function db_query_unbuffered($sql, $logError=false) { + return db_query($sql, $logError, true); +} + function db_squery($query) { //smart db query...utilizing args and sprintf $args = func_get_args(); diff --git a/scp/tickets.php b/scp/tickets.php index c5c5c2dd626cd1e44bb614ff32de975ed08a4ed5..f3502c96a2666cfcbdd8868d0a276ad03055c4cd 100644 --- a/scp/tickets.php +++ b/scp/tickets.php @@ -187,32 +187,12 @@ if($_POST && !$errors): break; case 'edit': case 'update': - $forms=DynamicFormEntry::forTicket($ticket->getId()); - foreach ($forms as $form) { - // Don't validate deleted forms - if (!in_array($form->getId(), $_POST['forms'])) - continue; - $form->setSource($_POST); - if (!$form->isValid()) - $errors = array_merge($errors, $form->errors()); - } if(!$ticket || !$thisstaff->canEditTickets()) $errors['err']=__('Permission Denied. You are not allowed to edit tickets'); elseif($ticket->update($_POST,$errors)) { $msg=__('Ticket updated successfully'); $_REQUEST['a'] = null; //Clear edit action - going back to view. //Check to make sure the staff STILL has access post-update (e.g dept change). - foreach ($forms as $f) { - // Drop deleted forms - $idx = array_search($f->getId(), $_POST['forms']); - if ($idx === false) { - $f->delete(); - } - else { - $f->set('sort', $idx); - $f->save(); - } - } if(!$ticket->checkStaffAccess($thisstaff)) $ticket=null; } elseif(!$errors['err']) {