diff --git a/assets/default/css/theme.css b/assets/default/css/theme.css index 26680176598c1b8916aaccacb041713d34074a16..e2120f71909cc711027c791bd0573437bdb04adb 100644 --- a/assets/default/css/theme.css +++ b/assets/default/css/theme.css @@ -118,6 +118,12 @@ fieldset { a { color: #0072bc; text-decoration: none; + display: inline-block; + margin-bottom: 1px; +} +a:hover { + border-bottom: 1px dotted #0072bc; + margin-bottom: 0; } h1 { color: #00AEEF; @@ -789,9 +795,6 @@ label.required, span.required { font-size: 1em; background-image: url('../images/icons/thread.gif?1319556657'); } -.Icon:hover { - text-decoration: underline; -} #ticketTable { border: 1px solid #aaa; border-left: none; @@ -828,25 +831,22 @@ label.required, span.required { #ticketTable tr.alt td { background: #f9f9f9; } -#ticketSearchForm { - display: inline-block; - float: left; - padding: 0 0 5px 0; +i.refresh { + color: #0a0; + font-size: 80%; + vertical-align: middle; } -a.refresh { - display: block; - width: auto; - float: right; - height: 20px; - line-height: 20px; - text-align: center; - padding: 0 10px 0 28px; - border: 1px solid #aaa; - margin-left: 10px; - color: #333; - background-position: 5px 50%; - background-repeat: no-repeat; - background-image: url('../images/icons/refresh.png'); +.states small { + font-size: 70%; +} +.active.state { + font-weight: bold; +} +.search.well { + padding: 10px; + background-color: rgba(0,0,0,0.05); + margin-bottom: 10px; + margin-top: -15px; } .infoTable { background: #F4FAFF; @@ -999,7 +999,7 @@ img.sign-in-image { } .span8 { display: inline-block; - width: 66.5%; + width: 66.0%; margin: 0 1%; vertical-align: top; } @@ -1153,6 +1153,10 @@ img.avatar { .thread-body .attachments .filesize { margin-left: 0.5em; } +.thread-body .attachments a, +.thread-body .attachments a:hover { + text-decoration: none; +} .thread-body .attachment-info { margin-right: 10px; display: inline-block; diff --git a/css/thread.css b/css/thread.css index 4ac6615703681721278f9d2f8815c499d2a6aff9..4e810094a70447cb92dd33671fe85bb4c58704eb 100644 --- a/css/thread.css +++ b/css/thread.css @@ -66,7 +66,7 @@ .thread-body kbd, .thread-body pre, .thread-body samp { - font-family: monospace, serif; + font-family: 'Source Code Pro', 'Monaco', 'Consolas', monospace, serif; font-size: 1em; } .thread-body pre { @@ -420,11 +420,18 @@ margin: 0; margin-bottom: 10px; border: none; - background: none !important; + background: none; box-shadow: none !important; text-indent: 0 !important; } +.thread-body pre { + background: #f5f5f5; + background-color: rgba(0,0,0,0.05); + border-radius: 5px; + padding: 0.5em; +} + .thread-body iframe, .thread-body object, .thread-body hr { diff --git a/include/ajax.search.php b/include/ajax.search.php index d228018e911f963d9ec8b201aca8d2921e201c70..4013d27e4290e3a5ffa3187069a6fa04c1c8fcf4 100644 --- a/include/ajax.search.php +++ b/include/ajax.search.php @@ -22,23 +22,14 @@ require_once(INCLUDE_DIR.'class.ajax.php'); class SearchAjaxAPI extends AjaxController { - static function ensureConsistentFormFieldIds($reset=false) { - // Maintain unique form field IDs over the life of the session - FormField::$uid = $reset ?: 1000; - } - function getAdvancedSearchDialog() { global $thisstaff; if (!$thisstaff) Http::response(403, 'Agent login required'); - self::ensureConsistentFormFieldIds(); $search = SavedSearch::create(); - // Don't send the state as the souce because it is not in the - // ::parse format (it's in ::to_php format) - $form = $search->getFormFromSession('advsearch'); - $form->loadState($_SESSION['advsearch']); + $form = $search->getFormFromSession('advsearch') ?: $search->getForm(); $matches = self::_getSupportedTicketMatches(); include STAFFINC_DIR . 'templates/advanced-search.tmpl.php'; @@ -50,7 +41,7 @@ class SearchAjaxAPI extends AjaxController { if (!$thisstaff) Http::response(403, 'Agent login required'); - list($type, $id) = explode('!', $name, 2); + @list($type, $id) = explode('!', $name, 2); switch (strtolower($type)) { case ':ticket': @@ -62,17 +53,23 @@ class SearchAjaxAPI extends AjaxController { list(,$id) = explode('!', $id, 2); if (!($field = DynamicFormField::lookup($id))) Http::response(404, 'No such field: ', print_r($id, true)); + + $impl = $field->getImpl(); + $impl->set('label', sprintf('%s / %s', + $field->form->getLocal('title'), $field->getLocal('label') + )); break; + default: + $extended = SavedSearch::getExtendedTicketFields(); + + if (isset($extended[$name])) { + $impl = $extended[$name]; + break; + } Http::response(400, 'No such field type'); } - self::ensureConsistentFormFieldIds($_GET['ff_uid']); - - $impl = $field->getImpl(); - $impl->set('label', sprintf('%s / %s', - $field->form->getLocal('title'), $field->getLocal('label') - )); $fields = SavedSearch::getSearchField($impl, $name); $form = new SimpleForm($fields); // Check the box to search the field by default @@ -96,7 +93,6 @@ class SearchAjaxAPI extends AjaxController { global $thisstaff; $search = SavedSearch::create(); - self::ensureConsistentFormFieldIds(); $form = $search->getForm($_POST); if (!$form->isValid()) { @@ -132,7 +128,6 @@ class SearchAjaxAPI extends AjaxController { else $data[$name] = $info['value']; } - self::ensureConsistentFormFieldIds(); $form = $search->getForm($data); if (!$data || !$form->isValid()) { Http::response(422, 'Validation errors exist on form'); @@ -152,7 +147,9 @@ class SearchAjaxAPI extends AjaxController { function _getSupportedTicketMatches() { // User information - $matches = array(); + $matches = array( + __('Ticket Built-In') => SavedSearch::getExtendedTicketFields(), + ); foreach (array('ticket'=>'TicketForm', 'user'=>'UserForm', 'organization'=>'OrganizationForm') as $k=>$F) { $form = $F::objects()->one(); $fields = &$matches[$form->getLocal('title')]; @@ -203,12 +200,12 @@ class SearchAjaxAPI extends AjaxController { Http::response(404, 'No such saved search'); } - self::ensureConsistentFormFieldIds(); - $form = $search->getForm(); - if ($state = JsonDataParser::parse($search->config)) + if ($state = JsonDataParser::parse($search->config)) { + $form = $search->loadFromState($state); $form->loadState($state); - + } $matches = self::_getSupportedTicketMatches(); + include STAFFINC_DIR . 'templates/advanced-search.tmpl.php'; } diff --git a/include/ajax.thread.php b/include/ajax.thread.php index 4a8b72016d69ce2ff4565d5026134b380f6e0649..470f9276e24a6d3c4c15a7d054422799db70c9b1 100644 --- a/include/ajax.thread.php +++ b/include/ajax.thread.php @@ -114,19 +114,6 @@ class ThreadAjaxAPI extends AjaxController { // FIXME: Refuse to add ticket owner?? if (($c=$thread->addCollaborator($user, array('isactive'=>1), $errors))) { - $note = Format::htmlchars(sprintf(__('%s <%s> added as a collaborator'), - Format::htmlchars($c->getName()), $c->getEmail())); - - $thread->getObject()->postThreadEntry('N', - array( - 'title' => __('New Collaborator Added'), - 'note' => $note - ), - array( - 'poster' => $thisstaff, - 'alert' => false - ) - ); $info = array('msg' => sprintf(__('%s added as a collaborator'), Format::htmlchars($c->getName()))); return self::_collaborators($thread, $info); diff --git a/include/class.attachment.php b/include/class.attachment.php index b787ee25093c3a5b6fcb23cfef08e054c45e50e7..6a904015421c5d1b899927cf9b21b5a4cc79baad 100644 --- a/include/class.attachment.php +++ b/include/class.attachment.php @@ -128,7 +128,9 @@ extends InstrumentedList { $fileId = $file; elseif (is_array($file) && isset($file['id'])) $fileId = $file['id']; - elseif ($F = AttachmentFile::upload($file)) + elseif (isset($file['tmp_name']) && ($F = AttachmentFile::upload($file))) + $fileId = $F->getId(); + elseif ($F = AttachmentFile::create($file)) $fileId = $F->getId(); else continue; diff --git a/include/class.client.php b/include/class.client.php index eb4ef00a870c62a6355d3269ec5b6c0476ed27c5..bbd6270c5f7ee781f58f2335ea29e20c895bb687 100644 --- a/include/class.client.php +++ b/include/class.client.php @@ -245,6 +245,10 @@ class EndUser extends BaseAuthenticatedUser { return ($stats=$this->getTicketStats())?$stats['closed']:0; } + function getNumTopicTickets($topic_id) { + return ($stats=$this->getTicketStats())?$stats['topics'][$topic_id]:0; + } + function getNumOrganizationTickets() { if (!($stats=$this->getTicketStats())) return 0; @@ -272,58 +276,20 @@ class EndUser extends BaseAuthenticatedUser { } private function getStats() { - - $where = ' WHERE ticket.user_id = '.db_input($this->getId()) - .' OR collab.user_id = '.db_input($this->getId()).' '; - - $where2 = ' WHERE user.org_id > 0 AND user.org_id = '.db_input($this->getOrgId()).' '; - - $join = 'LEFT JOIN '.THREAD_TABLE.' thread - ON (ticket.ticket_id = thread.object_id and thread.object_type = \'T\') - LEFT JOIN '.THREAD_COLLABORATOR_TABLE.' collab - ON (collab.thread_id=thread.id - AND collab.user_id = '.db_input($this->getId()).' ) '; - - $sql = 'SELECT \'open\', count( ticket.ticket_id ) AS tickets ' - .'FROM ' . TICKET_TABLE . ' ticket ' - .'INNER JOIN '.TICKET_STATUS_TABLE. ' status - ON (ticket.status_id=status.id - AND status.state=\'open\') ' - . $join - . $where - - .'UNION SELECT \'closed\', count( ticket.ticket_id ) AS tickets ' - .'FROM ' . TICKET_TABLE . ' ticket ' - .'INNER JOIN '.TICKET_STATUS_TABLE. ' status - ON (ticket.status_id=status.id - AND status.state=\'closed\' ) ' - . $join - . $where - - .'UNION SELECT \'org-open\', count( ticket.ticket_id ) AS tickets ' - .'FROM ' . TICKET_TABLE . ' ticket ' - .'INNER JOIN '.USER_TABLE.' user ON (ticket.user_id = user.id) ' - .'INNER JOIN '.TICKET_STATUS_TABLE. ' status - ON (ticket.status_id=status.id - AND status.state=\'open\' ) ' - . $join - . $where2 - - .'UNION SELECT \'org-closed\', count( ticket.ticket_id ) AS tickets ' - .'FROM ' . TICKET_TABLE . ' ticket ' - .'INNER JOIN '.USER_TABLE.' user ON (ticket.user_id = user.id) ' - .'INNER JOIN '.TICKET_STATUS_TABLE. ' status - ON (ticket.status_id=status.id - AND status.state=\'closed\' ) ' - . $join - . $where2; - - $res = db_query($sql); - $stats = array(); - while($row = db_fetch_row($res)) { - $stats[$row[0]] = $row[1]; + $basic = Ticket::objects() + ->annotate(array('count' => SqlAggregate::COUNT('ticket_id'))) + ->values('status__state', 'topic_id') + ->filter(Q::any(array( + 'user_id' => $this->getId(), + 'thread__collaborators__user_id' => $this->getId(), + ))); + + $stats = array('open' => 0, 'closed' => 0, 'topics' => array()); + foreach ($basic as $row) { + $stats[$row['status__state']] += $row['count']; + if ($row['topic_id']) + $stats['topics'][$row['topic_id']] += $row['count']; } - return $stats; } diff --git a/include/class.faq.php b/include/class.faq.php index f3d9d5bb76cfde2e0d454387b153d12475ade707..9f0b83ccd5161f08700fbfb03cfec1fea7a47d1a 100644 --- a/include/class.faq.php +++ b/include/class.faq.php @@ -367,10 +367,10 @@ class FAQ extends VerySimpleModel { } static function allPublic() { - return static::objects()->exclude(array( + return static::objects()->exclude(Q::any(array( 'ispublished'=>self::VISIBILITY_PRIVATE, 'category__ispublic'=>Category::VISIBILITY_PRIVATE, - )); + ))); } static function countPublishedFAQs() { diff --git a/include/class.file.php b/include/class.file.php index 5f4013ff762f85b780c61bc746d84190deba714b..e0953876d7571c4e6f322d78b4dd7a48a59ea85a 100644 --- a/include/class.file.php +++ b/include/class.file.php @@ -25,6 +25,12 @@ class AttachmentFile extends VerySimpleModel { ), ), ); + static $keyCache = array(); + + function __onload() { + // Cache for lookup in the ::lookupByHash method below + static::$keyCache[$this->key] = $this; + } function getHashtable() { return $this->ht; @@ -532,13 +538,11 @@ class AttachmentFile extends VerySimpleModel { } static function lookupByHash($hash) { - static $keyCache = array(); - - if (isset($keyCache[$hash])) - return $keyCache[$hash]; + if (isset(static::$keyCache[$hash])) + return static::$keyCache[$hash]; // Cache a negative lookup if no such file exists - return $keyCache[$hash] = parent::lookup(array('key' => $hash)); + return parent::lookup(array('key' => $hash)); } static function lookup($id) { diff --git a/include/class.format.php b/include/class.format.php index d062b69fdc5011712aa71863ae3c86c2dd13b65a..d13c2dd8b39a292698b73beda6272052d127eee6 100644 --- a/include/class.format.php +++ b/include/class.format.php @@ -317,7 +317,7 @@ class Format { global $ost; // Find all text between tags - $text = preg_replace_callback(':^[^<]+|>[^<]+:', + return preg_replace_callback(':^[^<]+|>[^<]+:', function($match) { // Scan for things that look like URLs return preg_replace_callback( @@ -344,32 +344,6 @@ class Format { $match[0]); }, $text); - - // Now change @href and @src attributes to come back through our - // system as well - $config = array( - 'hook_tag' => function($e, $a=0) use ($target) { - static $eE = array('area'=>1, 'br'=>1, 'col'=>1, 'embed'=>1, - 'hr'=>1, 'img'=>1, 'input'=>1, 'isindex'=>1, 'param'=>1); - if ($e == 'a' && $a) { - $a['target'] = $target; - $a['class'] = 'no-pjax'; - } - - $at = ''; - if (is_array($a)) { - foreach ($a as $k=>$v) - $at .= " $k=\"$v\""; - return "<{$e}{$at}".(isset($eE[$e])?" /":"").">"; - } else { - return "</{$e}>"; - } - }, - 'schemes' => 'href: aim, feed, file, ftp, gopher, http, https, irc, mailto, news, nntp, sftp, ssh, telnet; *:file, http, https; src: cid, http, https, data', - 'elements' => '*+iframe', - 'spec' => 'span=data-src,width,height;img=data-cid', - ); - return Format::html($text, $config); } function stripEmptyLines($string) { @@ -379,18 +353,9 @@ class Format { function viewableImages($html, $script=false) { $cids = $images = array(); - // Try and get information for all the files in one query - if (preg_match_all('/"cid:([\w._-]{32})"/', $html, $cids)) { - foreach (AttachmentFile::objects() - ->filter(array('key__in' => $cids[1])) - as $file - ) { - $images[strtolower($file->getKey())] = $file; - } - } return preg_replace_callback('/"cid:([\w._-]{32})"/', function($match) use ($script, $images) { - if (!($file = $images[strtolower($match[1])])) + if (!($file = AttachmentFile::lookup($match[1]))) return $match[0]; return sprintf('"%s" data-cid="%s"', $file->getDownloadUrl(false, 'inline', $script), $match[1]); @@ -441,6 +406,7 @@ class Format { function __formatDate($timestamp, $format, $fromDb, $dayType, $timeType, $strftimeFallback, $timezone, $user=false) { global $cfg; + static $cache; if (!$timestamp) return ''; @@ -449,18 +415,28 @@ class Format { $timestamp = Misc::db2gmtime($timestamp); if (class_exists('IntlDateFormatter')) { - $formatter = new IntlDateFormatter( - Internationalization::getCurrentLocale($user), - $dayType, - $timeType, - $timezone, - IntlDateFormatter::GREGORIAN, - $format ?: null - ); - if ($cfg->isForce24HourTime()) { - $format = str_replace(array('a', 'h'), array('', 'H'), - $formatter->getPattern()); - $formatter->setPattern($format); + $locale = Internationalization::getCurrentLocale($user); + $key = "{$locale}:{$dayType}:{$timeType}:{$timezone}:{$format}"; + if (!isset($cache[$key])) { + // Setting up the IntlDateFormatter is pretty expensive, so + // cache it since there aren't many variations of the + // arguments passed to the constructor + $cache[$key] = $formatter = new IntlDateFormatter( + $locale, + $dayType, + $timeType, + $timezone, + IntlDateFormatter::GREGORIAN, + $format ?: null + ); + if ($cfg->isForce24HourTime()) { + $format = str_replace(array('a', 'h'), array('', 'H'), + $formatter->getPattern()); + $formatter->setPattern($format); + } + } + else { + $formatter = $cache[$key]; } return $formatter->format($timestamp); } diff --git a/include/class.forms.php b/include/class.forms.php index a3dc7579634996b2da6d1837bdd4e31b3faf42f4..ea0947e44fbd2db2450f7a5c65d96ce15f229cef 100644 --- a/include/class.forms.php +++ b/include/class.forms.php @@ -388,6 +388,10 @@ class FormField { $this->ht[$field] = $value; } + function getId() { + return $this->ht['id']; + } + /** * getClean * @@ -422,7 +426,7 @@ class FormField { return $this->_clean; } function reset() { - $this->_clean = $this->_widget = null; + $this->value = $this->_clean = $this->_widget = null; } function getValue() { @@ -1248,6 +1252,18 @@ class BooleanField extends FormField { 'set.not' => null, ); } + + function getSearchQ($method, $value, $name=false) { + $name = $name ?: $this->get('name'); + switch ($method) { + case 'set': + return new Q(array($name => '1')); + case 'set.not': + return new Q(array($name => '0')); + default: + return parent::getSearchQ($method, $value, $name); + } + } } class ChoiceField extends FormField { @@ -2124,8 +2140,16 @@ class FileUploadField extends FormField { static function getFileTypes() { static $filetypes; - if (!isset($filetypes)) - $filetypes = YamlDataParser::load(INCLUDE_DIR . '/config/filetype.yaml'); + if (!isset($filetypes)) { + if (function_exists('apc_fetch')) { + $key = md5(SECRET_SALT . GIT_VERSION . 'filetypes'); + $filetypes = apc_fetch($key); + } + if (!$filetypes) + $filetypes = YamlDataParser::load(INCLUDE_DIR . '/config/filetype.yaml'); + if ($key) + apc_store($key, $filetypes, 7200); + } return $filetypes; } @@ -2910,7 +2934,9 @@ class CheckboxWidget extends Widget { $data = $this->field->getSource(); if (count($data)) { if (!isset($data[$this->name])) - return false; + // Indeterminite. Likely false, but consider current field + // value + return null; return @in_array($this->field->get('id'), $data[$this->name]); } return parent::getValue(); diff --git a/include/class.i18n.php b/include/class.i18n.php index 082d8040b7a2e01ab4b99e4dd5ef85a203f2c7fc..ed3df93bfcdbbb44fcfc9b4a1f7d0e02dcff2ece 100644 --- a/include/class.i18n.php +++ b/include/class.i18n.php @@ -129,7 +129,6 @@ class Internationalization { $sql = 'INSERT INTO '.PAGE_TABLE.' SET type='.db_input($type) .', name='.db_input($page['name']) .', body='.db_input($page['body']) - .', lang='.db_input($tpl->getLang()) .', notes='.db_input($page['notes']) .', created=NOW(), updated=NOW(), isactive=1'; if (db_query($sql) && ($id = db_insert_id()) @@ -139,17 +138,14 @@ class Internationalization { // Default Language $_config->set('system_language', $this->langs[0]); - // content_id defaults to the `id` field value - db_query('UPDATE '.PAGE_TABLE.' SET content_id=id'); - // Canned response examples if (($tpl = $this->getTemplate('templates/premade.yaml')) && ($canned = $tpl->getData())) { foreach ($canned as $c) { - if (($premade = Canned::create($c)) - && isset($c['attachments'])) { + if (!($premade = Canned::create($c)) || !$premade->save()) + continue; + if (isset($c['attachments'])) { $premade->attachments->upload($c['attachments']); - $premade->save(); } } } @@ -284,6 +280,10 @@ class Internationalization { // Algorithm borrowed from Drupal 7 (locale.inc) static function getDefaultLanguage() { global $cfg; + static $lang; + + if (isset($lang)) + return $lang; if (empty($_SERVER["HTTP_ACCEPT_LANGUAGE"])) return $cfg ? $cfg->getPrimaryLanguage() : 'en_US'; @@ -362,10 +362,9 @@ class Internationalization { } } - if (self::isLanguageInstalled($best_match_langcode)) - return $best_match_langcode; - else - return $cfg->getPrimaryLanguage(); + return $lang = self::isLanguageInstalled($best_match_langcode) + ? $best_match_langcode + : $cfg->getPrimaryLanguage(); } static function getCurrentLanguage($user=false) { diff --git a/include/class.mailer.php b/include/class.mailer.php index a752d4043f062c83b9388a09a1f12824bd74dd57..f41d0e55a63ea814519aeac09b64749509029779 100644 --- a/include/class.mailer.php +++ b/include/class.mailer.php @@ -433,11 +433,10 @@ class Mailer { function($match) use ($domain, $mime, $self) { $file = false; foreach ($self->attachments as $id=>$F) { + if ($F instanceof Attachment) + $F = $F->getFile(); if (strcasecmp($F->getKey(), $match[1]) === 0) { - if ($F instanceof Attachment) - $file = $F->getFile(); - else - $file = $F; + $file = $F; break; } } diff --git a/include/class.mailfetch.php b/include/class.mailfetch.php index d1ad8fbec966ce1a9b6b08211d0aacd54afb3930..d4d2029cb9a504b88fd525590f8d770ded296ce5 100644 --- a/include/class.mailfetch.php +++ b/include/class.mailfetch.php @@ -762,7 +762,7 @@ class MailFetcher { } // Allow continuation of thread without initial message or note elseif (($thread = Thread::lookupByEmailHeaders($vars)) - && ($message = $entry->postEmail($vars)) + && ($message = $thread->postEmail($vars)) ) { // NOTE: This might not be a "ticket" $ticket = $thread->getObject(); diff --git a/include/class.orm.php b/include/class.orm.php index d1aa0c5386ed981b4f0ebf5d26f804a3d53fd8b4..3157c0a8497d93351ba911ddae3935b88f44e122 100644 --- a/include/class.orm.php +++ b/include/class.orm.php @@ -19,7 +19,13 @@ class OrmException extends Exception {} class OrmConfigurationException extends Exception {} // Database fields/tables do not match codebase -class InconsistentModelException extends OrmException {} +class InconsistentModelException extends OrmException { + function __construct() { + // Drop the model cache (just incase) + ModelMeta::flushModelCache(); + call_user_func_array(array('parent', '__construct'), func_get_args()); + } +} /** * Meta information about a model including edges (relationships), table @@ -36,6 +42,8 @@ class ModelMeta implements ArrayAccess { 'defer' => array(), 'select_related' => array(), 'view' => false, + 'joins' => array(), + 'foreign_keys' => array(), ); static $model_cache; @@ -43,26 +51,27 @@ class ModelMeta implements ArrayAccess { function __construct($model) { $this->model = $model; - $parent = get_parent_class($model); // Merge ModelMeta from parent model (if inherited) + $parent = get_parent_class($this->model); if (is_subclass_of($parent, 'VerySimpleModel')) { - $parent::_inspect(); - $meta = $parent::$meta->extend($model::$meta); + $meta = $parent::getMeta()->extend($model::$meta); } else { $meta = $model::$meta + self::$base; } - if (!$meta['table']) - throw new OrmConfigurationException( - sprintf(__('%s: Model does not define meta.table'), $model)); - elseif (!$meta['pk']) - throw new OrmConfigurationException( - sprintf(__('%s: Model does not define meta.pk'), $model)); + if (!$meta['view']) { + if (!$meta['table']) + throw new OrmConfigurationException( + sprintf(__('%s: Model does not define meta.table'), $this->model)); + elseif (!$meta['pk']) + throw new OrmConfigurationException( + sprintf(__('%s: Model does not define meta.pk'), $this->model)); + } // Ensure other supported fields are set and are arrays - foreach (array('pk', 'ordering', 'defer') as $f) { + foreach (array('pk', 'ordering', 'defer', 'select_related') as $f) { if (!isset($meta[$f])) $meta[$f] = array(); elseif (!is_array($meta[$f])) @@ -70,8 +79,6 @@ class ModelMeta implements ArrayAccess { } // Break down foreign-key metadata - if (!isset($meta['joins'])) - $meta['joins'] = array(); foreach ($meta['joins'] as $field => &$j) { $this->processJoin($j); if ($j['local']) @@ -87,10 +94,32 @@ class ModelMeta implements ArrayAccess { return $meta + $this->base + self::$base; } + /** + * Adds some more information to a declared relationship. If the + * relationship is a reverse relation, then the information from the + * reverse relation is loaded into the local definition + * + * Compiled-Join-Structure: + * 'constraint' => array(local => array(foreign_field, foreign_class)), + * Constraint used to construct a JOIN in an SQL query + * 'list' => boolean + * TRUE if an InstrumentedList should be employed to fetch a list + * of related items + * 'broker' => Handler for the 'list' property. Usually a subclass of + * 'InstrumentedList' + * 'null' => boolean + * TRUE if relation is nullable + * 'fkey' => array(class, pk) + * Classname and field of the first item in the constraint that + * points to a PK field of a foreign model + * 'local' => string + * The local field corresponding to the 'fkey' property + */ function processJoin(&$j) { $constraint = array(); if (isset($j['reverse'])) { list($fmodel, $key) = explode('.', $j['reverse']); + // NOTE: It's ok if the forein meta data is not yet inspected. $info = $fmodel::$meta['joins'][$key]; if (!is_array($info['constraint'])) throw new OrmConfigurationException(sprintf(__( @@ -129,6 +158,11 @@ class ModelMeta implements ArrayAccess { $j['constraint'] = $constraint; } + function addJoin($name, array $join) { + $this->base['joins'][$name] = $join; + $this->processJoin($this->base['joins'][$name]); + } + function offsetGet($field) { if (!isset($this->base[$field])) $this->setupLazy($field); @@ -205,11 +239,8 @@ class VerySimpleModel { function get($field, $default=false) { if (array_key_exists($field, $this->ht)) return $this->ht[$field]; - elseif (isset(static::$meta['joins'][$field])) { - // Make sure joins were inspected - if (!static::$meta instanceof ModelMeta) - static::_inspect(); - $j = static::$meta['joins'][$field]; + elseif (($joins = static::getMeta('joins')) && isset($joins[$field])) { + $j = $joins[$field]; // Support instrumented lists and such if (isset($j['list']) && $j['list']) { $class = $j['fkey'][0]; @@ -290,11 +321,9 @@ class VerySimpleModel { function set($field, $value) { // Update of foreign-key by assignment to model instance $related = false; - if (isset(static::$meta['joins'][$field])) { - // XXX: This is likely not necessary - if (!isset(static::$meta['joins'][$field]['fkey'])) - static::_inspect(); - $j = static::$meta['joins'][$field]; + $joins = static::getMeta('joins'); + if (isset($joins[$field])) { + $j = $joins[$field]; if ($j['list'] && ($value instanceof InstrumentedList)) { // Magic list property $this->ht[$field] = $value; @@ -321,7 +350,7 @@ class VerySimpleModel { else throw new InvalidArgumentException( sprintf(__('Expecting NULL or instance of %s. Got a %s instead'), - $j['fkey'][0], get_class($value))); + $j['fkey'][0], is_object($value) ? get_class($value) : gettype($value))); // Capture the foreign key id value $field = $j['local']; @@ -359,12 +388,17 @@ class VerySimpleModel { static function __oninspect() {} static function _inspect() { - if (!static::$meta instanceof ModelMeta) { - static::$meta = new ModelMeta(get_called_class()); + static::$meta = new ModelMeta(get_called_class()); - // Let the model participate - static::__oninspect(); - } + // Let the model participate + static::__oninspect(); + } + + static function getMeta($key=false) { + if (!static::$meta instanceof ModelMeta) + static::_inspect(); + $M = static::$meta; + return ($key) ? $M->offsetGet($key) : $M; } /** @@ -406,14 +440,12 @@ class VerySimpleModel { * no such instance exists. */ static function lookup($criteria) { - // Autoinsepct model - static::_inspect(); - // Model::lookup(1), where >1< is the pk value if (!is_array($criteria)) { $criteria = array(); + $pk = static::getMeta('pk'); foreach (func_get_args() as $i=>$f) - $criteria[static::$meta['pk'][$i]] = $f; + $criteria[$pk[$i]] = $f; // Only consult cache for PK lookup, which is assumed if the // values are passed as args rather than an array @@ -449,14 +481,13 @@ class VerySimpleModel { if ($this->__deleted__) throw new OrmException('Trying to update a deleted object'); - $pk = static::$meta['pk']; + $pk = static::getMeta('pk'); $wasnew = $this->__new__; // First, if any foreign properties of this object are connected to // another *new* object, then save those objects first and set the // local foreign key field values - static::_inspect(); - foreach (static::$meta['joins'] as $prop => $j) { + foreach (static::getMeta('joins') as $prop => $j) { if (isset($this->ht[$prop]) && ($foreign = $this->ht[$prop]) && $foreign instanceof VerySimpleModel @@ -514,7 +545,7 @@ class VerySimpleModel { if ($wasnew) { // Attempt to update foreign, unsaved objects with the PK of // this newly created object - foreach (static::$meta['joins'] as $prop => $j) { + foreach (static::getMeta('joins') as $prop => $j) { if (isset($this->ht[$prop]) && ($foreign = $this->ht[$prop]) && in_array($j['local'], $pk) @@ -551,7 +582,7 @@ class VerySimpleModel { private function getPk() { $pk = array(); - foreach ($this::$meta['pk'] as $f) + foreach ($this::getMeta('pk') as $f) $pk[$f] = $this->ht[$f]; return $pk; } @@ -582,6 +613,7 @@ class AnnotatedModel { return $this->annotations[$what]; return $this->model->get($what, null); } + function __set($what, $to) { return $this->set($what, $to); } @@ -591,6 +623,10 @@ class AnnotatedModel { return $this->model->set($what, $to); } + function __isset($what) { + return isset($this->annotations[$what]) || $this->model->__isset($what); + } + // Delegate everything else to the model function __call($what, $how) { return call_user_func_array(array($this->model, $what), $how); @@ -647,6 +683,8 @@ class SqlCase extends SqlFunction { } function when($expr, $result) { + if (is_array($expr)) + $expr = new Q($expr); $this->cases[] = array($expr, $result); return $this; } @@ -805,14 +843,15 @@ class SqlAggregate extends SqlFunction { list($field, $rmodel) = $compiler->getField($E, $model, $options); if ($this->distinct) { $pk = false; - foreach ($rmodel::$meta['pk'] as $f) { + $fpk = $rmodel::getMeta('pk'); + foreach ($fpk as $f) { $pk |= false !== strpos($field, $f); } if (!$pk) { // Try and use the foriegn primary key - if (count($rmodel::$meta['pk']) == 1) { + if (count($fpk) == 1) { list($field) = $compiler->getField( - $this->expr . '__' . $rmodel::$meta['pk'][0], + $this->expr . '__' . $fpk[0], $model, $options); } else { @@ -845,6 +884,7 @@ class QuerySet implements IteratorAggregate, ArrayAccess, Serializable, Countabl var $related = array(); var $values = array(); var $defer = array(); + var $aggregated = false; var $annotations = array(); var $extra = array(); var $distinct = array(); @@ -1006,6 +1046,14 @@ class QuerySet implements IteratorAggregate, ArrayAccess, Serializable, Countabl return $this->_count = $compiler->compileCount($this); } + function toSql($compiler, $model, $alias) { + // FIXME: Force root model of the compiler to $model + $exec = $this->getQuery(array('compiler' => get_class($compiler))); + foreach ($exec->params as $P) + $compiler->params[] = $P; + return "({$exec})".($alias ? " AS {$alias}" : ''); + } + /** * exists * @@ -1044,6 +1092,17 @@ class QuerySet implements IteratorAggregate, ArrayAccess, Serializable, Countabl return $this; } + function aggregate($annotations) { + // Aggregate works like annotate, except that it sets up values + // fetching which will disable model creation + $this->annotate($annotations); + $this->values(); + // Disable other fields from being fetched + $this->aggregated = true; + $this->related = false; + return $this; + } + function delete() { $class = $this->compiler; $compiler = new $class(); @@ -1099,14 +1158,14 @@ class QuerySet implements IteratorAggregate, ArrayAccess, Serializable, Countabl // Load defaults from model $model = $this->model; $query = clone $this; - if (!$query->ordering && isset($model::$meta['ordering'])) - $query->ordering = $model::$meta['ordering']; - if (false !== $query->related && !$query->values && $model::$meta['select_related']) - $query->related = $model::$meta['select_related']; - if (!$query->defer && $model::$meta['defer']) - $query->defer = $model::$meta['defer']; - - $class = $this->compiler; + if (!$options['nosort'] && !$query->ordering && $model::getMeta('ordering')) + $query->ordering = $model::getMeta('ordering'); + if (false !== $query->related && !$query->values && $model::getMeta('select_related')) + $query->related = $model::getMeta('select_related'); + if (!$query->defer && $model::getMeta('defer')) + $query->defer = $model::getMeta('defer'); + + $class = $options['compiler'] ?: $this->compiler; $compiler = new $class($options); $this->query = $compiler->compileSelect($query); @@ -1167,10 +1226,14 @@ abstract class ResultSet implements Iterator, ArrayAccess, Countable { $this->queryset = $queryset; if ($queryset) { $this->model = $queryset->model; - $this->resource = $queryset->getQuery(); } } + function prime() { + if (!isset($this->resource) && $this->queryset) + $this->resource = $this->queryset->getQuery(); + } + abstract function fillTo($index); function asArray() { @@ -1225,17 +1288,9 @@ class ModelInstanceManager extends ResultSet { static $objectCache = array(); - function __construct($queryset=false) { - parent::__construct($queryset); - if ($queryset) { - $this->map = $this->resource->getMap(); - } - } - function cache($model) { - $model::_inspect(); $key = sprintf('%s.%s', - $model::$meta->model, implode('.', $model->pk)); + $model::$meta->model, implode('.', $model->get('pk'))); self::$objectCache[$key] = $model; } @@ -1253,8 +1308,8 @@ class ModelInstanceManager extends ResultSet { } static function checkCache($modelClass, $fields) { - $key = $modelClass; - foreach ($modelClass::$meta['pk'] as $f) + $key = $modelClass::$meta->model; + foreach ($modelClass::getMeta('pk') as $f) $key .= '.'.$fields[$f]; return @self::$objectCache[$key]; } @@ -1276,7 +1331,7 @@ class ModelInstanceManager extends ResultSet { function getOrBuild($modelClass, $fields, $cache=true) { // Check for NULL primary key, used with related model fetching. If // the PK is NULL, then consider the object to also be NULL - foreach ($modelClass::$meta['pk'] as $pkf) { + foreach ($modelClass::getMeta('pk') as $pkf) { if (!isset($fields[$pkf])) { return null; } @@ -1305,6 +1360,12 @@ class ModelInstanceManager extends ResultSet { if ($cache) $this->cache($m); } + elseif (get_class($m) != $modelClass) { + // Change the class of the object to be returned to match what + // was expected + // TODO: Emit a warning? + $m = new $modelClass($m->ht); + } // Wrap annotations in an AnnotatedModel if ($extras) { $m = new AnnotatedModel($m, $extras); @@ -1365,30 +1426,36 @@ class ModelInstanceManager extends ResultSet { } function fillTo($index) { + $this->prime(); $func = ($this->map) ? 'getRow' : 'getArray'; while ($this->resource && $index >= count($this->cache)) { if ($row = $this->resource->{$func}()) { $this->cache[] = $this->buildModel($row); } else { $this->resource->close(); - $this->resource = null; + $this->resource = false; break; } } } + + function prime() { + parent::prime(); + if ($this->resource) { + $this->map = $this->resource->getMap(); + } + } } class FlatArrayIterator extends ResultSet { - function __construct($queryset) { - $this->resource = $queryset->getQuery(); - } function fillTo($index) { + $this->prime(); while ($this->resource && $index >= count($this->cache)) { if ($row = $this->resource->getRow()) { $this->cache[] = $row; } else { $this->resource->close(); - $this->resource = null; + $this->resource = false; break; } } @@ -1396,16 +1463,14 @@ class FlatArrayIterator extends ResultSet { } class HashArrayIterator extends ResultSet { - function __construct($queryset) { - $this->resource = $queryset->getQuery(); - } function fillTo($index) { + $this->prime(); while ($this->resource && $index >= count($this->cache)) { if ($row = $this->resource->getArray()) { $this->cache[] = $row; } else { $this->resource->close(); - $this->resource = null; + $this->resource = false; break; } } @@ -1414,12 +1479,14 @@ class HashArrayIterator extends ResultSet { class InstrumentedList extends ModelInstanceManager { var $key; - var $model; function __construct($fkey, $queryset=false) { list($model, $this->key) = $fkey; - if (!$queryset) + if (!$queryset) { $queryset = $model::objects()->filter($this->key); + if ($related = $model::getMeta('select_related')) + $queryset->select_related($related); + } parent::__construct($queryset); $this->model = $model; } @@ -1449,7 +1516,8 @@ class InstrumentedList extends ModelInstanceManager { if ($delete) $object->delete(); else - $object->set($this->key, null); + foreach ($this->key as $field=>$value) + $object->set($field, null); } function reset() { @@ -1463,10 +1531,10 @@ class InstrumentedList extends ModelInstanceManager { */ function window($constraint) { $model = $this->model; - $meta = $model::$meta; + $fields = $model::getMeta('fields'); $key = $this->key; foreach ($constraint as $field=>$value) { - if (!is_string($field) || false === in_array($field, $meta['fields'])) + if (!is_string($field) || false === in_array($field, $fields)) throw new OrmException('InstrumentedList windowing must be performed on local fields only'); $key[$field] = $value; } @@ -1601,52 +1669,52 @@ class SqlCompiler { } } - $path = array(); + $path = ''; $rootModel = $model; // Call pushJoin for each segment in the join path. A new JOIN // fragment will need to be emitted and/or cached $joins = array(); - $push = function($p, $path, $model) use (&$joins) { - $model::_inspect(); - if (!($info = $model::$meta['joins'][$p])) { + $push = function($p, $model) use (&$joins, &$path) { + $J = $model::getMeta('joins'); + if (!($info = $J[$p])) { throw new OrmException(sprintf( 'Model `%s` does not have a relation called `%s`', $model, $p)); } - $crumb = implode('__', $path); - $tip = ($crumb) ? "{$crumb}__{$p}" : $p; - $joins[] = array($crumb, $tip, $model, $info); + $crumb = $path; + $path = ($path) ? "{$path}__{$p}" : $p; + $joins[] = array($crumb, $path, $model, $info); // Roll to foreign model return $info['fkey']; }; foreach ($parts as $p) { - list($model) = $push($p, $path, $model); - $path[] = $p; + list($model) = $push($p, $model); } // If comparing a relationship, join the foreign table // This is a comparison with a relationship — use the foreign key - if (isset($model::$meta['joins'][$field])) { - list($model, $field) = $push($field, $path, $model); - } - - // Add the conststraint as the last arg to the last join - if (isset($options['constraint'])) { - $joins[count($joins)-1][] = $options['constraint']; + $J = $model::getMeta('joins'); + if (isset($J[$field])) { + list($model, $field) = $push($field, $model); } // Apply the joins list to $this->pushJoin - foreach ($joins as $A) { - $alias = call_user_func_array(array($this, 'pushJoin'), $A); + $last = count($joins) - 1; + $constraint = false; + foreach ($joins as $i=>$A) { + // Add the conststraint as the last arg to the last join + if ($i == $last) + $constraint = $options['constraint']; + $alias = $this->pushJoin($A[0], $A[1], $A[2], $A[3], $contraint); } if (!isset($alias)) { // Determine the alias for the root model table $alias = (isset($this->joins[''])) ? $this->joins['']['alias'] - : $this->quote($rootModel::$meta['table']); + : $this->quote($rootModel::getMeta('table')); } if (isset($options['table']) && $options['table']) @@ -1754,6 +1822,8 @@ class SqlCompiler { } if ($value === null) $filter[] = sprintf('%s IS NULL', $field); + elseif ($value instanceof SqlField) + $filter[] = sprintf($op, $field, $value->toSql($this, $model)); // Allow operators to be callable rather than sprintf // strings elseif (is_callable($op)) @@ -1898,7 +1968,7 @@ class MySqlCompiler extends SqlCompiler { function __in($a, $b) { if (is_array($b)) { $vals = array_map(array($this, 'input'), $b); - $b = implode(', ', $vals); + $b = '('.implode(', ', $vals).')'; } // MySQL doesn't support LIMIT or OFFSET in subqueries. Instead, add // the query as a JOIN and add the join constraint into the WHERE @@ -1912,7 +1982,7 @@ class MySqlCompiler extends SqlCompiler { else { $b = $this->input($b); } - return sprintf('%s IN (%s)', $a, $b); + return sprintf('%s IN %s', $a, $b); } function __isnull($a, $b) { @@ -1942,7 +2012,7 @@ class MySqlCompiler extends SqlCompiler { if (isset($this->joins[$tip])) $table = $this->joins[$tip]['alias']; else - $table = $this->quote($model::$meta['table']); + $table = $this->quote($model::getMeta('table')); foreach ($info['constraint'] as $local => $foreign) { list($rmodel, $right) = $foreign; // Support a constant constraint with @@ -1975,10 +2045,10 @@ class MySqlCompiler extends SqlCompiler { if (!isset($rmodel)) $rmodel = $model; // Support inline views - $table = ($rmodel::$meta['view']) + $table = ($rmodel::getMeta('view')) // XXX: Support parameters from the nested query ? $rmodel::getQuery($this) - : $this->quote($rmodel::$meta['table']); + : $this->quote($rmodel::getMeta('table')); $base = "{$join}{$table} {$alias}"; if ($constraints) $base .= ' ON ('.implode(' AND ', $constraints).')'; @@ -2003,7 +2073,7 @@ class MySqlCompiler extends SqlCompiler { if ($what instanceof QuerySet) { $q = $what->getQuery(array('nosort'=>true)); $this->params = array_merge($this->params, $q->params); - return $q->sql; + return '('.$q->sql.')'; } elseif ($what instanceof SqlFunction) { return $what->toSql($this); @@ -2052,7 +2122,7 @@ class MySqlCompiler extends SqlCompiler { function compileCount($queryset) { $model = $queryset->model; - $table = $model::$meta['table']; + $table = $model::getMeta('table'); list($where, $having) = $this->getWhereHavingClause($queryset); $joins = $this->getJoins($queryset); $sql = 'SELECT COUNT(*) AS count FROM '.$this->quote($table).$joins.$where; @@ -2072,7 +2142,7 @@ class MySqlCompiler extends SqlCompiler { // Compile the ORDER BY clause $sort = ''; - if (($columns = $queryset->getSortFields()) && !isset($this->options['nosort'])) { + if ($columns = $queryset->getSortFields()) { $orders = array(); foreach ($columns as $sort) { $dir = 'ASC'; @@ -2084,30 +2154,34 @@ class MySqlCompiler extends SqlCompiler { $dir = 'DESC'; $sort = substr($sort, 1); } - list($field) = $this->getField($sort, $model); + // If the field is already an annotation, then don't + // compile the annotation again below. It's included in + // the select clause, which is sufficient + if (isset($this->annotations[$sort])) + $field = $this->quote($sort); + else + list($field) = $this->getField($sort, $model); } - // TODO: Throw exception if $field can be indentified as - // invalid if ($field instanceof SqlFunction) $field = $field->toSql($this, $model); + // TODO: Throw exception if $field can be indentified as + // invalid - $orders[] = $field.' '.$dir; + $orders[] = "{$field} {$dir}"; } $sort = ' ORDER BY '.implode(', ', $orders); } // Compile the field listing - $fields = array(); - $group_by = array(); - $table = $this->quote($model::$meta['table']).' '.$rootAlias; + $fields = $group_by = array(); + $table = $this->quote($model::getMeta('table')).' '.$rootAlias; // Handle related tables if ($queryset->related) { $count = 0; $fieldMap = $theseFields = array(); $defer = $queryset->defer ?: array(); // Add local fields first - $model::_inspect(); - foreach ($model::$meta['fields'] as $f) { + foreach ($model::getMeta('fields') as $f) { // Handle deferreds if (isset($defer[$f])) continue; @@ -2129,8 +2203,7 @@ class MySqlCompiler extends SqlCompiler { $theseFields = array(); list($alias, $fmodel) = $this->getField($full_path, $model, array('table'=>true, 'model'=>true)); - $fmodel::_inspect(); - foreach ($fmodel::$meta['fields'] as $f) { + foreach ($fmodel::getMeta('fields') as $f) { // Handle deferreds if (isset($defer[$sr . '__' . $f])) continue; @@ -2165,10 +2238,9 @@ class MySqlCompiler extends SqlCompiler { } } // Simple selection from one table - else { + elseif (!$queryset->aggregated) { if ($queryset->defer) { - $model::_inspect(); - foreach ($model::$meta['fields'] as $f) { + foreach ($model::getMeta('fields') as $f) { if (isset($queryset->defer[$f])) continue; $fields[$rootAlias .'.'. $this->quote($f)] = true; @@ -2195,8 +2267,8 @@ class MySqlCompiler extends SqlCompiler { } } // If no group by has been set yet, use the root model pk - if (!$group_by) { - foreach ($model::$meta['pk'] as $pk) + if (!$group_by && !$queryset->aggregated) { + foreach ($model::getMeta('pk') as $pk) $group_by[] = $rootAlias .'.'. $pk; } } @@ -2246,8 +2318,8 @@ class MySqlCompiler extends SqlCompiler { } function compileUpdate(VerySimpleModel $model) { - $pk = $model::$meta['pk']; - $sql = 'UPDATE '.$this->quote($model::$meta['table']); + $pk = $model::getMeta('pk'); + $sql = 'UPDATE '.$this->quote($model::getMeta('table')); $sql .= $this->__compileUpdateSet($model, $pk); // Support PK updates $criteria = array(); @@ -2261,15 +2333,15 @@ class MySqlCompiler extends SqlCompiler { } function compileInsert(VerySimpleModel $model) { - $pk = $model::$meta['pk']; - $sql = 'INSERT INTO '.$this->quote($model::$meta['table']); + $pk = $model::getMeta('pk'); + $sql = 'INSERT INTO '.$this->quote($model::getMeta('table')); $sql .= $this->__compileUpdateSet($model, $pk); return new MySqlExecutor($sql, $this->params); } function compileDelete($model) { - $table = $model::$meta['table']; + $table = $model::getMeta('table'); $where = ' WHERE '.implode(' AND ', $this->compileConstraints(array(new Q($model->pk)), $model)); @@ -2279,7 +2351,7 @@ class MySqlCompiler extends SqlCompiler { function compileBulkDelete($queryset) { $model = $queryset->model; - $table = $model::$meta['table']; + $table = $model::getMeta('table'); list($where, $having) = $this->getWhereHavingClause($queryset); $joins = $this->getJoins($queryset); $sql = 'DELETE '.$this->quote($table).'.* FROM ' @@ -2289,7 +2361,7 @@ class MySqlCompiler extends SqlCompiler { function compileBulkUpdate($queryset, array $what) { $model = $queryset->model; - $table = $model::$meta['table']; + $table = $model::getMeta('table'); $set = array(); foreach ($what as $field=>$value) $set[] = sprintf('%s = %s', $this->quote($field), $this->input($value)); @@ -2376,7 +2448,7 @@ class MySqlExecutor { $types = ''; $ps = array(); - foreach ($params as &$p) { + foreach ($params as $i=>&$p) { if (is_int($p) || is_bool($p)) $types .= 'i'; elseif (is_float($p)) diff --git a/include/class.search.php b/include/class.search.php index 137c1d375b041492ba8d285c6d2c27e792195867..b68a0d3ed37c6cf92e712987c141c36e4ff0f378 100644 --- a/include/class.search.php +++ b/include/class.search.php @@ -327,6 +327,8 @@ class MysqlSearchBackend extends SearchBackend { function find($query, QuerySet $criteria) { global $thisstaff; + $criteria = clone $criteria; + $mode = ' IN BOOLEAN MODE'; #if (count(explode(' ', $query)) == 1) # $mode = ' WITH QUERY EXPANSION'; @@ -450,7 +452,8 @@ class MysqlSearchBackend extends SearchBackend { return false; while ($row = db_fetch_row($res)) { - $ticket = Ticket::lookup($row[0]); + if (!($ticket = Ticket::lookup($row[0]))) + continue; $cdata = $ticket->loadDynamicData(); $content = array(); foreach ($cdata as $k=>$a) @@ -618,22 +621,25 @@ class SavedSearch extends VerySimpleModel { ))); } - function getFormFromSession($key, $source=false) { - if (isset($_SESSION[$key])) { - $source = $source ?: array(); - $state = $_SESSION[$key]; - // Pull out 'other' fields from the state so the fields will be - // added to the form. The state will be loaded below - foreach ($state as $k=>$v) { - $info = array(); - if (!preg_match('/^:(\w+)!(\d+)\+search/', $k, $info)) { - continue; - } - list($k,) = explode('+', $k, 2); - $source['fields'][] = ":{$info[1]}!{$info[2]}"; + function loadFromState($source=false) { + // Pull out 'other' fields from the state so the fields will be + // added to the form. The state will be loaded below + $state = $source ?: array(); + foreach ($state as $k=>$v) { + $info = array(); + if (!preg_match('/^:(\w+)(?:!(\d+))?\+search/', $k, $info)) { + continue; } + list($k,) = explode('+', $k, 2); + $state['fields'][] = $k; + } + return $this->getForm($state); + } + + function getFormFromSession($key) { + if (isset($_SESSION[$key])) { + return $this->loadFromState($_SESSION[$key]); } - return $this->getForm($source); } function getForm($source=false) { @@ -643,6 +649,7 @@ class SavedSearch extends VerySimpleModel { $searchable = $this->getCurrentSearchFields($source); $fields = array( 'keywords' => new TextboxField(array( + 'id' => 3001, 'configuration' => array( 'size' => 40, 'length' => 400, @@ -656,7 +663,10 @@ class SavedSearch extends VerySimpleModel { $fields = array_merge($fields, self::getSearchField($field, $name)); } - $form = new SimpleForm($fields, $source); + // Don't send the state as the souce because it is not in the + // ::parse format (it's in ::to_php format). Instead, source is set + // via ::loadState() below + $form = new AdvancedSearchForm($fields, $source); $form->addValidator(function($form) { $selected = 0; foreach ($form->getFields() as $F) { @@ -669,41 +679,48 @@ class SavedSearch extends VerySimpleModel { if (!$selected) $form->addError(__('No fields selected for searching')); }); + if ($source) + $form->loadState($source); return $form; } function getCurrentSearchFields($source=false) { $core = array( - 'state' => new TicketStateChoiceField(array( - 'label' => __('State'), - )), 'status_id' => new TicketStatusChoiceField(array( + 'id' => 3101, 'label' => __('Status'), )), - 'flags' => new TicketFlagChoiceField(array( - 'label' => __('Flags'), - )), 'dept_id' => new DepartmentChoiceField(array( + 'id' => 3102, 'label' => __('Department'), )), 'assignee' => new AssigneeChoiceField(array( + 'id' => 3103, 'label' => __('Assignee'), )), 'topic_id' => new HelpTopicChoiceField(array( + 'id' => 3104, 'label' => __('Help Topic'), )), 'created' => new DateTimeField(array( + 'id' => 3105, 'label' => __('Created'), )), 'duedate' => new DateTimeField(array( + 'id' => 3106, 'label' => __('Due Date'), )), ); // Add 'other' fields added dynamically if (is_array($source) && isset($source['fields'])) { + $extended = self::getExtendedTicketFields(); foreach ($source['fields'] as $f) { $info = array(); + if (isset($extended[$f])) { + $core[$f] = $extended[$f]; + continue; + } if (!preg_match('/^:(\w+)!(\d+)/', $f, $info)) { continue; } @@ -717,27 +734,56 @@ class SavedSearch extends VerySimpleModel { } } } - return $core; } + static function getExtendedTicketFields() { + return array( +# ':user' => new UserChoiceField(array( +# 'label' => __('Ticket Owner'), +# )), +# ':org' => new OrganizationChoiceField(array( +# 'label' => __('Organization'), +# )), + ':source' => new TicketSourceChoiceField(array( + 'id' => 3201, + 'label' => __('Source'), + )), + ':state' => new TicketStateChoiceField(array( + 'id' => 3202, + 'label' => __('State'), + )), + ':flags' => new TicketFlagChoiceField(array( + 'id' => 3203, + 'label' => __('Flags'), + )), + ); + } + static function getSearchField($field, $name) { + $baseId = $field->getId() * 20; $pieces = array(); $pieces["{$name}+search"] = new BooleanField(array( - 'configuration' => array('desc' => $field->get('label')) + 'id' => $baseId + 50000, + 'configuration' => array( + 'desc' => $field->get('label'), + ), )); $methods = $field->getSearchMethods(); $pieces["{$name}+method"] = new ChoiceField(array( + 'id' => $baseId + 50001, 'choices' => $methods, 'default' => key($methods), 'visibility' => new VisibilityConstraint(new Q(array( "{$name}+search__eq" => true, )), VisibilityConstraint::HIDDEN), )); + $offs = 0; foreach ($field->getSearchMethodWidgets() as $m=>$w) { if (!$w) continue; list($class, $args) = $w; + $args['id'] = $baseId + 50002 + $offs++; $args['required'] = true; $args['visibility'] = new VisibilityConstraint(new Q(array( "{$name}+method__eq" => $m, @@ -749,13 +795,14 @@ class SavedSearch extends VerySimpleModel { function mangleQuerySet(QuerySet $qs, $form=false) { $form = $form ?: $this->getForm(); - $searchable = $this->getCurrentSearchFields($form->getSource()); + $searchable = $this->getCurrentSearchFields($form->state); $qs = clone $qs; // Figure out fields to search on foreach ($form->getFields() as $f) { if (substr($f->get('name'), -7) == '+search' && $f->getClean()) { $name = substr($f->get('name'), 0, -7); + $filter = new Q(); // Determine the search method and fetch the original field if (($M = $form->getField("{$name}+method")) && ($method = $M->getClean()) @@ -777,12 +824,6 @@ class SavedSearch extends VerySimpleModel { ); $column = $field->get('name') ?: 'field_'.$field->get('id'); list($type,$id) = explode('!', $name, 2); - $OP = $other_paths[$type]; - if ($type == ':field') { - $DF = DynamicFormField::lookup($id); - TicketModel::registerCustomData($DF->form); - $OP = 'cdata+'.$DF->form->id.'__'; - } // XXX: Last mile — find a better idea switch (array($type, $column)) { case array(':user', 'name'): @@ -795,13 +836,21 @@ class SavedSearch extends VerySimpleModel { $name = 'user__org__name'; break; default: + if ($type == ':field' && $id) { + $name = 'entries__answers__value'; + $filter->add(array('entries__answers__field_id' => $id)); + break; + } + $OP = $other_paths[$type]; $name = $OP . $column; } } // Add the criteria to the QuerySet - if ($Q = $field->getSearchQ($method, $value, $name)) - $qs = $qs->filter($Q); + if ($Q = $field->getSearchQ($method, $value, $name)) { + $filter->add($Q); + $qs = $qs->filter($filter); + } } } } @@ -846,6 +895,15 @@ class SavedSearch extends VerySimpleModel { } } +class AdvancedSearchForm extends SimpleForm { + var $state; + + function __construct($fields, $state) { + parent::__construct($fields); + $this->state = $state; + } +} + // Advanced search special fields class HelpTopicChoiceField extends ChoiceField { @@ -1008,6 +1066,29 @@ class TicketFlagChoiceField extends ChoiceField { } } +class TicketSourceChoiceField extends ChoiceField { + function getChoices() { + return array( + 'w' => __('Web'), + 'e' => __('Email'), + 'p' => __('Phone'), + 'a' => __('API'), + 'o' => __('Other'), + ); + } + + function getSearchMethods() { + return array( + 'includes' => __('is'), + '!includes' => __('is not'), + ); + } + + function getSearchQ($method, $value, $name=false) { + return parent::getSearchQ($method, $value, 'source'); + } +} + class TicketStatusChoiceField extends SelectionField { static $widget = 'ChoicesWidget'; diff --git a/include/class.template.php b/include/class.template.php index 18c04548731719bcc09a169d50f0d639dc437a8c..f3a459f6de0855b27c0aca7647107d2733e8481e 100644 --- a/include/class.template.php +++ b/include/class.template.php @@ -161,8 +161,7 @@ class EmailTemplateGroup { 'task.overdue.alert'=>array( 'group'=>'c.task', 'name'=>/* @trans */ 'Overdue Task Alert', - 'desc'=>/* @trans */ 'Alert sent to agents on stale or overdue - task.', + 'desc'=>/* @trans */ 'Alert sent to agents on stale or overdue task.', ), ); diff --git a/include/class.thread.php b/include/class.thread.php index eb02999f4161070eb369d97fd6f360dccfe4ac01..f64d1c7dd09ddf6eabfd131e3beb1ba97173aff4 100644 --- a/include/class.thread.php +++ b/include/class.thread.php @@ -86,15 +86,20 @@ class Thread extends VerySimpleModel { return $this->entries->count(); } + var $_entries; function getEntries($criteria=false) { - $base = $this->entries->annotate(array( - 'has_attachments' => SqlAggregate::COUNT('attachments', false, - new Q(array('attachments__inline'=>0))) - )); - $base->exclude(array('flags__hasbit'=>ThreadEntry::FLAG_HIDDEN)); - if ($criteria) - $base->filter($criteria); - return $base; + if (!isset($this->_entries)) { + $this->_entries = $this->entries->annotate(array( + 'has_attachments' => SqlAggregate::COUNT(SqlCase::N() + ->when(array('attachments__inline'=>0), 1) + ->otherwise(null) + ), + )); + $this->_entries->exclude(array('flags__hasbit'=>ThreadEntry::FLAG_HIDDEN)); + if ($criteria) + $this->_entries->filter($criteria); + } + return $this->_entries; } // Collaborators @@ -134,7 +139,7 @@ class Thread extends VerySimpleModel { return $collaborators; } - function addCollaborator($user, $vars, &$errors) { + function addCollaborator($user, $vars, &$errors, $event=true) { if (!$user) return null; @@ -147,6 +152,16 @@ class Thread extends VerySimpleModel { $this->_collaborators = null; + if ($event) + $this->getEvents()->log($this->getObject(), + 'collab', + array('add' => array($user->getId() => array( + 'name' => $user->getName()->getOriginal(), + 'src' => @$vars['source'], + )) + ) + ); + return $c; } @@ -164,11 +179,9 @@ class Thread extends VerySimpleModel { && $c->delete()) $collabs[] = $c; } - - $this->getObject()->postThreadEntry('N', - array( - 'title' => _S('Collaborators Removed'), - 'note' => implode("<br>", $collabs))); + $this->getEvents()->log($this->getObject(), 'collab', array( + 'del' => array($c->user_id => array('name' => $c->getName()->getOriginal())) + )); } //statuses @@ -205,6 +218,11 @@ class Thread extends VerySimpleModel { if ($type && is_array($type)) $entries->filter(array('type__in' => $type)); + // Precache all the attachments on this thread + AttachmentFile::objects()->filter(array( + 'attachments__thread_entry__thread__id' => $this->id + ))->all(); + $events = $this->getEvents(); $inc = ($mode == self::MODE_STAFF) ? STAFFINC_DIR : CLIENTINC_DIR; include $inc . 'templates/thread-entries.tmpl.php'; @@ -381,10 +399,10 @@ class Thread extends VerySimpleModel { // Try not to destroy the format of the body $header = sprintf("Received From: %s <%s>\n\n", $mailinfo['name'], $mailinfo['email']); - if ($body instanceof HtmlThreadBody) + if ($body instanceof HtmlThreadEntryBody) $header = nl2br(Format::htmlchars($header)); // Add the banner to the top of the message - if ($body instanceof ThreadBody) + if ($body instanceof ThreadEntryBody) $body->prepend($header); $vars['message'] = $body; $vars['userId'] = 0; //Unknown user! //XXX: Assume ticket owner? @@ -499,6 +517,9 @@ class Thread extends VerySimpleModel { $this->entries->delete(); + // Null out the events + $this->events->update(array('thread_id' => 0)); + return true; } @@ -562,6 +583,7 @@ implements TemplateVariable { const FLAG_EDITED = 0x0002; const FLAG_HIDDEN = 0x0004; const FLAG_GUARDED = 0x0008; // No replace on edit + const FLAG_RESENT = 0x0010; const PERM_EDIT = 'thread.edit'; @@ -759,6 +781,17 @@ implements TemplateVariable { return $this->user; } + function getEditor() { + static $types = array( + 'U' => 'User', + 'S' => 'Staff', + ); + if (!isset($types[$this->editor_type])) + return null; + + return $types[$this->editor_type]::lookup($this->editor); + } + function getName() { if ($this->staff_id) return $this->staff->getName(); @@ -908,8 +941,9 @@ implements TemplateVariable { } if ($filename) { // This should be a noop since the ORM caches on PK - $file = AttachmentFile::lookup($fileId); - if ($file->name != $filename) + $F = $F ?: AttachmentFile::lookup($fileId); + // XXX: This is not Unicode safe + if ($F && 0 !== strcasecmp($F->name, $filename)) $att->name = $filename; } @@ -1522,83 +1556,23 @@ class ThreadEvent extends VerySimpleModel { 'overdue' => 'time', 'transferred' => 'share-alt', 'edited' => 'pencil', + 'closed' => 'thumbs-up-alt', + 'reopened' => 'rotate-right', + 'resent' => 'reply-all icon-flip-horizontal', ); return @$icons[$this->state] ?: 'chevron-sign-right'; } function getDescription($mode=self::MODE_STAFF) { - static $descs; - if (!isset($descs)) - $descs = array( - 'assigned' => __('Assignee changed by <b>{username}</b> to <strong>{assignees}</strong> {timestamp}'), - 'assigned:staff' => __('<b>{username}</b> assigned this to <strong>{<Staff>data.staff}</strong> {timestamp}'), - 'assigned:team' => __('<b>{username}</b> assigned this to <strong>{<Team>data.team}</strong> {timestamp}'), - 'assigned:claim' => __('<b>{username}</b> claimed this {timestamp}'), - 'collab:org' => __('Collaborators for {<Organization>data.org} organization added'), - 'collab:del' => function($evt) { - $data = $evt->getData(); - $base = __('<b>{username}</b> removed %s from the collaborators.'); - return $data['del'] - ? Format::htmlchars(sprintf($base, implode(', ', $data['del']))) - : 'somebody'; - }, - 'collab:add' => function($evt) { - $data = $evt->getData(); - $base = __('<b>{username}</b> added <strong>%s</strong> as collaborators {timestamp}'); - $collabs = array(); - if ($data['add']) { - foreach ($data['add'] as $c) { - $collabs[] = Format::htmlchars($c); - } - } - return $collabs - ? sprintf($base, implode(', ', $collabs)) - : 'somebody'; - }, - 'created' => __('Created by <b>{username}</b> {timestamp}'), - 'closed' => __('Closed by <b>{username}</b> {timestamp}'), - 'reopened' => __('Reopened by <b>{username}</b> {timestamp}'), - 'edited:owner' => __('<b>{username}</b> changed ownership to {<User>data.owner} {timestamp}'), - 'edited:status' => __('<b>{username}</b> changed the status to <strong>{<TicketStatus>data.status}</strong> {timestamp}'), - 'overdue' => __('Flagged as overdue by the system {timestamp}'), - 'transferred' => __('<b>{username}</b> transferred this to <strong>{dept}</strong> {timestamp}'), - 'edited:fields' => function($evt) use ($mode) { - $base = __('Updated by <b>{username}</b> {timestamp} — %s'); - $data = $evt->getData(); - $fields = $changes = array(); - foreach (DynamicFormField::objects()->filter(array( - 'id__in' => array_keys($data['fields']) - )) as $F) { - $fields[$F->id] = $F; - } - foreach ($data['fields'] as $id=>$f) { - $field = $fields[$id]; - if ($mode == self::MODE_CLIENT && !$field->isVisibleToUsers()) - continue; - list($old, $new) = $f; - $impl = $field->getImpl($field); - $before = $impl->to_php($old); - $after = $impl->to_php($new); - $changes[] = sprintf('<strong>%s</strong> %s', - $field->getLocal('label'), $impl->whatChanged($before, $after)); - } - if (!$changes) - return ''; - return sprintf($base, implode(', ', $changes)); - }, - ); - $self = $this; - $data = $this->getData(); - $state = $this->state; - if (is_array($data)) { - foreach (array_keys($data) as $k) - if (isset($descs[$state . ':' . $k])) - $state .= ':' . $k; - } - $description = $descs[$state]; - if (is_callable($description)) - $description = $description($this); + // Abstract description + return $this->template(sprintf( + __('%s by {somebody} {timestamp}'), + $this->state + )); + } + function template($description) { + $self = $this; return preg_replace_callback('/\{(<(?P<type>([^>]+))>)?(?P<key>[^}.]+)(\.(?P<data>[^}]+))?\}/', function ($m) use ($self) { switch ($m['key']) { @@ -1613,7 +1587,7 @@ class ThreadEvent extends VerySimpleModel { $assignees[] = $T->getLocalName(); } return implode('/', $assignees); - case 'username': + case 'somebody': $name = $self->getUserName(); if ($url = $self->getAvatar()) $name = "<img class=\"avatar\" src=\"{$url}\"> ".$name; @@ -1657,40 +1631,53 @@ class ThreadEvent extends VerySimpleModel { function render($mode) { $inc = ($mode == self::MODE_STAFF) ? STAFFINC_DIR : CLIENTINC_DIR; - $event = $this; + $event = $this->getTypedEvent(); include $inc . 'templates/thread-event.tmpl.php'; } - static function create($ht=false) { + static function create($ht=false, $user=false) { $inst = parent::create($ht); $inst->timestamp = SqlFunction::NOW(); global $thisstaff, $thisclient; - if ($thisstaff) { + $user = is_object($user) ? $user : $thisstaff ?: $thisclient; + if ($user instanceof Staff) { $inst->uid_type = 'S'; - $inst->uid = $thisstaff->getId(); + $inst->uid = $user->getId(); } - else if ($thisclient) { + elseif ($user instanceof User) { $inst->uid_type = 'U'; - $inst->uid = $thisclient->getId(); + $inst->uid = $user->getId(); } return $inst; } - static function forTicket($ticket, $state) { + static function forTicket($ticket, $state, $user=false) { $inst = static::create(array( 'staff_id' => $ticket->getStaffId(), 'team_id' => $ticket->getTeamId(), 'dept_id' => $ticket->getDeptId(), 'topic_id' => $ticket->getTopicId(), - )); - if (!isset($inst->uid_type) && $state == self::CREATED) { - $inst->uid_type = 'U'; - $inst->uid = $ticket->getOwnerId(); - } + ), $user); return $inst; } + + function getTypedEvent() { + static $subclasses; + + if (!isset($subclasses)) { + $parent = get_class($this); + $subclasses = array(); + foreach (get_declared_classes() as $class) { + if (is_subclass_of($class, $parent)) + $subclasses[$class::$state] = $class; + } + } + if (!($class = $subclasses[$this->state])) + return $this; + return new $class($this->ht); + } } class ThreadEvents extends InstrumentedList { @@ -1700,13 +1687,27 @@ class ThreadEvents extends InstrumentedList { ->update(array('annulled' => 1)); } - function log($object, $state, $data=null, $annul=null, $username=null) { + /** + * Add an event to the thread activity log. + * + * Parameters: + * $object - Object to log activity for + * $state - State name of the activity (one of 'created', 'edited', + * 'deleted', 'closed', 'reopened', 'error', 'collab', 'resent', + * 'assigned', 'transferred') + * $data - (array?) Details about the state change + * $user - (string|User|Staff) user triggering the state change + * $annul - (state) a corresponding state change that is annulled by + * this event + */ + function log($object, $state, $data=null, $user=null, $annul=null) { global $thisstaff, $thisclient; if ($object instanceof Ticket) - $event = ThreadEvent::forTicket($object, $state); + // TODO: Use $object->createEvent() + $event = ThreadEvent::forTicket($object, $state, $user); else - $event = ThreadEvent::create(); + $event = ThreadEvent::create(false, $user); # Annul previous entries if requested (for instance, reopening a # ticket will annul an 'closed' entry). This will be useful to @@ -1715,11 +1716,14 @@ class ThreadEvents extends InstrumentedList { $this->annul($annul); } - if ($username === null) { - if ($thisstaff) { - $username = $thisstaff->getUserName(); + $username = $user; + $user = is_object($user) ? $user : $thisclient ?: $thisstaff; + if (!is_string($username)) { + if ($user instanceof Staff) { + $username = $user->getUserName(); } - else if ($thisclient) { + // XXX: Use $user here + elseif ($thisclient) { if ($thisclient->hasAccount) $username = $thisclient->getAccount()->getUserName(); if (!$username) @@ -1748,6 +1752,186 @@ class ThreadEvents extends InstrumentedList { } } +class AssignmentEvent extends ThreadEvent { + static $icon = 'hand-right'; + static $state = 'assigned'; + + function getDescription($mode=self::MODE_STAFF) { + $data = $this->getData(); + switch (true) { + case !is_array($data): + default: + $desc = __('Assignee changed by <b>{somebody}</b> to <strong>{assignees}</strong> {timestamp}'); + break; + case isset($data['staff']): + $desc = __('<b>{somebody}</b> assigned this to <strong>{<Staff>data.staff}</strong> {timestamp}'); + break; + case isset($data['team']): + $desc = __('<b>{somebody}</b> assigned this to <strong>{<Team>data.team}</strong> {timestamp}'); + break; + case isset($data['claim']): + $desc = __('<b>{somebody}</b> claimed this {timestamp}'); + break; + } + return $this->template($desc); + } +} + +class CloseEvent extends ThreadEvent { + static $icon = 'thumbs-up-alt'; + static $state = 'closed'; + + function getDescription($mode=self::MODE_STAFF) { + return $this->template(__('Closed by <b>{somebody}</b> {timestamp}')); + } +} + +class CollaboratorEvent extends ThreadEvent { + static $icon = 'group'; + static $state = 'collab'; + + function getDescription($mode=self::MODE_STAFF) { + $data = $this->getData(); + switch (true) { + case isset($data['org']): + $desc = __('Collaborators for {<Organization>data.org} organization added'); + break; + case isset($data['del']): + $base = __('<b>{somebody}</b> removed <strong>%s</strong> from the collaborators {timestamp}'); + $collabs = array(); + $users = User::objects()->filter(array('id__in' => array_keys($data['del']))); + foreach ($data['del'] as $id=>$c) { + $U = false; + foreach ($users as $user) { + if ($user->id == $id) { + $U = $user; + break; + } + } + $collabs[] = Format::htmlchars($U ? $U->getName() : @$c['name'] ?: $c); + } + $desc = sprintf($base, implode(', ', $collabs)); + break; + case isset($data['add']): + $base = __('<b>{somebody}</b> added <strong>%s</strong> as collaborators {timestamp}'); + $collabs = array(); + if ($data['add']) { + $users = User::objects()->filter(array('id__in' => array_keys($data['add']))); + foreach ($data['add'] as $id=>$c) { + $U = false; + foreach ($users as $user) { + if ($user->id == $id) { + $U = $user; + break; + } + } + $c = sprintf(__("%s via %s" + /* e.g. "Me <me@company.me> via Email (to)" */), + Format::htmlchars($U ? $U->getName() : @$c['name'] ?: $c), + $c['src'] ?: '?' + ); + $collabs[] = $c; + } + } + $desc = $collabs + ? sprintf($base, implode(', ', $collabs)) + : 'somebody'; + break; + } + return $this->template($desc); + } +} + +class CreationEvent extends ThreadEvent { + static $icon = 'magic'; + static $state = 'created'; + + function getDescription($mode=self::MODE_STAFF) { + return $this->template(__('Created by <b>{somebody}</b> {timestamp}')); + } +} + +class EditEvent extends ThreadEvent { + static $icon = 'pencil'; + static $state = 'edited'; + + function getDescription($mode=self::MODE_STAFF) { + $data = $this->getData(); + switch (true) { + case isset($data['owner']): + $desc = __('<b>{somebody}</b> changed ownership to {<User>data.owner} {timestamp}'); + break; + case isset($data['status']): + $desc = __('<b>{somebody}</b> changed the status to <strong>{<TicketStatus>data.status}</strong> {timestamp}'); + break; + case isset($data['fields']): + $base = __('Updated by <b>{somebody}</b> {timestamp} — %s'); + $fields = $changes = array(); + foreach (DynamicFormField::objects()->filter(array( + 'id__in' => array_keys($data['fields']) + )) as $F) { + $fields[$F->id] = $F; + } + foreach ($data['fields'] as $id=>$f) { + $field = $fields[$id]; + if ($mode == self::MODE_CLIENT && !$field->isVisibleToUsers()) + continue; + list($old, $new) = $f; + $impl = $field->getImpl($field); + $before = $impl->to_php($old); + $after = $impl->to_php($new); + $changes[] = sprintf('<strong>%s</strong> %s', + $field->getLocal('label'), $impl->whatChanged($before, $after)); + } + $desc = $changes + ? sprintf($base, implode(', ', $changes)) : ''; + break; + } + + return $this->template($desc); + } +} + +class OverdueEvent extends ThreadEvent { + static $icon = 'time'; + static $state = 'overdue'; + + function getDescription($mode=self::MODE_STAFF) { + return $this->template(__('Flagged as overdue by the system {timestamp}')); + } +} + +class ReopenEvent extends ThreadEvent { + static $icon = 'rotate-right'; + static $state = 'reopened'; + + function getDescription($mode=self::MODE_STAFF) { + return $this->template(__('Reopened by <b>{somebody}</b> {timestamp}')); + } +} + +class ResendEvent extends ThreadEvent { + static $icon = 'reply-all icon-flip-horizontal'; + static $state = 'resent'; + + function getDescription($mode=self::MODE_STAFF) { + return $this->template(__('<b>{somebody}</b> resent <strong><a href="#thread-entry-{data.entry}">a previous response</a></strong> {timestamp}')); + } +} + +class TransferEvent extends ThreadEvent { + static $icon = 'share-alt'; + static $state = 'transferred'; + + function getDescription($mode=self::MODE_STAFF) { + return $this->template(__('<b>{somebody}</b> transferred this to <strong>{dept}</strong> {timestamp}')); + } +} + +class ViewEvent extends ThreadEvent { + static $state = 'viewed'; +} + class ThreadEntryBody /* extends SplString */ { static $types = array('text', 'html'); @@ -2088,10 +2272,9 @@ class NoteThreadEntry extends ThreadEntry { // Object specific thread utils. class ObjectThread extends Thread implements TemplateVariable { - private $_entries = array(); - static $types = array( ObjectModel::OBJECT_TYPE_TASK => 'TaskThread', + ObjectModel::OBJECT_TYPE_TICKET => 'TicketThread', ); var $counts; diff --git a/include/class.thread_actions.php b/include/class.thread_actions.php index 15758498fdabb968929300fded2c0402f3af7610..df4cf37e02e1d48cb087a47b293053d20ab17236 100644 --- a/include/class.thread_actions.php +++ b/include/class.thread_actions.php @@ -88,12 +88,12 @@ $.dialog(url, [201], function(xhr, resp) { var json = JSON.parse(resp); if (!json || !json.thread_id) return; - $('#thread-id-'+json.thread_id) - .attr('id', 'thread-id-' + json.new_id) - .find('div') - .html(json.body) - .closest('td') - .effect('highlight') + $('#thread-entry-'+json.thread_id) + .attr('id', 'thread-entry-' + json.new_id) + .html(json.entry) + .find('.thread-body') + .delay(500) + .effect('highlight'); }, {size:'large'}); JS , $this->getAjaxUrl()); @@ -118,10 +118,10 @@ JS } function updateEntry($guard=false) { + global $thisstaff; + $old = $this->entry; - $type = ($old->format == 'html') - ? 'HtmlThreadEntryBody' : 'TextThreadEntryBody'; - $new = new $type($_POST['body']); + $new = ThreadEntryBody::fromFormattedText($_POST['body'], $old->format); if ($new->getClean() == $old->body) // No update was performed @@ -139,7 +139,7 @@ JS 'pid' => $old->id, // Add in new stuff - 'title' => $_POST['title'], + 'title' => Format::htmlchars($_POST['title']), 'body' => $new, 'ip_address' => $_SERVER['REMOTE_ADDR'], )); @@ -151,6 +151,8 @@ JS // that way for email header lookups and such to remain consistent if ($old->flags & ThreadEntry::FLAG_EDITED + // If editing another person's edit, make a new entry + and ($old->editor == $thisstaff->getId() && $old->editor_type == 'S') and !($old->flags & ThreadEntry::FLAG_GUARDED) ) { // Replace previous edit -------------------------- @@ -162,20 +164,24 @@ JS $old = $original; } - // Mark the new entry as edited (but not hidden) - $entry->flags = ($old->flags & ~ThreadEntry::FLAG_HIDDEN) + // Mark the new entry as edited (but not hidden nor guarded) + $entry->flags = ($old->flags & ~(ThreadEntry::FLAG_HIDDEN | ThreadEntry::FLAG_GUARDED)) | ThreadEntry::FLAG_EDITED; // Guard against deletes on future edit if requested. This is done // if an email was triggered by the last edit. In such a case, it - // should not be replace by a subsequent edit. + // should not be replaced by a subsequent edit. if ($guard) $entry->flags |= ThreadEntry::FLAG_GUARDED; - // Sort in the same place in the thread — XXX: Add a `sequence` id + // Log the editor + $entry->editor = $thisstaff->getId(); + $entry->editor_type = 'S'; + + // Sort in the same place in the thread $entry->created = $old->created; $entry->updated = SqlFunction::NOW(); - $entry->save(); + $entry->save(true); // Hide the old entry from the object thread $old->flags |= ThreadEntry::FLAG_HIDDEN; @@ -190,10 +196,14 @@ JS if (!($entry = $this->updateEntry())) return $this->trigger__get(); + ob_start(); + include STAFFINC_DIR . 'templates/thread-entry.tmpl.php'; + $content = ob_get_clean(); + Http::response('201', JsonDataEncoder::encode(array( - 'thread_id' => $this->entry->id, + 'thread_id' => $this->entry->id, # This is the old id! 'new_id' => $entry->id, - 'body' => $entry->getBody()->toHtml(), + 'entry' => $content, ))); } } @@ -250,13 +260,17 @@ class TEA_EditAndResendThreadEntry extends TEA_EditThreadEntry { if (!($entry = $this->updateEntry($resend))) return $this->trigger__get(); - if (@$_POST['commit'] == 'resend') + if ($resend) $this->resend($entry); + ob_start(); + include STAFFINC_DIR . 'templates/thread-entry.tmpl.php'; + $content = ob_get_clean(); + Http::response('201', JsonDataEncoder::encode(array( - 'thread_id' => $this->entry->id, + 'thread_id' => $this->entry->id, # This is the old id! 'new_id' => $entry->id, - 'body' => $entry->getBody()->toHtml(), + 'entry' => $content, ))); } @@ -299,6 +313,13 @@ class TEA_EditAndResendThreadEntry extends TEA_EditThreadEntry { } // TODO: Add an option to the dialog $ticket->notifyCollaborators($response, array('signature' => $signature)); + + // Log an event that the item was resent + $ticket->logEvent('resent', array('entry' => $response->id)); + + // Flag the entry as resent + $response->flags |= ThreadEntry::FLAG_RESENT; + $response->save(); } } ThreadEntry::registerAction(/* trans */ 'Manage', 'TEA_EditAndResendThreadEntry'); diff --git a/include/class.ticket.php b/include/class.ticket.php index 48057249b8c6365fa18e5208f617ca5a4315a3e8..217718d956bc22b8415aaa52eb3846fdecbbce35 100644 --- a/include/class.ticket.php +++ b/include/class.ticket.php @@ -73,7 +73,7 @@ class TicketModel extends VerySimpleModel { 'null' => true, ), 'thread' => array( - 'reverse' => 'Thread.ticket', + 'reverse' => 'TicketThread.ticket', 'list' => false, 'null' => true, ), @@ -81,6 +81,12 @@ class TicketModel extends VerySimpleModel { 'reverse' => 'TicketCData.ticket', 'list' => false, ), + 'entries' => array( + 'constraint' => array( + "'T'" => 'DynamicFormEntry.object_type', + 'ticket_id' => 'DynamicFormEntry.object_id', + ), + ), ) ); @@ -144,14 +150,6 @@ class TicketModel extends VerySimpleModel { )); } - function delete() { - - if (($ticket=Ticket::lookup($this->getId())) && @$ticket->delete()) - return true; - - return false; - } - static function registerCustomData(DynamicForm $form) { if (!isset(static::$meta['joins']['cdata+'.$form->id])) { $cdata_class = <<<EOF @@ -163,7 +161,8 @@ class DynamicForm{$form->id} extends DynamicForm { return \$instance; } } -class TicketCdataForm{$form->id} { +class TicketCdataForm{$form->id} +extends VerySimpleModel { static \$meta = array( 'view' => true, 'pk' => array('ticket_id'), @@ -179,13 +178,19 @@ class TicketCdataForm{$form->id} { } EOF; eval($cdata_class); - static::$meta['joins']['cdata+'.$form->id] = array( - 'reverse' => 'TicketCdataForm'.$form->id.'.ticket', - 'null' => true, + $join = array( + 'constraint' => array('ticket_id' => 'TicketCdataForm'.$form->id.'.ticket_id'), + 'list' => true, ); // This may be necessary if the model has already been inspected if (static::$meta instanceof ModelMeta) - static::$meta->processJoin(static::$meta['joins']['cdata+'.$form->id]); + static::$meta->addJoin('cdata+'.$form->id, $join); + else { + static::$meta['joins']['cdata+'.$form->id] = array( + 'constraint' => array('ticket_id' => 'TicketCdataForm'.$form->id.'.ticket_id'), + 'list' => true, + ); + } } } @@ -216,10 +221,12 @@ class Ticket extends TicketModel implements RestrictedAccess, Threadable { static $meta = array( - 'select_related' => array('topic', 'staff', 'user', 'team', 'dept', 'sla', 'thread'), + 'select_related' => array('topic', 'staff', 'user', 'team', 'dept', 'sla', 'thread', + 'user__default_email'), ); var $lastMsgId; + var $last_message; var $owner; // TicketOwner var $_user; // EndUser @@ -236,12 +243,15 @@ implements RestrictedAccess, Threadable { function loadDynamicData() { if (!isset($this->_answers)) { $this->_answers = array(); - foreach (DynamicFormEntry::forTicket($this->getId(), true) as $form) { - foreach ($form->getAnswers() as $answer) { - $tag = mb_strtolower($answer->field->name) - ?: 'field.' . $answer->field->id; - $this->_answers[$tag] = $answer; - } + foreach (DynamicFormEntryAnswer::objects() + ->filter(array( + 'entry__object_id' => $this->getId(), + 'entry__object_type' => 'T' + )) as $answer + ) { + $tag = mb_strtolower($answer->field->name) + ?: 'field.' . $answer->field->id; + $this->_answers[$tag] = $answer; } } return $this->_answers; @@ -654,20 +664,20 @@ implements RestrictedAccess, Threadable { } function getLastRespondent() { - if (!isset($this->lastrespondent)) { $this->lastresponent = Staff::objects() ->filter(array( 'staff_id' => static::objects() ->filter(array( - 'thread__entry__type' => 'R', - 'thread__entry__staff_id__gt' => 0 + 'thread__entries__type' => 'R', + 'thread__entries__staff_id__gt' => 0 )) - ->values_flat('thread__entry__staff_id') - ->order_by('-thread__entry__id') - ->first() + ->values_flat('thread__entries__staff_id') + ->order_by('-thread__entries__id') + ->limit(1) )) - ->first(); + ->first() + ?: false; } return $this->lastrespondent; } @@ -806,21 +816,15 @@ implements RestrictedAccess, Threadable { return $fields[0]; } - function addCollaborator($user, $vars, &$errors) { + function addCollaborator($user, $vars, &$errors, $event=true) { if (!$user || $user->getId() == $this->getOwnerId()) return null; - $vars = array_merge(array( - 'threadId' => $this->getThreadId(), - 'userId' => $user->getId()), $vars); - if (!($c=Collaborator::add($vars, $errors))) - return null; - - $this->collaborators = null; - $this->recipients = null; - - $this->logEvent('collab', array('add' => array($c->toString()))); + if ($c = $this->getThread()->addCollaborator($user, $vars, $errors, $event)) { + $this->collaborators = null; + $this->recipients = null; + } return $c; } @@ -1027,11 +1031,9 @@ implements RestrictedAccess, Threadable { if ($this->getStatusId() == $status->getId()) return true; - $this->status = $status; - - //TODO: move this up. + // Perform checks on the *new* status, _before_ the status changes $ecb = null; - switch($status->getState()) { + switch ($status->getState()) { case 'closed': if ($this->getMissingRequiredFields()) { $errors['err'] = sprintf(__( @@ -1043,7 +1045,7 @@ implements RestrictedAccess, Threadable { $this->duedate = null; if ($thisstaff && $set_closing_agent) $this->staff = $thisstaff; - $this->clearOverdue(); + $this->clearOverdue(false); $ecb = function($t) { $t->logEvent('closed'); @@ -1055,7 +1057,7 @@ implements RestrictedAccess, Threadable { if ($this->isClosed()) { $this->closed = $this->lastupdate = $this->reopened = SqlFunction::NOW(); $ecb = function ($t) { - $t->logEvent('reopened', false, 'closed'); + $t->logEvent('reopened', false, null, 'closed'); }; } @@ -1068,24 +1070,19 @@ implements RestrictedAccess, Threadable { } + $this->status = $status; if (!$this->save()) return false; // Log status change b4 reload — if currently has a status. (On new // ticket, the ticket is opened and thereafter the status is set to // the requested status). - if ($current_status = $this->getStatus()) { - $note = sprintf(__('Status changed from %1$s to %2$s by %3$s'), - $this->getStatus(), - $status, - $thisstaff ?: 'SYSTEM'); - + if ($hadStatus) { $alert = false; if ($comments) { - $note .= sprintf('<hr>%s', $comments); // Send out alerts if comments are included $alert = true; - $this->logNote(__('Status Changed'), $note, $thisstaff, $alert); + $this->logNote(__('Status Changed'), $comments, $thisstaff, $alert); } } // Log events via callback @@ -1139,7 +1136,7 @@ implements RestrictedAccess, Threadable { if (!($status=$this->getStatus()->getReopenStatus())) $status = $cfg->getDefaultTicketStatusId(); - return $status ? $this->setStatus($status, 'Reopened') : false; + return $status ? $this->setStatus($status) : false; } function onNewTicket($message, $autorespond=true, $alertstaff=true) { @@ -1575,10 +1572,12 @@ implements RestrictedAccess, Threadable { ); // Send the alerts. $sentlist = array(); - $options = array( - 'inreplyto'=>$note->getEmailMessageId(), - 'references'=>$note->getEmailReferences(), - 'thread'=>$note); + $options = $note instanceof ThreadEntry + ? array( + 'inreplyto'=>$note->getEmailMessageId(), + 'references'=>$note->getEmailReferences(), + 'thread'=>$note) + : array(); foreach ($recipients as $k=>$staff) { if (!is_object($staff) || !$staff->isAvailable() @@ -1796,7 +1795,7 @@ implements RestrictedAccess, Threadable { return true; } - function clearOverdue() { + function clearOverdue($save=true) { if (!$this->isOverdue()) return true; @@ -1812,7 +1811,7 @@ implements RestrictedAccess, Threadable { if ($this->getSLADueDate() && Misc::db2gmtime($this->getSLADueDate()) <= Misc::gmtime()) $this->sla = null; - return $this->save(); + return $save ? $this->save() : true; } //Dept Tranfer...with alert.. done by staff @@ -2081,14 +2080,17 @@ implements RestrictedAccess, Threadable { continue; if (($user=User::fromVars($recipient))) - if ($c=$this->addCollaborator($user, $info, $errors)) + if ($c=$this->addCollaborator($user, $info, $errors, false)) // FIXME: This feels very unwise — should be a // string indexed array for future - $collabs[] = array((string)$c, $recipient['source']); + $collabs[$c->user_id] = array( + 'name' => $c->getName()->getOriginal(), + 'src' => $recipient['source'], + ); } // TODO: Can collaborators add others? if ($collabs) { - $this->logEvent('collab', array('add' => $collabs)); + $this->logEvent('collab', array('add' => $collabs), $message->user); } } @@ -2325,8 +2327,8 @@ implements RestrictedAccess, Threadable { } // History log -- used for statistics generation (pretty reports) - function logEvent($state, $data=null, $annul=null, $staff=null) { - $this->getThread()->getEvents()->log($this, $state, $data, $annul, $staff); + function logEvent($state, $data=null, $user=null, $annul=null) { + $this->getThread()->getEvents()->log($this, $state, $data, $user, $annul); } //Insert Internal Notes @@ -2381,8 +2383,7 @@ implements RestrictedAccess, Threadable { if ($vars['note_status_id'] && ($status=TicketStatus::lookup($vars['note_status_id'])) ) { - if ($this->setStatus($status)) - $this->reload(); + $this->setStatus($status); } $activity = $vars['activity'] ?: _S('New Internal Note'); @@ -2522,8 +2523,8 @@ implements RestrictedAccess, Threadable { if ($errors) return false; - $this->topic_id = $vars['topic_id']; - $this->sla_id = $vars['sla_id']; + $this->topic_id = $vars['topicId']; + $this->sla_id = $vars['slaId']; $this->source = $vars['source']; $this->duedate = $vars['duedate'] ? date('Y-m-d G:i',Misc::dbtime($vars['duedate'].' '.$vars['time'])) @@ -2582,11 +2583,11 @@ implements RestrictedAccess, Threadable { } Signal::send('model.updated', $this); - return true; + return $this->save(); } /*============== Static functions. Use Ticket::function(params); =============nolint*/ - function getIdByNumber($number, $email=null, $ticket=false) { + static function getIdByNumber($number, $email=null, $ticket=false) { if (!$number) return 0; @@ -2608,8 +2609,8 @@ implements RestrictedAccess, Threadable { } } - function lookupByNumber($number, $email=null) { - return self::getIdByNumber($number, $email, true); + static function lookupByNumber($number, $email=null) { + return static::getIdByNumber($number, $email, true); } static function isTicketNumberUnique($number) { @@ -3076,9 +3077,10 @@ implements RestrictedAccess, Threadable { $ticket = parent::create(array( 'created' => SqlFunction::NOW(), 'lastupdate' => SqlFunction::NOW(), + 'number' => $number, 'user' => $user, - 'dept' => $deptId, - 'topicId' => $topicId, + 'dept_id' => $deptId, + 'topic_id' => $topicId, 'ip_address' => $ipaddress, 'source' => $source, )); @@ -3120,6 +3122,9 @@ implements RestrictedAccess, Threadable { $dept = $ticket->getDept(); + // Start tracking ticket lifecycle events (created should come first!) + $ticket->logEvent('created', null, $thisstaff ?: $user); + // Add organizational collaborators if ($org && $org->autoAddCollabs()) { $pris = $org->autoAddPrimaryContactsAsCollabs(); @@ -3163,11 +3168,10 @@ implements RestrictedAccess, Threadable { // Auto assign staff or team - auto assignment based on filter // rules. Both team and staff can be assigned if ($vars['staffId']) - $ticket->assignToStaff($vars['staffId'], _S('Auto Assignment')); + $ticket->assignToStaff($vars['staffId'], false); if ($vars['teamId']) // No team alert if also assigned to an individual agent - $ticket->assignToTeam($vars['teamId'], _S('Auto Assignment'), - !$vars['staffId']); + $ticket->assignToTeam($vars['teamId'], false, !$vars['staffId']); } // Update the estimated due date in the database @@ -3223,9 +3227,6 @@ implements RestrictedAccess, Threadable { $ticket->onOpenLimit($autorespond && strcasecmp($origin, 'staff')); } - /* Start tracking ticket lifecycle events */ - $ticket->logEvent('created'); - // Fire post-create signal (for extra email sending, searching) Signal::send('ticket.created', $ticket); @@ -3293,8 +3294,6 @@ implements RestrictedAccess, Threadable { $ticket->logNote(_S('New Ticket'), $vars['note'], $thisstaff, false); } - $ticket->reload(); - if (!$cfg->notifyONNewStaffTicket() || !isset($vars['alertuser']) || !($dept=$ticket->getDept()) diff --git a/include/class.user.php b/include/class.user.php index 655edfd00e4dcb9cd6f6023018a1192ff54c02c6..3ba2b54ae1d3cb0d7f194036d1a5412199b1afd4 100644 --- a/include/class.user.php +++ b/include/class.user.php @@ -51,7 +51,7 @@ class UserModel extends VerySimpleModel { 'account' => array( 'list' => false, 'null' => true, - 'reverse' => 'UserAccount.user', + 'reverse' => 'ClientAccount.user', ), 'org' => array( 'null' => true, diff --git a/include/client/faq-category.inc.php b/include/client/faq-category.inc.php index eb6606af8b261f9daf9d3e8fed1d17c345423a90..3ce0b7230dcdff9e3ded235c902b8db55b2f9315 100644 --- a/include/client/faq-category.inc.php +++ b/include/client/faq-category.inc.php @@ -13,9 +13,11 @@ if(!defined('OSTCLIENTINC') || !$category || !$category->isPublic()) die('Access <?php $faqs = FAQ::objects() ->filter(array('category'=>$category)) - ->exclude(array('ispublished'=>false)) - ->annotate(array('has_attachments'=>SqlAggregate::COUNT('attachments', false, - array('attachments__inline'=>0)))) + ->exclude(array('ispublished'=>FAQ::VISIBILITY_PRIVATE)) + ->annotate(array('has_attachments' => SqlAggregate::COUNT(SqlCase::N() + ->when(array('attachments__inline'=>0), 1) + ->otherwise(null) + ))) ->order_by('-ispublished', 'question'); if ($faqs->exists(true)) { diff --git a/include/client/kb-categories.inc.php b/include/client/kb-categories.inc.php index c6cc4e930aad3076b4237db87f8d932d6aac1df6..6129180fa8b9a01cf8a66c0e124bdc1c514d447b 100644 --- a/include/client/kb-categories.inc.php +++ b/include/client/kb-categories.inc.php @@ -2,10 +2,13 @@ <div class="span8"> <?php $categories = Category::objects() - ->exclude(Q::any(array('ispublic'=>false, 'faqs__ispublished'=>false))) + ->exclude(Q::any(array( + 'ispublic'=>Category::VISIBILITY_PRIVATE, + 'faqs__ispublished'=>FAQ::VISIBILITY_PRIVATE, + ))) ->annotate(array('faq_count'=>SqlAggregate::COUNT('faqs'))) ->filter(array('faq_count__gt'=>0)); - if ($categories->all()) { ?> + if ($categories->exists(true)) { ?> <div><?php echo __('Click on the category to browse FAQs.'); ?></div> <ul id="kb"> <?php @@ -18,7 +21,7 @@ <?php echo Format::safe_html($C->getLocalDescriptionWithImages()); ?> </div> <?php foreach ($C->faqs - ->exclude(array('ispublished'=>false)) + ->exclude(array('ispublished'=>FAQ::VISIBILITY_PRIVATE)) ->order_by('-views')->limit(5) as $F) { ?> <div class="popular-faq"><i class="icon-file-alt"></i> <a href="faq.php?id=<?php echo $F->getId(); ?>"> diff --git a/include/client/kb-search.inc.php b/include/client/kb-search.inc.php index 5166a6616fbd5ee2ff677124e46aeaaae15b0a2c..a81f5e4fdea5f7064c1f5dacbeb88fab99fb296c 100644 --- a/include/client/kb-search.inc.php +++ b/include/client/kb-search.inc.php @@ -5,7 +5,7 @@ <?php if ($faqs->exists(true)) { echo '<div id="faq">'.sprintf(__('%d FAQs matched your search criteria.'), - count($faqs->all())) + $faqs->count()) .'<ol>'; foreach ($faqs as $F) { echo sprintf( diff --git a/include/client/templates/thread-entries.tmpl.php b/include/client/templates/thread-entries.tmpl.php index d031182cad2fed9947e5bf75a953b05fb81b0374..9b1fc704db6ed350c06b932abd3fcb93bcf00bf6 100644 --- a/include/client/templates/thread-entries.tmpl.php +++ b/include/client/templates/thread-entries.tmpl.php @@ -25,7 +25,7 @@ if (count($entries)) { // changes in dates between thread items. foreach ($entries as $entry) { // Emit all events prior to this entry - while ($event && $event->timestamp <= $entry->created) { + while ($event && $event->timestamp < $entry->created) { $event->render(ThreadEvent::MODE_CLIENT); $events->next(); $event = $events->current(); diff --git a/include/client/templates/thread-entry.tmpl.php b/include/client/templates/thread-entry.tmpl.php index 70e4bbb5c6fae24ba42a48a2c67520b044d03e58..6c16c0660d09c5e71527c90cffd302b0731df65a 100644 --- a/include/client/templates/thread-entry.tmpl.php +++ b/include/client/templates/thread-entry.tmpl.php @@ -57,6 +57,7 @@ if ($user && ($url = $user->get_gravatar(48))) </div> <div class="thread-body" id="thread-id-<?php echo $entry->getId(); ?>"> <div><?php echo $entry->getBody()->toHtml(); ?></div> + <div class="clear"></div> <?php if ($entry->has_attachments) { ?> <div class="attachments"><?php diff --git a/include/client/tickets.inc.php b/include/client/tickets.inc.php index 58d056a1786b624bb3ea0e63e5c10ae62cc24d80..12908ece25e65bc1f0240ca6ca88522973a01744 100644 --- a/include/client/tickets.inc.php +++ b/include/client/tickets.inc.php @@ -1,29 +1,33 @@ <?php if(!defined('OSTCLIENTINC') || !is_object($thisclient) || !$thisclient->isValid()) die('Access Denied'); +$settings = &$_SESSION['client:Q']; + +// Unpack search, filter, and sort requests +if (isset($_REQUEST['clear'])) + $settings = array(); +if (isset($_REQUEST['keywords'])) + $settings['keywords'] = $_REQUEST['keywords']; +if (isset($_REQUEST['topic_id'])) + $settings['topic_id'] = $_REQUEST['topic_id']; +if (isset($_REQUEST['status'])) + $settings['status'] = $_REQUEST['status']; + $tickets = TicketModel::objects(); $qs = array(); $status=null; -if(isset($_REQUEST['status'])) { //Query string status has nothing to do with the real status used below. - $qs += array('status' => $_REQUEST['status']); - //Status we are actually going to use on the query...making sure it is clean! - $status=strtolower($_REQUEST['status']); - switch(strtolower($_REQUEST['status'])) { - case 'open': - $results_type=__('Open Tickets'); - $tickets->filter(array('status__state'=>'open')); - break; - case 'closed': - $results_type=__('Closed Tickets'); - $tickets->filter(array('status__state'=>'closed')); + +if ($settings['status']) + $status = strtolower($settings['status']); + switch ($status) { + default: + $status = 'open'; + case 'open': + case 'closed': + $results_type = ($status == 'closed') ? __('Closed Tickets') : __('Open Tickets'); + $tickets->filter(array('status__state' => $status)); break; - default: - $status=''; //ignore - } -} elseif($thisclient->getNumOpenTickets()) { - $status='open'; //Defaulting to open - $results_type=__('Open Tickets'); } $sortOptions=array('id'=>'number', 'subject'=>'cdata__subject', @@ -49,17 +53,20 @@ $tickets->filter(Q::any(array( ))); // Perform basic search -$search=($_REQUEST['a']=='search' && $_REQUEST['q']); -if($search) { - $qs += array('a' => $_REQUEST['a'], 'q' => $_REQUEST['q']); - if (is_numeric($_REQUEST['q'])) { - $tickets->filter(array('number__startswith'=>$_REQUEST['q'])); +if ($settings['keywords']) { + $q = $settings['keywords']; + if (is_numeric($q)) { + $tickets->filter(array('number__startswith'=>$q)); } else { //Deep search! // Use the search engine to perform the search - $tickets = $ost->searcher->find($_REQUEST['q'], $tickets); + $tickets = $ost->searcher->find($q, $tickets); } } +if ($settings['topic_id']) { + $tickets = $tickets->filter(array('topic_id' => $settings['topic_id'])); +} + TicketForm::ensureDynamicDataView(); $total=$tickets->count(); @@ -89,28 +96,62 @@ $tickets->values( ); ?> -<h1><?php echo __('Tickets');?></h1> -<br> +<div class="search well"> +<div class="flush-left"> <form action="tickets.php" method="get" id="ticketSearchForm"> <input type="hidden" name="a" value="search"> - <input type="text" name="q" size="20" value="<?php echo Format::htmlchars($_REQUEST['q']); ?>"> - <select name="status"> - <option value="">— <?php echo __('Any Status');?> —</option> - <option value="open" - <?php echo ($status=='open') ? 'selected="selected"' : '';?>> - <?php echo _P('ticket-status', 'Open');?> (<?php echo $thisclient->getNumOpenTickets(); ?>)</option> - <?php - if($thisclient->getNumClosedTickets()) { - ?> - <option value="closed" - <?php echo ($status=='closed') ? 'selected="selected"' : '';?>> - <?php echo __('Closed');?> (<?php echo $thisclient->getNumClosedTickets(); ?>)</option> - <?php - } ?> + <input type="search" name="keywords" size="30" value="<?php echo Format::htmlchars($settings['keywords']); ?>"> + <input type="submit" value="<?php echo __('Search');?>"> +<div class="pull-right"> + <?php echo __('Help Topic'); ?>: + <select name="topic_id" class="nowarn" onchange="javascript: this.form.submit(); "> + <option value="">— <?php echo __('All Help Topics');?> —</option> +<?php foreach (Topic::getHelpTopics(true) as $id=>$name) { + $count = $thisclient->getNumTopicTickets($id); + if ($count == 0) + continue; +?> + <option value="<?php echo $id; ?>"i + <?php if ($settings['topic_id'] == $id) echo 'selected="selected"'; ?> + ><?php echo sprintf('%s (%d)', Format::htmlchars($name), + $thisclient->getNumTopicTickets($id)); ?></option> +<?php } ?> </select> - <input type="submit" value="<?php echo __('Go');?>"> +</div> </form> -<a class="refresh" href="<?php echo Format::htmlchars($_SERVER['REQUEST_URI']); ?>"><?php echo __('Refresh'); ?></a> +</div> + +<?php if ($settings['keywords'] || $settings['topic_id'] || $_REQUEST['sort']) { ?> +<div style="margin-top:10px"><strong><a href="?clear" style="color:#777"><i class="icon-remove-circle"></i> <?php echo __('Clear all filters and sort'); ?></a></strong></div> +<?php } ?> + +</div> + + +<h1 style="margin:10px 0"> + <a href="<?php echo Format::htmlchars($_SERVER['REQUEST_URI']); ?>" + ><i class="refresh icon-refresh"></i> + <?php echo __('Tickets'); ?> + </a> + +<div class="pull-right states"> + <small> + <i class="icon-file-alt"></i> + <a class="state <?php if ($status == 'open') echo 'active'; ?>" + href="?<?php echo Http::build_query(array('a' => 'search', 'status' => 'open')); ?>"> + <?php echo sprintf('%s (%d)', _P('ticket-status', 'Open'), $thisclient->getNumOpenTickets()); ?> + </a> + + <span style="color:lightgray">|</span> + + <i class="icon-file-text"></i> + <a class="state <?php if ($status == 'closed') echo 'active'; ?>" + href="?<?php echo Http::build_query(array('a' => 'search', 'status' => 'closed')); ?>"> + <?php echo sprintf('%s (%d)', __('Closed'), $thisclient->getNumClosedTickets()); ?> + </a> + </small> +</div> +</h1> <table id="ticketTable" width="800" border="0" cellspacing="0" cellpadding="0"> <caption><?php echo $showing; ?></caption> <thead> @@ -170,7 +211,7 @@ $tickets->values( } } else { - echo '<tr><td colspan="6">'.__('Your query did not match any records').'</td></tr>'; + echo '<tr><td colspan="5">'.__('Your query did not match any records').'</td></tr>'; } ?> </tbody> diff --git a/include/client/view.inc.php b/include/client/view.inc.php index 498e6d6aecd75a1aaca5e61a432ee731e1e0f12f..2be53bc477943706361921063d776169e1398dce 100644 --- a/include/client/view.inc.php +++ b/include/client/view.inc.php @@ -31,9 +31,9 @@ if ($thisclient && $thisclient->isGuest() <tr> <td colspan="2" width="100%"> <h1> + <a href="tickets.php?id=<?php echo $ticket->getId(); ?>" title="<?php echo __('Reload'); ?>"><i class="refresh icon-refresh"></i></a> <b><?php echo $ticket->getSubject(); ?></b> <small>#<?php echo $ticket->getNumber(); ?></small> - <a href="tickets.php?id=<?php echo $ticket->getId(); ?>" title="<?php echo __('Reload'); ?>"><span class="Icon refresh"> </span></a> <div class="pull-right"> <a class="action-button" href="tickets.php?a=print&id=<?php echo $ticket->getId(); ?>"><i class="icon-print"></i> <?php echo __('Print'); ?></a> diff --git a/include/staff/tasks.inc.php b/include/staff/tasks.inc.php index 01c0be6986e239d6ac055d5559d598f9c200daee..ab826da6aeb550dd0fdcb42c2a4c62a0d86f410b 100644 --- a/include/staff/tasks.inc.php +++ b/include/staff/tasks.inc.php @@ -42,10 +42,10 @@ case 'search': 'cdata__title__contains' => $_REQUEST['query'], ))); break; - } elseif (isset($_SESSION['advsearch'])) { + } elseif (isset($_SESSION['advsearch:tasks'])) { // XXX: De-duplicate and simplify this code - $form = $search->getFormFromSession('advsearch'); - $form->loadState($_SESSION['advsearch']); + $form = $search->getFormFromSession('advsearch:tasks'); + $form->loadState($_SESSION['advsearch:tasks']); $tasks = $search->mangleQuerySet($tasks, $form); $results_type=__('Advanced Search') . '<a class="action-button" href="?clear_filter"><i class="icon-ban-circle"></i> <em>' . __('clear') . '</em></a>'; diff --git a/include/staff/templates/advanced-search-field.tmpl.php b/include/staff/templates/advanced-search-field.tmpl.php index 40712cc18cbf18a639437b3331a71ead5828a7b7..5191a0cf4dac8217e664798d76d925e4623e6e08 100644 --- a/include/staff/templates/advanced-search-field.tmpl.php +++ b/include/staff/templates/advanced-search-field.tmpl.php @@ -1,7 +1,8 @@ <input type="hidden" name="fields[]" value="<?php echo $name; ?>"/> <?php foreach ($fields as $F) { ?> <fieldset id="field<?php echo $F->getWidget()->id; - ?>" <?php if (!$F->isVisible()) echo 'style="display:none;"'; ?>> + ?>" <?php if (!$F->isVisible()) echo 'style="display:none;"'; ?> + <?php if (substr($F->get('name'), -7) === '+search') echo 'class="advanced-search-field"'; ?>> <?php echo $F->render(); ?> <?php foreach ($F->errors() as $E) { ?><div class="error"><?php echo $E; ?></div><?php diff --git a/include/staff/templates/advanced-search.tmpl.php b/include/staff/templates/advanced-search.tmpl.php index cfe1971b1e6d8325c24df867a5ecd5dd235bebca..94d193b598e851fff60469cadcf5e2706b23c32c 100644 --- a/include/staff/templates/advanced-search.tmpl.php +++ b/include/staff/templates/advanced-search.tmpl.php @@ -16,14 +16,17 @@ foreach ($form->errors(true) ?: array() as $message) { foreach ($form->getFields() as $name=>$field) { ?> <fieldset id="field<?php echo $field->getWidget()->id; - ?>" <?php if (!$field->isVisible()) echo 'style="display:none;"'; ?>> + ?>" <?php if (!$field->isVisible()) echo 'class="hidden"'; ?> + <?php if (substr($field->get('name'), -7) === '+search') echo 'class="advanced-search-field"'; ?>> <?php echo $field->render(); ?> <?php foreach ($field->errors() as $E) { ?><div class="error"><?php echo $E; ?></div><?php } ?> </fieldset> - <?php if ($name[0] == ':') { ?> - <input type="hidden" name="fields[]" value="<?php echo $name; ?>"/> + <?php if ($name[0] == ':' && substr($name, -7) == '+search') { + list($N,) = explode('+', $name, 2); +?> + <input type="hidden" name="fields[]" value="<?php echo $N; ?>"/> <?php } } ?> @@ -38,7 +41,7 @@ foreach ($matches as $name => $fields) { ?> foreach ($fields as $id => $desc) { ?> <option value="<?php echo $id; ?>" <?php if (isset($state[$id])) echo 'disabled="disabled"'; - ?>><?php echo $desc; ?></option> + ?>><?php echo ($desc instanceof FormField ? $desc->getLocal('label') : $desc); ?></option> <?php } ?> </optgroup> <?php } ?> diff --git a/include/staff/templates/thread-entries.tmpl.php b/include/staff/templates/thread-entries.tmpl.php index 0be5722c5004d59e9bf62415af5413f5c249e247..7ac199444b8b2607346451a97e5435cb5dda0753 100644 --- a/include/staff/templates/thread-entries.tmpl.php +++ b/include/staff/templates/thread-entries.tmpl.php @@ -28,7 +28,9 @@ if (count($entries)) { $events->next(); $event = $events->current(); } + ?><div id="thread-entry-<?php echo $entry->getId(); ?>"><?php include STAFFINC_DIR . 'templates/thread-entry.tmpl.php'; + ?></div><?php } $i++; } diff --git a/include/staff/templates/thread-entry-edit.tmpl.php b/include/staff/templates/thread-entry-edit.tmpl.php index bab5e4eb65223cb4705a8499a3909ed58a404b7b..1440780061aa0831d3009522a54f501c63425ca4 100644 --- a/include/staff/templates/thread-entry-edit.tmpl.php +++ b/include/staff/templates/thread-entry-edit.tmpl.php @@ -33,7 +33,7 @@ class="large <?php if ($cfg->isRichTextEnabled() && $this->entry->format == 'html') echo 'richtext'; - ?>"><?php echo Format::viewableImages($this->entry->body); + ?>"><?php echo htmlspecialchars(Format::viewableImages($this->entry->body)); ?></textarea> <?php if ($this->entry->type == 'R') { ?> diff --git a/include/staff/templates/thread-entry-view.tmpl.php b/include/staff/templates/thread-entry-view.tmpl.php index 51a0a10da8bc411507c049337ee7cc2b92a86e5b..0b5a542bae73f87eadfa31a810487042c05e54f5 100644 --- a/include/staff/templates/thread-entry-view.tmpl.php +++ b/include/staff/templates/thread-entry-view.tmpl.php @@ -16,7 +16,7 @@ do { // If you originally posted it, you can see all the edits && $E->staff_id != $thisstaff->getId() // You can see your own edits - // && $E->editor != $thisstaff->getId() + && ($E->editor != $thisstaff->getId() || $E->editor_type != 'S') ) { // Skip edits made by other agents continue; @@ -26,7 +26,8 @@ do { <strong><?php if ($E->title) echo Format::htmlchars($E->title).' — '; ?></strong> <em><?php if (strpos($E->updated, '0000-') === false) - echo sprintf(__('Edited on %s'), Format::datetime($E->updated)); + echo sprintf(__('Edited on %s by %s'), Format::datetime($E->updated), + ($editor = $E->getEditor()) ? $editor->getName() : ''); else echo __('Original'); ?></em> </a> diff --git a/include/staff/templates/thread-entry.tmpl.php b/include/staff/templates/thread-entry.tmpl.php index cc5a704ab300058b15959a048dc0112521e63728..62cd3e3362509d15f13faee81508f9b51060059d 100644 --- a/include/staff/templates/thread-entry.tmpl.php +++ b/include/staff/templates/thread-entry.tmpl.php @@ -38,14 +38,19 @@ if ($user && ($url = $user->get_gravatar(48))) <span style="vertical-align:middle;" class="textra"> <?php if ($entry->flags & ThreadEntry::FLAG_EDITED) { ?> <span class="label label-bare" title="<?php - echo sprintf(__('Edited on %s by %s'), Format::datetime($entry->updated), 'You'); + echo sprintf(__('Edited on %s by %s'), Format::datetime($entry->updated), + ($editor = $entry->getEditor()) ? $editor->getName() : ''); ?>"><?php echo __('Edited'); ?></span> - <?php } ?> +<?php } ?> +<?php if ($entry->flags & ThreadEntry::FLAG_RESENT) { ?> + <span class="label label-bare"><?php echo __('Resent'); ?></span> +<?php } ?> </span> </div> <?php echo sprintf(__('<b>%s</b> posted %s'), $name, - sprintf('<time class="relative" datetime="%s" title="%s">%s</time>', + sprintf('<a name="entry-%d" href="#entry-%1$s"><time class="relative" datetime="%s" title="%s">%s</time></a>', + $entry->id, date(DateTime::W3C, Misc::db2gmtime($entry->created)), Format::daydatetime($entry->created), Format::relativeTime(Misc::db2gmtime($entry->created)) @@ -55,10 +60,15 @@ if ($user && ($url = $user->get_gravatar(48))) echo $entry->title; ?></span> </span> </div> - <div class="thread-body" id="thread-id-<?php echo $entry->getId(); ?>"> + <div class="thread-body no-pjax"> <div><?php echo $entry->getBody()->toHtml(); ?></div> + <div class="clear"></div> <?php - if ($entry->has_attachments) { ?> + // The strangeness here is because .has_attachments is an annotation from + // Thread::getEntries(); however, this template may be used in other + // places such as from thread entry editing + if (isset($entry->has_attachments) ? $entry->has_attachments + : $entry->attachments->filter(array('inline'=>0))->count()) { ?> <div class="attachments"><?php foreach ($entry->attachments as $A) { if ($A->inline) @@ -81,7 +91,7 @@ if ($user && ($url = $user->get_gravatar(48))) <?php if ($urls = $entry->getAttachmentUrls()) { ?> <script type="text/javascript"> - $('#thread-id-<?php echo $entry->getId(); ?>') + $('#thread-entry-<?php echo $entry->getId(); ?>') .data('urls', <?php echo JsonDataEncoder::encode($urls); ?>) .data('id', <?php echo $entry->getId(); ?>); diff --git a/include/staff/tickets.inc.php b/include/staff/tickets.inc.php index da1d522122e01fd806a756355ade4b2a43fa0ccf..32d556b3f7bb66191337103b82322ac45a834842 100644 --- a/include/staff/tickets.inc.php +++ b/include/staff/tickets.inc.php @@ -77,24 +77,35 @@ case 'search': if ($_REQUEST['query']) { $results_type=__('Search Results'); // Use an index if possible - if ($_REQUEST['search-type'] == 'email') { + if ($_REQUEST['search-type'] == 'typeahead' && Validator::is_email($_REQUEST['query'])) { $tickets = $tickets->filter(array( 'user__emails__address' => $_REQUEST['query'], )); } else { - $tickets = $tickets->filter(Q::any(array( + $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'], - ))); + )); + 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 + $tickets = $ost->searcher->find($_REQUEST['query'], $tickets); + $keywords = array_pop($tickets->constraints); + $basic_search->add($keywords); + // FIXME: The subquery technique below will crash with + // keyword search + $use_subquery = false; + } + $tickets->filter($basic_search); } break; } elseif (isset($_SESSION['advsearch'])) { - // XXX: De-duplicate and simplify this code $form = $search->getFormFromSession('advsearch'); - $form->loadState($_SESSION['advsearch']); $tickets = $search->mangleQuerySet($tickets, $form); $view_all_tickets = $thisstaff->getRole()->hasPerm(SearchBackend::PERM_EVERYTHING); $results_type=__('Advanced Search') @@ -274,23 +285,21 @@ TicketForm::ensureDynamicDataView(); // Select pertinent columns // ------------------------------------------------------------ -$tickets->values('lock__staff_id', 'staff_id', 'isoverdue', 'team_id', 'ticket_id', 'number', 'cdata__subject', 'user__default_email__address', 'source', 'cdata__:priority__priority_color', 'cdata__:priority__priority_desc', 'status_id', 'status__name', 'status__state', 'dept_id', 'dept__name', 'user__name', 'lastupdate'); +$tickets->values('lock__staff_id', 'staff_id', 'isoverdue', 'team_id', 'ticket_id', 'number', 'cdata__subject', 'user__default_email__address', 'source', 'cdata__:priority__priority_color', 'cdata__:priority__priority_desc', 'status_id', 'status__name', 'status__state', 'dept_id', 'dept__name', 'user__name', 'lastupdate', 'isanswered'); // Add in annotations $tickets->annotate(array( - 'collab_count' => SqlAggregate::COUNT('thread__collaborators', true), - 'attachment_count' => SqlAggregate::COUNT(SqlCase::N() - ->when(new SqlField('thread__entries__attachments__inline'), null) - ->otherwise(new SqlField('thread__entries__attachments')), - true - ), - 'thread_count' => SqlAggregate::COUNT(SqlCase::N() - ->when( - new Q(array('thread__entries__flags__hasbit'=>ThreadEntry::FLAG_HIDDEN)), - null) - ->otherwise(new SqlField('thread__entries__id')), - true - ), + 'collab_count' => TicketThread::objects() + ->filter(array('ticket__ticket_id' => new SqlField('ticket_id'))) + ->aggregate(array('count' => SqlAggregate::COUNT('collaborators__id'))), + 'attachment_count' => TicketThread::objects() + ->filter(array('ticket__ticket_id' => new SqlField('ticket_id'))) + ->filter(array('entries__attachments__inline' => 0)) + ->aggregate(array('count' => SqlAggregate::COUNT('entries__attachments__id'))), + 'thread_count' => TicketThread::objects() + ->filter(array('ticket__ticket_id' => new SqlField('ticket_id'))) + ->filter(Q::not(array('entries__flags__hasbit' => ThreadEntry::FLAG_HIDDEN))) + ->aggregate(array('count' => SqlAggregate::COUNT('entries__id'))), )); // Save the query to the session for exporting diff --git a/include/upgrader/streams/core/9143a511-0d6099a6.patch.sql b/include/upgrader/streams/core/9143a511-0d6099a6.patch.sql index 401b6780cfd4680deea77c8efa1a935f5184a5a3..c6bf19b42ba79cfde7f076d9b7b056856d0a0b55 100644 --- a/include/upgrader/streams/core/9143a511-0d6099a6.patch.sql +++ b/include/upgrader/streams/core/9143a511-0d6099a6.patch.sql @@ -22,7 +22,7 @@ CREATE TABLE `%TABLE_PREFIX%_ticket_thread_evt` WHERE `object_type` = 'T'; UPDATE `%TABLE_PREFIX%thread_event` A1 - JOIN `%TABLE_PREFIX%_ticket_thread_evt` A2 ON (A1.`thread_id` = A2.`object_id`) + LEFT JOIN `%TABLE_PREFIX%_ticket_thread_evt` A2 ON (A1.`thread_id` = A2.`object_id`) SET A1.`thread_id` = A2.`id`; DROP TABLE `%TABLE_PREFIX%_ticket_thread_evt`; diff --git a/js/osticket.js b/js/osticket.js index 28fd56fc8fd5df9153eac06242e8116b8cdf5dc0..d885b9216e66a2111639312e7d54ce8f0102618d 100644 --- a/js/osticket.js +++ b/js/osticket.js @@ -21,7 +21,7 @@ $(document).ready(function(){ left : ($(window).width() / 2 - 160) }); - $("form :input").change(function() { + $(document).on('change', "form :input:not(.nowarn)", function() { var fObj = $(this).closest('form'); if(!fObj.data('changed')){ fObj.data('changed', true); @@ -30,7 +30,7 @@ $(document).ready(function(){ return __("Are you sure you want to leave? Any changes or info you've entered will be discarded!"); }); } - }); + }); $("form :input[type=reset]").click(function() { var fObj = $(this).closest('form'); @@ -133,6 +133,10 @@ $(document).ready(function(){ // TODO: Add a hover-button to show just one image }); }); + + $('div.thread-body a').each(function() { + $(this).attr('target', '_blank'); + }); }); showImagesInline = function(urls, thread_id) { diff --git a/scp/css/scp.css b/scp/css/scp.css index c965cb7a97cc4a025750ed740c6e0123b532ff97..ad7c976ade5c91a161e2b1e345801a253f5fa85d 100644 --- a/scp/css/scp.css +++ b/scp/css/scp.css @@ -70,6 +70,9 @@ div#header a { time[title]:hover { text-decoration: underline; } +a time.relative { + color: initial; +} .small[class^="icon-"], .small[class*=" icon-"] { @@ -1610,10 +1613,6 @@ time.faq { width:100%; } -#advanced-search div.closed_by, #advanced-search span.spinner { - display:none; -} - .dialog fieldset { margin:0; padding:0 0; @@ -1669,42 +1668,14 @@ time.faq { vertical-align: top; } -#advanced-search .query input { - width:100%; - padding: 4px; - margin-bottom: 10px; -} - -#advanced-search .date_range { - margin-bottom: 5px; -} -#advanced-search .date_range input { - width:227px; - width: calc(49% - 73px); -} - -#advanced-search .date_range i { - display:inline-block; - margin-left:3px; - position:relative; - top:5px; - width:16px; - height:16px; - background:url(../images/cal.png) bottom left no-repeat; -} - -#advanced-search fieldset.sorting select { - width:130px; -} - -#advanced-search p { - text-align:center; -} - .search-dropdown { padding-left: 19px; } +.advanced-search-field { + margin-top: 5px !important; +} + .dialog input[type="submit"], .dialog input[type="reset"], .dialog input[type="button"], @@ -2388,6 +2359,9 @@ td.indented { padding: 0 2px 15px; margin-left: 60px; } +.thread-event a { + color: inherit; +} .type-icon { border-radius: 8px; background-color: #f4f4f4; diff --git a/scp/js/scp.js b/scp/js/scp.js index 2d93705cdbbe88dfc41d3e5e57faf06e5f1b40a4..dfa6ed4e08beb7a497e2ab57710186153d3b449e 100644 --- a/scp/js/scp.js +++ b/scp/js/scp.js @@ -261,7 +261,7 @@ var scp_prep = function() { }, onselect: function (obj) { var form = $('#basic-ticket-search').closest('form'); - form.find('input[name=search-type]').val('email'); + form.find('input[name=search-type]').val('typeahead'); $('#basic-ticket-search').val(obj.value); form.submit(); }, @@ -1068,3 +1068,31 @@ function addSearchParam(key, value) { //this will reload the page, it's likely better to store this until finished return kvp.join('&'); } + +// Periodically adjust relative times +window.relativeAdjust = setInterval(function() { + // Thanks, http://stackoverflow.com/a/7641822/1025836 + var prettyDate = function(time) { + var date = new Date((time || "").replace(/-/g, "/").replace(/[TZ]/g, " ")), + diff = (((new Date()).getTime() - date.getTime()) / 1000), + day_diff = Math.floor(diff / 86400); + + if (isNaN(day_diff) || day_diff < 0 || day_diff >= 31) return; + + return day_diff == 0 && ( + diff < 60 && __("just now") + || diff < 120 && __("about a minute ago") + || diff < 3600 && __("%d minutes ago").replace('%d', Math.floor(diff/60)) + || diff < 7200 && __("about an hour ago") + || diff < 86400 && __("%d hours ago").replace('%d', Math.floor(diff/3600)) + ) + || day_diff == 1 && __("yesterday") + || day_diff < 7 && __("%d days ago").replace('%d', day_diff); + // Longer dates don't need to change dynamically + }; + $('time.relative[datetime]').each(function() { + var rel = prettyDate($(this).attr('datetime')); + if (rel) $(this).text(rel); + }); +}, 20000); + diff --git a/scp/js/ticket.js b/scp/js/ticket.js index 7f7b879e7eb8a13f0723ee764dd668398ff5fdca..de289ca296892b8fb7196599a398d8105480c429 100644 --- a/scp/js/ticket.js +++ b/scp/js/ticket.js @@ -214,7 +214,7 @@ var autoLock = { async: false, cache: false, success: function() { - autoLock.lockId = 0; + autoLock.destroy(); } }); }, @@ -281,6 +281,11 @@ var autoLock = { function () { autoLock.monitorEvents(); }, time || 30000 ); + }, + + destroy: function() { + autoLock.clearTimeout(); + autoLock.lockId = 0; } }; $.autoLock = autoLock; @@ -305,7 +310,7 @@ $.showNonLocalImage = function(div) { $.showImagesInline = function(urls, thread_id) { var selector = (thread_id == undefined) ? '.thread-body img[data-cid]' - : '.thread-body#thread-id-'+thread_id+' img[data-cid]'; + : '.thread-body#thread-entry-'+thread_id+' img[data-cid]'; $(selector).each(function(i, el) { var e = $(el), cid = e.data('cid').toLowerCase(), @@ -448,6 +453,10 @@ var ticket_onload = function($) { fx.end = last_entry.offset().top - 50; } }); + + $('div.thread-body a').each(function() { + $(this).attr('target', '_blank'); + }); }; $(ticket_onload); $(document).on('pjax:success', function() { ticket_onload(jQuery); }); diff --git a/scp/tasks.php b/scp/tasks.php index ab0996e8822a3370f33b8b8e8d64cb62ba297d9c..2f4791356b83b0bc46dcde1b96b4e36624828f8f 100644 --- a/scp/tasks.php +++ b/scp/tasks.php @@ -82,7 +82,7 @@ $stats= $thisstaff->getTasksStats(); // Clear advanced search upon request if (isset($_GET['clear_filter'])) - unset($_SESSION['advsearch']); + unset($_SESSION['advsearch:tasks']); //Navigation $nav->setTabActive('tasks'); @@ -94,7 +94,7 @@ $nav->addSubMenu(array('desc'=>$open_name.' ('.number_format($stats['open']).')' 'title'=>__('Open Tasks'), 'href'=>'tasks.php?status=open', 'iconclass'=>'Ticket'), - ((!$_REQUEST['status'] && !isset($_SESSION['advsearch'])) || $_REQUEST['status']=='open')); + ((!$_REQUEST['status'] && !isset($_SESSION['advsearch:tasks'])) || $_REQUEST['status']=='open')); if ($stats['assigned']) { @@ -124,11 +124,11 @@ if ($stats['closed']) { ($_REQUEST['status']=='closed')); } -if (isset($_SESSION['advsearch'])) { +if (isset($_SESSION['advsearch:tasks'])) { // XXX: De-duplicate and simplify this code $search = SavedSearch::create(); - $form = $search->getFormFromSession('advsearch'); - $form->loadState($_SESSION['advsearch']); + $form = $search->getFormFromSession('advsearch:tasks'); + $form->loadState($_SESSION['advsearch:tasks']); $tasks = Task::objects(); $tasks = $search->mangleQuerySet($tasks, $form); $count = $tasks->count(); diff --git a/scp/tickets.php b/scp/tickets.php index 9e244bcc61115fb39e9817c0f6283ecea41bf438..8c1f84047c82f21330b73bb3569e8ba7b680fc28 100644 --- a/scp/tickets.php +++ b/scp/tickets.php @@ -358,8 +358,6 @@ if($_POST && !$errors): default: $errors['err']=__('Unknown action'); endswitch; - if($ticket && is_object($ticket)) - $ticket->reload();//Reload ticket info following post processing }elseif($_POST['a']) { switch($_POST['a']) { @@ -475,7 +473,6 @@ if (isset($_SESSION['advsearch'])) { // XXX: De-duplicate and simplify this code $search = SavedSearch::create(); $form = $search->getFormFromSession('advsearch'); - $form->loadState($_SESSION['advsearch']); $tickets = TicketModel::objects(); $tickets = $search->mangleQuerySet($tickets, $form); $count = $tickets->count(); diff --git a/setup/css/wizard.css b/setup/css/wizard.css index a742c75cbbbf0cc2c91b72ffbabf6a99abef16a0..9d4e8b6ae79985567148c53753b782965a23a334 100644 --- a/setup/css/wizard.css +++ b/setup/css/wizard.css @@ -11,7 +11,7 @@ a { color: #2a67ac; display: inline-block; } .hidden { display: none;} .error { color:#f00;} -#header { height: 72px; margin-bottom: 20px; width: 100%; } +#header { min-height: 72px; margin-bottom: 20px; width: 100%; } #header #logo { width: 280px; height: 72px; display: block; float: left; } #header .info { font-size: 11pt; font-weight: bold; border-bottom: 1px solid #2a67ac; color: #444; text-align: right; float: right; } #header ul { margin: 0; padding: 0; text-align: right; } diff --git a/setup/inc/install-prereq.inc.php b/setup/inc/install-prereq.inc.php index 12c97730a035cadf8aec02d677280f38b81c8e8a..659b01c0f29dee918377b403087e07ae2012994c 100644 --- a/setup/inc/install-prereq.inc.php +++ b/setup/inc/install-prereq.inc.php @@ -36,6 +36,10 @@ if(!defined('SETUPINC')) die('Kwaheri!'); echo __('recommended for all installations');?></li> <li class="<?php echo extension_loaded('phar')?'yes':'no'; ?>">Phar <?php echo __('extension');?> — <?php echo __('recommended for plugins and language packs');?></li> + <li class="<?php echo extension_loaded('intl')?'yes':'no'; ?>">Intl <?php echo __('extension');?> — <?php + echo __('recommended for improved localization');?></li> + <li class="<?php echo extension_loaded('apc')?'yes':'no'; ?>">APC <?php echo __('extension');?> — <?php + echo __('(faster performance)');?></li> </ul> <div id="bar"> <form method="post" action="install.php"> diff --git a/tickets.php b/tickets.php index 875fdfec40dc2d4770e4891c8a5cd76832c3a5d7..070cb2567b350d42b3b7af1bbed54e543769b4ef 100644 --- a/tickets.php +++ b/tickets.php @@ -103,7 +103,6 @@ if ($_POST && is_object($ticket) && $ticket->getId()) { default: $errors['err']=__('Unknown action'); } - $ticket->reload(); } elseif (is_object($ticket) && $ticket->getId()) { switch(strtolower($_REQUEST['a'])) {