diff --git a/assets/default/css/theme.css b/assets/default/css/theme.css index 8adafc32edbd4b7335de5e75769d25a46a86bfda..26680176598c1b8916aaccacb041713d34074a16 100644 --- a/assets/default/css/theme.css +++ b/assets/default/css/theme.css @@ -726,7 +726,7 @@ label.required, span.required { border-radius: 4px; } #reply { - margin-top: 20px; + margin-top: 5px; padding: 10px; background: #f9f9f9; border: 1px solid #ccc; @@ -855,44 +855,6 @@ a.refresh { text-align: left; padding: 3px 8px; } -#ticketThread table.response, -#ticketThread table.message { - margin-top: 10px; - border: 1px solid #aaa; - border-bottom: 2px solid #aaa; -} -#ticketThread table th { - text-align: left; - border-bottom: 1px solid #aaa; - font-size: 12px; - padding: 5px; -} -#ticketThread table th span { - font-weight: normal; - color: #888; - padding-left: 20px; -} -#ticketThread .message th { - background: #d8efff; -} -#ticketThread .response th { - background: #ddd; -} -#ticketThread .info { - padding: 2px; - background: #f9f9f9; - border-top: 1px solid #ddd; - height: 16px; - line-height: 16px; -} -#ticketThread .info a { - display: inline-block; - margin: 5px 10px 5px 0; - height: 16px; - line-height: 16px; - background-position: 0 50%; - background-repeat: no-repeat; -} .action-button { -webkit-border-radius: 3px; -moz-border-radius: 3px; @@ -1048,6 +1010,7 @@ img.sign-in-image { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + vertical-align: bottom; } .image-hover a.action-button:hover, .image-hover a.action-button { @@ -1079,3 +1042,220 @@ table.custom-data .headline { #ticketInfo h1 small { font-weight: normal; } +.thread-entry { + margin-bottom: 15px; +} +.thread-entry.avatar { + margin-left: 60px; +} +.thread-entry.response.avatar { + margin-right: 60px; + margin-left: 0; +} +.thread-entry > .avatar { + margin-left: -60px; + display:inline-block; + width:48px; + height:auto; + border-radius: 5px; +} +.thread-entry.response > .avatar { + margin-left: initial; + margin-right: -60px; +} +img.avatar { + border-radius: inherit; +} +.thread-entry .header { + padding: 8px 0.9em; + border: 1px solid #ccc; + border-color: rgba(0,0,0,0.2); + border-radius: 5px 5px 0 0; +} +.thread-entry.avatar .header:before { + position: absolute; + top: 7px; + right: -8px; + content: ''; + border-top: 8px solid transparent; + border-bottom: 8px solid transparent; + border-left: 8px solid #b0b0b0; + display: inline-block; +} +.thread-entry.avatar .header:after { + position: absolute; + top: 7px; + right: -8px; + content: ''; + border-top: 7px solid transparent; + border-bottom: 7px solid transparent; + display: inline-block; + margin-top: 1px; +} + +.thread-entry.avatar .header { + position: relative; +} + +.thread-entry.response .header { + background:#dddddd; +} +.thread-entry.avatar.response .header:after { + border-left: 7px solid #dddddd; + margin-right: 1px; +} + +.thread-entry.message .header { + background:#C3D9FF; +} +.thread-entry.avatar.message .header:before { + top: 7px; + left: -8px; + right: initial; + border-left: none; + border-right: 8px solid #CCC; +} +.thread-entry.avatar.message .header:before { + border-right-color: #9cadcc; +} +.thread-entry.avatar.message .header:after { + top: 7px; + left: -8px; + right: initial; + border-left: none; + border-right: 7px solid #c3d9ff; + margin-left: 1px; +} + +.thread-entry .header .title { + max-width: 500px; + vertical-align: bottom; + display: inline-block; + margin-left: 15px; +} + +.thread-entry .thread-body { + border: 1px solid #ddd; + border-top: none; + border-bottom:2px solid #aaa; + border-radius: 0 0 5px 5px; +} +.thread-body .attachments { + background-color: #f4faff; + margin: 0 -0.9em; + position: relative; + top: 0.9em; + padding: 0.3em 0.9em; + border-top: 1px dotted #ccc; + border-top-color: rgba(0,0,0,0.2); + border-radius: 0 0 6px 6px; +} +.thread-body .attachments .filesize { + margin-left: 0.5em; +} +.thread-body .attachment-info { + margin-right: 10px; + display: inline-block; + width: 48%; +} +.thread-body .attachment-info .filename { + max-width: 80%; + max-width: calc(100% - 70px); +} +.label { + font-size: 11px; + padding: 1px 4px; + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + border-radius: 3px; + font-weight: bold; + line-height: 14px; + color: #ffffff; + vertical-align: baseline; + white-space: nowrap; + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); + background-color: #999999; +} +.label-bare { + background-color: transparent; + background-color: rgba(0,0,0,0); + border: 1px solid #999999; + color: #999999; + text-shadow: none; +} +.thread-event { + padding: 0px 2px 15px; + margin-left: 60px; +} +.type-icon { + border-radius: 8px; + background-color: #f4f4f4; + padding: 4px 6px; + margin-right: 5px; + text-align: center; + display: inline-block; + font-size: 1.1em; + border: 1px solid #eee; + vertical-align: top; +} +.type-icon.dark { + border-color: #666; + background-color: #949494; +} +.thread-event img.avatar { + vertical-align: middle; + border-radius: 3px; + width: auto; + max-height: 24px; + margin: -3px 3px 0; +} +.thread-event .description { + margin-left: -30px; + padding-top: 6px; + padding-left: 30px; + display: inline-block; + width: 642px; + width: calc(100% - 95px); + line-height: 1.4em; +} +.thread-event .type-icon { + position:relative; +} +.thread-event .type-icon::after { + content: ""; + border: 16px solid white; + position: absolute; + top: -3px; + bottom: 0; + left: -3px; + right: 0; + z-index: -1; +} +.thread-entry::after { + content: ""; + border-bottom: 2px solid white; + display: block; +} +.thread-entry::before { + content: ""; + display: block; + border-top: 2px solid white; +} +#ticketThread::before { + border-left: 2px dotted #ddd; + border-bottom-color: rgba(0,0,0,0.1); + position: absolute; + margin-left: 74px; + z-index: -1; + content: ""; + top: 0; + bottom: 0; + right: 0; + left: 0; +} +#ticketThread { + z-index: 0; + position: relative; + border-bottom: 2px solid #ddd; + border-bottom-color: rgba(0,0,0,0.1); +} diff --git a/bootstrap.php b/bootstrap.php index b39a23f2debb68e28d2f00810d328e384357eeae..6d80b6330ad075c087c0ad915a6276e2f4e99b3c 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -98,7 +98,7 @@ class Bootstrap { define('TICKET_TABLE',$prefix.'ticket'); define('TICKET_CDATA_TABLE', $prefix.'ticket__cdata'); - define('TICKET_EVENT_TABLE',$prefix.'ticket_event'); + define('THREAD_EVENT_TABLE',$prefix.'thread_event'); define('THREAD_COLLABORATOR_TABLE', $prefix.'thread_collaborator'); define('TICKET_STATUS_TABLE', $prefix.'ticket_status'); define('TICKET_PRIORITY_TABLE',$prefix.'ticket_priority'); diff --git a/css/thread.css b/css/thread.css index 233f283158c5e8f98a67e1dad072bfac756e343d..4ac6615703681721278f9d2f8815c499d2a6aff9 100644 --- a/css/thread.css +++ b/css/thread.css @@ -30,7 +30,7 @@ color: #333333; background-color: #ffffff; margin: 0; - padding: 0.5em; + padding: 0.9em; } .thread-body a:focus { outline: thin dotted; diff --git a/include/ajax.reports.php b/include/ajax.reports.php index 6086e3cb0b65fb214c14dd141d874976c25c0916..7b2e4f450a74e0b780b8bbede6515d0382684152 100644 --- a/include/ajax.reports.php +++ b/include/ajax.reports.php @@ -90,7 +90,7 @@ class OverviewReportAjaxAPI extends AjaxController { COUNT(*)-COUNT(NULLIF(A1.state, "closed")) AS Closed, COUNT(*)-COUNT(NULLIF(A1.state, "reopened")) AS Reopened FROM '.$info['table'].' T1 - LEFT JOIN '.TICKET_EVENT_TABLE.' A1 + LEFT JOIN '.THREAD_EVENT_TABLE.' A1 ON (A1.'.$info['pk'].'=T1.'.$info['pk'].' AND NOT annulled AND (A1.timestamp BETWEEN '.$start.' AND '.$stop.')) @@ -190,7 +190,7 @@ class OverviewReportAjaxAPI extends AjaxController { list($start, $stop) = $this->_getDateRange(); # Fetch all types of events over the timeframe - $res = db_query('SELECT DISTINCT(state) FROM '.TICKET_EVENT_TABLE + $res = db_query('SELECT DISTINCT(state) FROM '.THREAD_EVENT_TABLE .' WHERE timestamp BETWEEN '.$start.' AND '.$stop .' ORDER BY 1'); $events = array(); @@ -200,7 +200,7 @@ class OverviewReportAjaxAPI extends AjaxController { # XXX: Implement annulled column from the %ticket_event table $res = db_query('SELECT state, DATE_FORMAT(timestamp, \'%Y-%m-%d\'), ' .'COUNT(ticket_id)' - .' FROM '.TICKET_EVENT_TABLE + .' FROM '.THREAD_EVENT_TABLE .' WHERE timestamp BETWEEN '.$start.' AND '.$stop .' AND NOT annulled' .' GROUP BY state, DATE_FORMAT(timestamp, \'%Y-%m-%d\')' diff --git a/include/ajax.tickets.php b/include/ajax.tickets.php index 1fb4d5bc5bc070b37875b46e335f8ded7890acbd..dde62c7f2a511474610c5913d14446c6df87049d 100644 --- a/include/ajax.tickets.php +++ b/include/ajax.tickets.php @@ -192,7 +192,6 @@ class TicketsAjaxAPI extends AjaxController { include STAFFINC_DIR . 'templates/ticket-preview.tmpl.php'; } - function viewUser($tid) { global $thisstaff; diff --git a/include/class.attachment.php b/include/class.attachment.php index 3b25f87a019cd7e5868be68fc67266e1e51740fa..262486c2977a854d34d9b96cb77c1439f9fe5d49 100644 --- a/include/class.attachment.php +++ b/include/class.attachment.php @@ -56,6 +56,10 @@ class Attachment extends VerySimpleModel { return $this->file; } + function getFilename() { + return $this->name ?: $this->file->name; + } + function getHashtable() { return $this->ht; } @@ -117,7 +121,8 @@ extends InstrumentedList { function upload($files, $inline=false, $lang=false) { $i=array(); - if (!is_array($files)) $files=array($files); + if (!is_array($files)) + $files = array($files); foreach ($files as $file) { if (is_numeric($file)) $fileId = $file; @@ -134,6 +139,18 @@ extends InstrumentedList { 'file_id' => $fileId, 'inline' => $_inline ? 1 : 0, ))); + + // Record varying file names in the attachment record + if (is_array($file) && isset($file['name'])) { + $filename = $file['name']; + } + if ($filename) { + // This should be a noop since the ORM caches on PK + $file = $F ?: AttachmentFile::lookup($fileId); + // XXX: This is not Unicode safe + if ($file && 0 !== strcasecmp($file->name, $filename)) + $att->name = $filename; + } if ($lang) $att->lang = $lang; diff --git a/include/class.collaborator.php b/include/class.collaborator.php index 205b5200b151911c757611483fae9560f041bb27..6bc891e5fd9fd53d19789faac4de813fc2ab1dfc 100644 --- a/include/class.collaborator.php +++ b/include/class.collaborator.php @@ -35,8 +35,10 @@ implements EmailContact, ITicketUser { ); function __toString() { - return Format::htmlchars(sprintf('%s <%s>', $this->getName(), - $this->getEmail())); + return Format::htmlchars($this->toString()); + } + function toString() { + return sprintf('%s <%s>', $this->getName(), $this->getEmail()); } function getId() { diff --git a/include/class.config.php b/include/class.config.php index 5c6cf5cee3467bfe4a46ea9fed0dc333f7e72daf..39d63cebf469558a5b8786f0bce844dba304687d 100644 --- a/include/class.config.php +++ b/include/class.config.php @@ -154,7 +154,7 @@ class OsticketConfig extends Config { var $defaults = array( 'allow_pw_reset' => true, 'pw_reset_window' => 30, - 'enable_html_thread' => true, + 'enable_richtext' => true, 'allow_attachments' => true, 'agent_name_format' => 'full', # First Last 'client_name_format' => 'original', # As entered @@ -366,7 +366,7 @@ class OsticketConfig extends Config { } function isRichTextEnabled() { - return $this->get('enable_html_thread'); + return $this->get('enable_richtext'); } function getClientTimeout() { @@ -1081,7 +1081,7 @@ class OsticketConfig extends Config { 'secondary_langs'=>$secondary_langs, 'max_file_size' => $vars['max_file_size'], 'autolock_minutes' => $vars['autolock_minutes'], - 'enable_html_thread' => isset($vars['enable_html_thread']) ? 1 : 0, + 'enable_richtext' => isset($vars['enable_richtext']) ? 1 : 0, )); } diff --git a/include/class.dept.php b/include/class.dept.php index a841f43e01959e19f775aa6b7bf2de955ae79a2b..90cbe3a4b72088cc2257dacc03de6a13aaabd5d2 100644 --- a/include/class.dept.php +++ b/include/class.dept.php @@ -62,11 +62,7 @@ implements TemplateVariable { const ALERTS_DEPT_AND_GROUPS = 1; const ALERTS_DEPT_ONLY = 0; - function getConfig() { - if (!isset($this->config)) - $this->config = new Config('dept.'. $this->getId()); - return $this->config; - } + const FLAG_ASSIGN_MEMBERS_ONLY = 0x0001; function asVar() { return $this->getName(); @@ -301,7 +297,7 @@ implements TemplateVariable { } function assignMembersOnly() { - return $this->getConfig()->get('assign_members_only', 0); + return $this->flags & self::FLAG_ASSIGN_MEMBERS_ONLY; } function isGroupMembershipEnabled() { @@ -314,11 +310,12 @@ implements TemplateVariable { foreach (static::$meta['joins'] as $k => $v) unset($ht[$k]); + $ht['assign_members_only'] = $this->flags & self::FLAG_ASSIGN_MEMBERS_ONLY; return $ht; } function getInfo() { - return $this->getConfig()->getInfo() + $this->getHashtable(); + return $this->getHashtable(); } function getAllowedGroups() { @@ -368,7 +365,6 @@ implements TemplateVariable { function updateSettings($vars) { $this->updateGroups($vars['groups'] ?: array(), $vars); - $this->getConfig()->set('assign_members_only', $vars['assign_members_only']); $this->path = $this->getFullPath(); $this->save(); return true; @@ -386,10 +382,8 @@ implements TemplateVariable { return 0; } - parent::delete(); $id = $this->getId(); - $sql='DELETE FROM '.DEPT_TABLE.' WHERE id='.db_input($id).' LIMIT 1'; - if(db_query($sql) && ($num=db_affected_rows())) { + if (parent::delete()) { // DO SOME HOUSE CLEANING //Move tickets to default Dept. TODO: Move one ticket at a time and send alerts + log notes. db_query('UPDATE '.TICKET_TABLE.' SET dept_id='.db_input($cfg->getDefaultDeptId()).' WHERE dept_id='.db_input($id)); @@ -403,9 +397,6 @@ implements TemplateVariable { //Delete group access db_query('DELETE FROM '.GROUP_DEPT_TABLE.' WHERE dept_id='.db_input($id)); - - // Destrory config settings - $this->getConfig()->destroy(); } return true; @@ -602,6 +593,7 @@ implements TemplateVariable { $this->group_membership = $vars['group_membership']; $this->ticket_auto_response = isset($vars['ticket_auto_response'])?$vars['ticket_auto_response']:1; $this->message_auto_response = isset($vars['message_auto_response'])?$vars['message_auto_response']:1; + $this->flags = isset($vars['assign_members_only']) ? self::FLAG_ASSIGN_MEMBERS_ONLY : 0; if ($this->save()) return $this->updateSettings($vars); diff --git a/include/class.dynamic_forms.php b/include/class.dynamic_forms.php index cde5b24900c6b0f6c5af4b7edeb1b9bd4d059f41..8fedabe4c8158ae3917826e6e5956f93e764c302 100644 --- a/include/class.dynamic_forms.php +++ b/include/class.dynamic_forms.php @@ -46,6 +46,9 @@ class DynamicForm extends VerySimpleModel { 'O' => 'Organization Information', ); + const FLAG_DELETABLE = 0x0001; + const FLAG_DELETED = 0x0002; + var $_form; var $_fields; var $_has_data = false; @@ -124,7 +127,11 @@ class DynamicForm extends VerySimpleModel { } function isDeletable() { - return $this->get('deletable'); + return $this->flags & self::FLAG_DELETABLE; + } + + function setFlag($flag) { + $this->flags |= $flag; } function hasAnyVisibleFields($user=false) { @@ -173,6 +180,7 @@ class DynamicForm extends VerySimpleModel { function save($refetch=false) { if (count($this->dirty)) $this->set('updated', new SqlFunction('NOW')); + // XXX: This should go to an update routine if (isset($this->dirty['notes'])) $this->notes = Format::sanitize($this->notes); if ($rv = parent::save($refetch | $this->dirty)) @@ -181,10 +189,13 @@ class DynamicForm extends VerySimpleModel { } function delete() { + if (!$this->isDeletable()) return false; - else - return parent::delete(); + + // Soft Delete: Mark the form as deleted. + $this->setFlag(self::FLAG_DELETED); + return $this->save(); } function getExportableFields($exclude=array()) { @@ -278,7 +289,7 @@ class DynamicForm extends VerySimpleModel { static function buildDynamicDataView($cdata) { $sql = 'CREATE TABLE `'.$cdata['table'].'` (PRIMARY KEY - ('.$cdata['object_id'].')) AS ' + ('.$cdata['object_id'].')) DEFAULT CHARSET=utf8 AS ' . static::getCrossTabQuery( $cdata['object_type'], $cdata['object_id']); db_query($sql); } @@ -874,12 +885,15 @@ class DynamicFormField extends VerySimpleModel { if (!$this->get('label')) $this->addError( __("Label is required for custom form fields"), "label"); - if ($this->get('required') && !$this->get('name')) + if (($this->isRequiredForStaff() || $this->isRequiredForUsers()) + && !$this->get('name') + ) { $this->addError( __("Variable name is required for required fields" /* `required` is a visibility setting fields */ /* `variable` is used for automation. Internally it's called `name` */ ), "name"); + } if (preg_match('/[.{}\'"`; ]/u', $this->get('name'))) $this->addError(__( 'Invalid character in variable name. Please use letters and numbers only.' @@ -1172,6 +1186,23 @@ class DynamicFormEntry extends VerySimpleModel { return $this->getForm()->render($staff, $title, $options); } + function getChanges() { + $fields = array(); + foreach ($this->getAnswers() as $a) { + $field = $a->getField(); + if (!$field->hasData() || $field->isPresentationOnly()) + continue; + $val = $v = $field->to_database($field->getClean()); + if (is_array($val)) + $v = $val[0]; + if ($a->value == $v) + continue; + $before = $field->to_database($a->getValue()); + $fields[$field->get('id')] = array($before, $val); + } + return $fields; + } + /** * addMissingFields * @@ -1494,6 +1525,38 @@ class SelectionField extends FormField { return $value; } + // PHP 5.4 Move this to a trait + function whatChanged($before, $after) { + $before = (array) $before; + $after = (array) $after; + $added = array_diff($after, $before); + $deleted = array_diff($before, $after); + $added = array_map(array($this, 'display'), $added); + $deleted = array_map(array($this, 'display'), $deleted); + + if ($added && $deleted) { + $desc = sprintf( + __('added <strong>%1$s</strong> and removed <strong>%2$s</strong>'), + implode(', ', $added), implode(', ', $deleted)); + } + elseif ($added) { + $desc = sprintf( + __('added <strong>%1$s</strong>'), + implode(', ', $added)); + } + elseif ($deleted) { + $desc = sprintf( + __('removed <strong>%1$s</strong>'), + implode(', ', $deleted)); + } + else { + $desc = sprintf( + __('changed to <strong>%1$s</strong>'), + $this->display($after)); + } + return $desc; + } + function asVar($value, $id=false) { $values = $this->to_php($value, $id); if (is_array($values)) { diff --git a/include/class.export.php b/include/class.export.php index acdc333582c6d7d54b35c0fd17d4db5ccd5ec473..68b907282853cbf55b5ebde39f367230905d4ebc 100644 --- a/include/class.export.php +++ b/include/class.export.php @@ -345,7 +345,7 @@ class DatabaseExporter { FAQ_TOPIC_TABLE, FAQ_CATEGORY_TABLE, DRAFT_TABLE, CANNED_TABLE, TICKET_TABLE, ATTACHMENT_TABLE, THREAD_TABLE, THREAD_ENTRY_TABLE, THREAD_ENTRY_EMAIL_TABLE, - LOCK_TABLE, TICKET_EVENT_TABLE, TICKET_PRIORITY_TABLE, + LOCK_TABLE, THREAD_EVENT_TABLE, TICKET_PRIORITY_TABLE, EMAIL_TABLE, EMAIL_TEMPLATE_TABLE, EMAIL_TEMPLATE_GRP_TABLE, FILTER_TABLE, FILTER_RULE_TABLE, SLA_TABLE, API_KEY_TABLE, TIMEZONE_TABLE, SESSION_TABLE, PAGE_TABLE, diff --git a/include/class.format.php b/include/class.format.php index b3ff5bf5c5b9dee946da4f5debe58ae293f0cef3..d062b69fdc5011712aa71863ae3c86c2dd13b65a 100644 --- a/include/class.format.php +++ b/include/class.format.php @@ -731,7 +731,7 @@ class Format { return $text; } - function relativeTime($to, $from=false) { + function relativeTime($to, $from=false, $granularity=1) { $timestamp = $to ?: Misc::gmtime(); if (gettype($timestamp) === 'string') $timestamp = strtotime($timestamp); @@ -741,6 +741,9 @@ class Format { $timeDiff = $from - $timestamp; $absTimeDiff = abs($timeDiff); + // Roll back to the nearest multiple of $granularity + $absTimeDiff -= $absTimeDiff % $granularity; + // within 2 seconds if ($absTimeDiff <= 2) { return $timeDiff >= 0 ? __('just now') : __('now'); @@ -775,7 +778,7 @@ class Format { $days2 = 2 * 86400; if ($absTimeDiff < $days2) { // XXX: yesterday / tomorrow? - return $absTimeDiff >= 0 ? __('1 day ago') : __('in 1 day'); + return $absTimeDiff >= 0 ? __('yesterday') : __('tomorrow'); } // within 29 days diff --git a/include/class.forms.php b/include/class.forms.php index 9ccb615809700b919a8a815182dbf3d0957d83c1..bc29a836d9744293acb0e597380fdc554dc30fb4 100644 --- a/include/class.forms.php +++ b/include/class.forms.php @@ -501,7 +501,7 @@ class FormField { */ function isEditable() { - return (($this->get('edit_mask') & 32) == 0); + return (($this->get('flags') & DynamicFormField::FLAG_MASK_EDIT) == 0); } /** @@ -629,6 +629,19 @@ class FormField { return false; } + /** + * Describe the difference between the to two values. Note that the + * values should be passed through ::parse() or to_php() before + * utilizing this method. + */ + function whatChanged($before, $after) { + if ($before) + $desc = __('changed from <strong>%2$s</strong> to <strong>%1$s</strong>'); + else + $desc = __('set to <strong>%1$s</strong>'); + return sprintf($desc, $this->display($after), $this->display($before)); + } + /** * Convert the field data to something matchable by filtering. The * primary use of this is for ticket filtering. @@ -1315,6 +1328,37 @@ class ChoiceField extends FormField { return (string) $value; } + function whatChanged($before, $after) { + $B = (array) $before; + $A = (array) $after; + $added = array_diff($A, $B); + $deleted = array_diff($B, $A); + $added = array_map(array($this, 'display'), $added); + $deleted = array_map(array($this, 'display'), $deleted); + + if ($added && $deleted) { + $desc = sprintf( + __('added <strong>%1$s</strong> and removed <strong>%2$s</strong>'), + implode(', ', $added), implode(', ', $deleted)); + } + elseif ($added) { + $desc = sprintf( + __('added <strong>%1$s</strong>'), + implode(', ', $added)); + } + elseif ($deleted) { + $desc = sprintf( + __('removed <strong>%1$s</strong>'), + implode(', ', $deleted)); + } + else { + $desc = sprintf( + __('changed from <strong>%1$s</strong> to <strong>%2$s</strong>'), + $this->display($before), $this->display($after)); + } + return $desc; + } + /* Return criteria to which the choice should be filtered by */ @@ -1693,6 +1737,8 @@ class PriorityField extends ChoiceField { reset($id); $id = key($id); } + elseif (is_array($value)) + list($value, $id) = $value; elseif ($id === false) $id = $value; if ($id) @@ -3079,9 +3125,9 @@ class FileUploadWidget extends Widget { } function getValue() { + $ids = array(); // Handle manual uploads (IE<10) if ($_SERVER['REQUEST_METHOD'] == 'POST' && isset($_FILES[$this->name])) { - $ids = array(); foreach (AttachmentFile::format($_FILES[$this->name]) as $file) { try { $F = $this->field->uploadFile($file); @@ -3089,10 +3135,25 @@ class FileUploadWidget extends Widget { } catch (FileUploadError $ex) {} } - return array_merge($ids, parent::getValue() ?: array()); + return $ids; } + // If no value was sent, assume an empty list - return parent::getValue() ?: array(); + $base = parent::getValue(); + if (!$base) + return array(); + + if (is_array($base)) { + foreach ($base as $info) { + @list($id, $name) = explode(',', $info, 2); + // Keep the values as the IDs + if ($name) + $ids[$name] = $id; + else + $ids[] = $id; + } + } + return $ids; } } diff --git a/include/class.list.php b/include/class.list.php index e873f4ee9a37d683422cd5e32cb3de19baaba3f5..82142b90ea86c50b822c926512284fed8a43b739 100644 --- a/include/class.list.php +++ b/include/class.list.php @@ -148,12 +148,6 @@ class DynamicList extends VerySimpleModel implements CustomList { var $_items; var $_form; - var $_config; - - function __construct() { - call_user_func_array(array('parent', '__construct'), func_get_args()); - $this->_config = new Config('list.'.$this->getId()); - } function getId() { return $this->get('id'); @@ -318,7 +312,7 @@ class DynamicList extends VerySimpleModel implements CustomList { } function getConfiguration() { - return JsonDataParser::parse($this->_config->get('configuration')); + return JsonDataParser::parse($this->configuration); } function getTranslateTag($subtag) { @@ -361,11 +355,20 @@ class DynamicList extends VerySimpleModel implements CustomList { function delete() { $fields = DynamicFormField::objects()->filter(array( 'type'=>'list-'.$this->id))->count(); - if ($fields == 0) - return parent::delete(); - else - // Refuse to delete lists that are in use by fields + + // Refuse to delete lists that are in use by fields + if ($fields != 0) return false; + + if (!parent::delete()) + return false; + + if (($form = $this->getForm(false))) { + $form->delete(false); + $form->fields->delete(); + } + + return true; } private function createForm() { @@ -402,6 +405,10 @@ class DynamicList extends VerySimpleModel implements CustomList { } static function create($ht=false, &$errors=array()) { + if (isset($ht['configuration'])) { + $ht['configuration'] = JsonDataEncoder::encode($ht['configuration']); + } + $inst = parent::create($ht); $inst->set('created', new SqlFunction('NOW')); @@ -412,12 +419,6 @@ class DynamicList extends VerySimpleModel implements CustomList { $form->save(); } - if (isset($ht['configuration'])) { - $inst->save(); - $c = new Config('list.'.$inst->getId()); - $c->set('configuration', JsonDataEncoder::encode($ht['configuration'])); - } - if (isset($ht['items'])) { $inst->save(); foreach ($ht['items'] as $i) { diff --git a/include/class.mailer.php b/include/class.mailer.php index cedb308d7982f29ae20c934ea468c0245e6a9143..023a061a6fdb42781104563823e6f5937aa32516 100644 --- a/include/class.mailer.php +++ b/include/class.mailer.php @@ -82,7 +82,7 @@ class Mailer { function addAttachment(Attachment $attachment) { // XXX: This looks too assuming; however, the attachment processor // in the ::send() method seems hard coded to expect this format - $this->attachments[$attachment->file_id] = $attachment->file; + $this->attachments[$attachment->file_id] = $attachment; } function addFile(AttachmentFile $file) { @@ -433,7 +433,10 @@ class Mailer { $file = false; foreach ($self->attachments as $id=>$F) { if (strcasecmp($F->getKey(), $match[1]) === 0) { - $file = $F; + if ($F instanceof Attachment) + $file = $F->getFile(); + else + $file = $F; break; } } @@ -452,8 +455,16 @@ class Mailer { //XXX: Attachments if(($attachments=$this->getAttachments())) { foreach($attachments as $id=>$file) { + // Read the filename from the Attachment if possible + if ($file instanceof Attachment) { + $filename = $file->getFilename(); + $file = $file->getFile(); + } + else { + $filename = $file->getName(); + } $mime->addAttachment($file->getData(), - $file->getType(), $file->getName(),false); + $file->getType(), $filename, false); } } diff --git a/include/class.page.php b/include/class.page.php index 6625c3f5c38bb7f5325be89997b991bd39a479a4..0373780e4e697fd7c7d12c4058ed63e746b16750 100644 --- a/include/class.page.php +++ b/include/class.page.php @@ -280,13 +280,9 @@ class Page extends VerySimpleModel { $this->isactive = (bool) $vars['isactive']; $this->notes = Format::sanitize($vars['notes']); - if (!isset($this->id)) { - if ($this->save()) { - $this->content_id = $this->id; - $rv = $this->save(); - } - } - elseif ($this->save()) + $isnew = !isset($this->id); + $rv = $this->save(); + if (!$isnew) $rv = $this->saveTranslations($vars, $errors); // Attach inline attachments from the editor diff --git a/include/class.sla.php b/include/class.sla.php index c1b4ead65df6f5d1ec796f7655e1d0a4322519f1..66e06d94cf188098e1f2b92dcff636283e5e9a8f 100644 --- a/include/class.sla.php +++ b/include/class.sla.php @@ -21,7 +21,10 @@ implements TemplateVariable { 'pk' => array('id'), ); - //TODO: Use flags + const FLAG_ACTIVE = 0x0001; + const FLAG_ESCALATE = 0x0002; + const FLAG_NOALERTS = 0x0004; + const FLAG_TRANSIENT = 0x0008; var $_config; @@ -37,19 +40,13 @@ implements TemplateVariable { return $this->grace_period; } - function getHashtable() { - $this->getHashtable(); - } - function getInfo() { - return array_merge($this->getConfig()->getInfo(), $this->ht); - } - - function getConfig() { - if (!isset($this->_config)) - $this->_config = new SlaConfig($this->getId()); - - return $this->_config; + $base = $this->ht; + $base['isactive'] = $this->flags & self::FLAG_ACTIVE; + $base['disable_overdue_alerts'] = $this->flags & self::FLAG_NOALERTS; + $base['enable_priority_escalation'] = $this->flags & self::FLAG_ESCALATE; + $base['transient'] = $this->flags & self::FLAG_TRANSIENT; + return $base; } function getCreateDate() { @@ -61,15 +58,15 @@ implements TemplateVariable { } function isActive() { - return ($this->isactive); + return $this->flags & self::FLAG_ACTIVE; } function isTransient() { - return $this->getConfig()->get('transient', false); + return $this->flags & self::FLAG_TRANSIENT; } function sendAlerts() { - return $this->disable_overdue_alerts; + return 0 === ($this->flags & self::FLAG_NOALERTS); } function alertOnOverdue() { @@ -77,7 +74,7 @@ implements TemplateVariable { } function priorityEscalation() { - return ($this->enable_priority_escalation); + return $this->flags && self::FLAG_ESCALATE; } function getTranslateTag($subtag) { @@ -123,17 +120,17 @@ implements TemplateVariable { if ($errors) return false; - $this->isactive = $vars['isactive']; $this->name = $vars['name']; $this->grace_period = $vars['grace_period']; - $this->disable_overdue_alerts = isset($vars['disable_overdue_alerts']) ? 1 : 0; - $this->enable_priority_escalation = isset($vars['enable_priority_escalation'])? 1: 0; $this->notes = Format::sanitize($vars['notes']); + $this->flags = + ($vars['isactive'] ? self::FLAG_ACTIVE : 0) + | (isset($vars['disable_overdue_alerts']) ? self::FLAG_NOALERTS : 0) + | (isset($vars['enable_priority_escalation']) ? self::FLAG_ESCALATE : 0) + | (isset($vars['transient']) ? self::FLAG_TRANSIENT : 0); - if ($this->save()) { - $this->getConfig()->set('transient', isset($vars['transient']) ? 1 : 0); + if ($this->save()) return true; - } if (isset($this->id)) { $errors['err']=sprintf(__('Unable to update %s.'), __('this SLA plan')) @@ -176,10 +173,11 @@ implements TemplateVariable { $slas = self::objects() ->order_by('name') - ->values_flat('id', 'name', 'isactive', 'grace_period'); + ->values_flat('id', 'name', 'flags', 'grace_period'); $entries = array(); foreach ($slas as $row) { + $row[2] = $row[2] & self::FLAG_ACTIVE; $entries[$row[0]] = sprintf(__('%s (%d hours - %s)' /* Tokens are <name> (<#> hours - <Active|Disabled>) */), self::getLocalById($row[0], 'name', $row[1]), @@ -211,13 +209,4 @@ implements TemplateVariable { return $sla; } } - -require_once(INCLUDE_DIR.'class.config.php'); -class SlaConfig extends Config { - var $table = CONFIG_TABLE; - - function __construct($id) { - parent::__construct("sla.$id"); - } -} ?> diff --git a/include/class.staff.php b/include/class.staff.php index 2a9c3673bed43ffae8f80fd01834b246dc5d54ed..ceb42129cd1ef9b2c22a47ff281eed68ca2d8869 100644 --- a/include/class.staff.php +++ b/include/class.staff.php @@ -243,6 +243,30 @@ implements AuthenticatedUser, EmailContact, TemplateVariable { function getEmail() { return $this->email; } + /** + * Get either a Gravatar URL or complete image tag for a specified email address. + * + * @param string $email The email address + * @param string $s Size in pixels, defaults to 80px [ 1 - 2048 ] + * @param string $d Default imageset to use [ 404 | mm | identicon | monsterid | wavatar ] + * @param string $r Maximum rating (inclusive) [ g | pg | r | x ] + * @param boole $img True to return a complete IMG tag False for just the URL + * @param array $atts Optional, additional key/value attributes to include in the IMG tag + * @return String containing either just a URL or a complete image tag + * @source http://gravatar.com/site/implement/images/php/ + */ + function get_gravatar($s = 80, $img = false, $atts = array(), $d = 'retro', $r = 'g' ) { + $url = '//www.gravatar.com/avatar/'; + $url .= md5( strtolower( $this->getEmail() ) ); + $url .= "?s=$s&d=$d&r=$r"; + if ( $img ) { + $url = '<img src="' . $url . '"'; + foreach ( $atts as $key => $val ) + $url .= ' ' . $key . '="' . $val . '"'; + $url .= ' />'; + } + return $url; + } function getUserName() { return $this->username; diff --git a/include/class.team.php b/include/class.team.php index 04427634a0bcce591e1e460737f34cd445e5e56b..12d14e71382714cd42cb1c1991fc804ac9e703e4 100644 --- a/include/class.team.php +++ b/include/class.team.php @@ -33,6 +33,9 @@ implements TemplateVariable { ), ); + const FLAG_ENABLED = 0x0001; + const FLAG_NOALERTS = 0x0002; + var $_members; function asVar() { @@ -62,6 +65,9 @@ implements TemplateVariable { function getName() { return $this->name; } + function getLocalName() { + return $this->getLocal('name'); + } function getNumMembers() { return $this->members->count(); @@ -98,6 +104,8 @@ implements TemplateVariable { function getHashtable() { $base = $this->ht; + $base['isenabled'] = $this->isEnabled(); + $base['noalerts'] = !$this->alertsEnabled(); unset($base['staffmembers']); return $base; } @@ -107,7 +115,7 @@ implements TemplateVariable { } function isEnabled() { - return $this->isenabled; + return $this->flags & self::FLAG_ENABLED; } function isActive() { @@ -115,7 +123,7 @@ implements TemplateVariable { } function alertsEnabled() { - return !$this->noalerts; + return ($this->flags & self::FLAG_NOALERTS) == 0; } function getTranslateTag($subtag) { @@ -150,8 +158,9 @@ implements TemplateVariable { && in_array($this->lead_id, $vars['remove'])) $vars['lead_id'] =0 ; - $this->isenabled = $vars['isenabled']; - $this->noalerts = isset($vars['noalerts']) ? $vars['noalerts'] : 0; + $this->flags = + ($vars['isenabled'] ? self::FLAG_ENABLED : 0) + | (isset($vars['noalerts']) ? self::FLAG_NOALERTS : 0); $this->lead_id = $vars['lead_id'] ?: 0; $this->name = $vars['name']; $this->notes = Format::sanitize($vars['notes']); @@ -222,13 +231,13 @@ implements TemplateVariable { if (!$teams || $criteria) { $teams = array(); $query = static::objects() - ->values_flat('team_id', 'name', 'isenabled') + ->values_flat('team_id', 'name', 'flags') ->order_by('name'); if (isset($criteria['active']) && $criteria['active']) { $query->annotate(array('members_count'=>SqlAggregate::COUNT('members'))) ->filter(array( - 'isenabled'=>1, + 'flags__hasbit'=>self::FLAG_ENABLED, 'members__staff__isactive'=>1, 'members__staff__onvacation'=>0, 'members__staff__group__flags__hasbit'=>Group::FLAG_ENABLED, @@ -239,7 +248,8 @@ implements TemplateVariable { $items = array(); foreach ($query as $row) { //TODO: Fix enabled - flags is a bit field. - list($id, $name, $enabled) = $row; + list($id, $name, $flags) = $row; + $enabled = $flags & self::FLAG_ENABLED; $items[$id] = sprintf('%s%s', self::getLocalById($id, 'name', $name), ($enabled || isset($criteria['active'])) diff --git a/include/class.thread.php b/include/class.thread.php index bce15899974ffee9e8cf36ae076afd78c14d0cc7..6204bf39beee89c930db77da8e415487b5ad6bfb 100644 --- a/include/class.thread.php +++ b/include/class.thread.php @@ -42,9 +42,16 @@ class Thread extends VerySimpleModel { 'entries' => array( 'reverse' => 'ThreadEntry.thread', ), + 'events' => array( + 'reverse' => 'ThreadEvent.thread', + 'broker' => 'ThreadEvents', + ), ), ); + const MODE_STAFF = 1; + const MODE_CLIENT = 2; + var $_object; var $_collaborators; // Cache for collabs @@ -192,19 +199,25 @@ class Thread extends VerySimpleModel { return true; } // Render thread - function render($type=false) { + function render($type=false, $mode=self::MODE_STAFF) { $entries = $this->getEntries(); if ($type && is_array($type)) $entries->filter(array('type__in' => $type)); - include STAFFINC_DIR . 'templates/thread-entries.tmpl.php'; + $events = $this->getEvents(); + $inc = ($mode == self::MODE_STAFF) ? STAFFINC_DIR : CLIENTINC_DIR; + include $inc . 'templates/thread-entries.tmpl.php'; } function getEntry($id) { return ThreadEntry::lookup($id, $this->getId()); } + function getEvents() { + return $this->events; + } + /** * postEmail * @@ -864,7 +877,7 @@ implements TemplateVariable { Save attachment to the DB. @file is a mixed var - can be ID or file hashtable. */ - function saveAttachment(&$file) { + function saveAttachment(&$file, $name=false) { $inline = is_array($file) && @$file['inline']; @@ -885,6 +898,21 @@ implements TemplateVariable { 'file_id' => $fileId, 'inline' => $inline ? 1 : 0, )); + + // Record varying file names in the attachment record + if (is_array($file) && isset($file['name'])) { + $filename = $file['name']; + } + elseif (is_string($name)) { + $filename = $name; + } + if ($filename) { + // This should be a noop since the ORM caches on PK + $file = AttachmentFile::lookup($fileId); + if ($file->name != $filename) + $att->name = $filename; + } + if (!$att->save()) return false; return $att; @@ -892,9 +920,10 @@ implements TemplateVariable { function saveAttachments($files) { $attachments = array(); - foreach ($files as $file) - if (($A = $this->saveAttachment($file))) + foreach ($files as $name=>$file) { + if (($A = $this->saveAttachment($file, $name))) $attachments[] = $A; + } return $attachments; } @@ -908,7 +937,7 @@ implements TemplateVariable { foreach ($this->attachments as $att) { $json[$att->file->getKey()] = array( 'download_url' => $att->file->getDownloadUrl(), - 'filename' => $att->file->name, + 'filename' => $att->getFilename(), ); } @@ -1406,6 +1435,311 @@ implements TemplateVariable { RolePermission::register(/* @trans */ 'Tickets', ThreadEntry::getPermissions()); +class ThreadEvent extends VerySimpleModel { + static $meta = array( + 'table' => THREAD_EVENT_TABLE, + 'pk' => array('id'), + 'joins' => array( + // Originator of activity + 'agent' => array( + 'constraint' => array( + 'uid' => 'Staff.staff_id', + ), + 'null' => true, + ), + // Agent assignee + 'staff' => array( + 'constraint' => array( + 'staff_id' => 'Staff.staff_id', + ), + 'null' => true, + ), + 'team' => array( + 'constraint' => array( + 'team_id' => 'Team.team_id', + ), + 'null' => true, + ), + 'thread' => array( + 'constraint' => array('thread_id' => 'Thread.id'), + ), + 'user' => array( + 'constraint' => array( + 'uid' => 'User.id', + ), + 'null' => true, + ), + 'dept' => array( + 'constraint' => array( + 'dept_id' => 'Dept.id', + ), + 'null' => true, + ), + ), + ); + + // Valid events for database storage + const ASSIGNED = 'assigned'; + const CLOSED = 'closed'; + const CREATED = 'created'; + const COLLAB = 'collab'; + const EDITED = 'edited'; + const ERROR = 'error'; + const OVERDUE = 'overdue'; + const REOPENED = 'reopened'; + const STATUS = 'status'; + const TRANFERRED = 'transferred'; + const VIEWED = 'viewed'; + + const MODE_STAFF = 1; + const MODE_CLIENT = 2; + + var $_data; + + function getAvatar($size=16) { + if ($this->uid && $this->uid_type == 'S') + return $this->agent->get_gravatar($size); + if ($this->uid && $this->uid_type == 'U') + return $this->user->get_gravatar($size); + } + + function getUserName() { + if ($this->uid && $this->uid_type == 'S') + return $this->agent->getName(); + if ($this->uid && $this->uid_type == 'U') + return $this->user->getName(); + return $this->username; + } + + function getIcon() { + $icons = array( + 'assigned' => 'hand-right', + 'collab' => 'group', + 'created' => 'magic', + 'overdue' => 'time', + 'transferred' => 'share-alt', + 'edited' => 'pencil', + ); + 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); + + return preg_replace_callback('/\{(<(?P<type>([^>]+))>)?(?P<key>[^}.]+)(\.(?P<data>[^}]+))?\}/', + function ($m) use ($self) { + switch ($m['key']) { + case 'assignees': + $assignees = array(); + if ($S = $this->staff) { + $url = $S->get_gravatar(16); + $assignees[] = + "<img class=\"avatar\" src=\"{$url}\"> ".$S->getName(); + } + if ($T = $this->team) { + $assignees[] = $T->getLocalName(); + } + return implode('/', $assignees); + case 'username': + $name = $self->getUserName(); + if ($url = $self->getAvatar()) + $name = "<img class=\"avatar\" src=\"{$url}\"> ".$name; + return $name; + case 'timestamp': + return sprintf('<time class="relative" datetime="%s" title="%s">%s</time>', + date(DateTime::W3C, Misc::db2gmtime($self->timestamp)), + Format::daydatetime($self->timestamp), + Format::relativeTime(Misc::db2gmtime($self->timestamp)) + ); + case 'agent': + $st = $this->agent; + if ($url = $self->getAvatar()) + $name = "<img class=\"avatar\" src=\"{$url}\"> ".$name; + case 'dept': + if ($dept = $this->getDept()) + return $dept->getLocalName(); + case 'data': + $val = $self->getData($m['data']); + if ($m['type'] && class_exists($m['type'])) + $val = $m['type']::lookup($val); + return (string) $val; + } + return $m[0]; + }, + $description + ); + } + + function getDept() { + return $this->dept; + } + + function getData($key=false) { + if (!isset($this->_data)) + $this->_data = JsonDataParser::decode($this->data); + return ($key) ? @$this->_data[$key] : $this->_data; + } + + function render($mode) { + $inc = ($mode == self::MODE_STAFF) ? STAFFINC_DIR : CLIENTINC_DIR; + $event = $this; + include $inc . 'templates/thread-event.tmpl.php'; + } + + static function create($ht=false) { + $inst = parent::create($ht); + $inst->timestamp = SqlFunction::NOW(); + + global $thisstaff, $thisclient; + if ($thisstaff) { + $inst->uid_type = 'S'; + $inst->uid = $thisstaff->getId(); + } + else if ($thisclient) { + $inst->uid_type = 'U'; + $inst->uid = $thisclient->getId(); + } + + return $inst; + } + + static function forTicket($ticket, $state) { + $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(); + } + return $inst; + } +} + +class ThreadEvents extends InstrumentedList { + function annul($event) { + $this->queryset + ->filter(array('state' => $event)) + ->update(array('annulled' => 1)); + } + + function log($object, $state, $data=null, $annul=null, $username=null) { + if ($object instanceof Ticket) + $event = ThreadEvent::forTicket($object, $state); + else + $event = ThreadEvent::create(); + + # Annul previous entries if requested (for instance, reopening a + # ticket will annul an 'closed' entry). This will be useful to + # easily prevent repeated statistics. + if ($annul) { + $this->annul($annul); + } + + if ($username === null) { + if ($thisstaff) { + $username = $thisstaff->getUserName(); + } + else if ($thisclient) { + if ($thisclient->hasAccount) + $username = $thisclient->getAccount()->getUserName(); + if (!$username) + $username = $thisclient->getEmail(); + } + else { + # XXX: Security Violation ? + $username = 'SYSTEM'; + } + } + $event->username = $username; + $event->state = $state; + + if ($data) { + if (is_array($data)) + $data = JsonDataEncoder::encode($data); + if (!is_string($data)) + throw new InvalidArgumentException('Data must be string or array'); + $event->data = $data; + } + + $this->add($event); + + // Save event immediately + return $event->save(); + } +} class ThreadEntryBody /* extends SplString */ { diff --git a/include/class.ticket.php b/include/class.ticket.php index d571a845db87e4fd9b9b345de29c12750a3aa66b..7df96993872ba5efb53339b124402c9976a0916d 100644 --- a/include/class.ticket.php +++ b/include/class.ticket.php @@ -943,6 +943,8 @@ implements RestrictedAccess, Threadable, TemplateVariable { $this->collaborators = null; $this->recipients = null; + $this->logEvent('collab', array('add' => array($c->toString()))); + return $c; } @@ -959,11 +961,10 @@ implements RestrictedAccess, Threadable, TemplateVariable { if (($c=Collaborator::lookup($cid)) && $c->getTicketId() == $this->getId() && $c->delete()) - $collabs[] = $c; + $collabs[] = (string) $c; } - $this->logNote(_S('Collaborators Removed'), - implode("<br>", $collabs), $thisstaff, false); + $this->logEvent('collab', array('del' => $collabs)); } //statuses @@ -1164,6 +1165,7 @@ implements RestrictedAccess, Threadable, TemplateVariable { } } + $hadStatus = $this->getStatusId(); if ($this->getStatusId() == $status->getId()) return true; @@ -1196,7 +1198,7 @@ implements RestrictedAccess, Threadable, TemplateVariable { if ($this->isClosed()) { $sql .= ',closed=NULL, lastupdate=NOW(), reopened=NOW() '; $ecb = function ($t) { - $t->logEvent('reopened', 'closed'); + $t->logEvent('reopened', false, 'closed'); }; } @@ -1228,12 +1230,15 @@ implements RestrictedAccess, Threadable, TemplateVariable { $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'), $note, $thisstaff, $alert); } // Log events via callback - if ($ecb) $ecb($this); + if ($ecb) + $ecb($this); + elseif ($hadStatus) + // Don't log the initial status change + $this->logEvent('edited', array('status' => $status->getId())); return true; } @@ -1662,13 +1667,16 @@ implements RestrictedAccess, Threadable, TemplateVariable { $this->reload(); + $user_comments = (bool) $comments; $comments = $comments ?: _S('Ticket assignment'); $assigner = $thisstaff ?: _S('SYSTEM (Auto Assignment)'); //Log an internal note - no alerts on the internal note. - $note = $this->logNote( - sprintf(_S('Ticket Assigned to %s'), $assignee->getName()), - $comments, $assigner, false); + if ($user_comments) { + $note = $this->logNote( + sprintf(_S('Ticket Assigned to %s'), $assignee->getName()), + $comments, $assigner, false); + } //See if we need to send alerts if(!$alert || !$cfg->alertONAssignment()) return true; //No alerts! @@ -1971,8 +1979,11 @@ implements RestrictedAccess, Threadable, TemplateVariable { /*** log the transfer comments as internal note - with alerts disabled - ***/ $title=sprintf(_S('Ticket transferred from %1$s to %2$s'), $currentDept, $this->getDeptName()); - $comments=$comments?$comments:$title; - $note = $this->logNote($title, $comments, $thisstaff, false); + + if ($comments) { + $note = $this->logNote($title, $comments, $thisstaff, false); + } + $comments = $comments ?: $title; $this->logEvent('transferred'); @@ -2004,11 +2015,13 @@ implements RestrictedAccess, Threadable, TemplateVariable { if($cfg->alertDeptManagerONTransfer() && $dept && ($manager=$dept->getManager())) $recipients[]= $manager; - $sentlist=array(); - $options = array( - 'inreplyto'=>$note->getEmailMessageId(), - 'references'=>$note->getEmailReferences(), - 'thread'=>$note); + $sentlist = $options = array(); + if ($note) { + $options += array( + 'inreplyto'=>$note->getEmailMessageId(), + 'references'=>$note->getEmailReferences(), + 'thread'=>$note); + } foreach( $recipients as $k=>$staff) { if(!is_object($staff) || !$staff->isAvailable() || in_array($staff->getEmail(), $sentlist)) continue; $alert = $this->replaceVars($msg, array('recipient' => $staff)); @@ -2030,9 +2043,7 @@ implements RestrictedAccess, Threadable, TemplateVariable { if ($dept->assignMembersOnly() && !$dept->isMember($thisstaff)) return false; - $comments = sprintf(_S('Ticket claimed by %s'), $thisstaff->getName()); - - return $this->assignToStaff($thisstaff->getId(), $comments, false); + return $this->assignToStaff($thisstaff->getId(), null, false); } function assignToStaff($staff, $note, $alert=true) { @@ -2044,7 +2055,14 @@ implements RestrictedAccess, Threadable, TemplateVariable { return false; $this->onAssign($staff, $note, $alert); - $this->logEvent('assigned'); + + global $thisstaff; + $data = array(); + if ($staff->getId() == $thisstaff->getId()) + $data['claim'] = true; + else + $data['staff'] = $staff->getId(); + $this->logEvent('assigned', $data); return true; } @@ -2063,7 +2081,7 @@ implements RestrictedAccess, Threadable, TemplateVariable { $this->setStaffId(0); $this->onAssign($team, $note, $alert); - $this->logEvent('assigned'); + $this->logEvent('assigned', array('team' => $team->getId())); return true; } @@ -2134,18 +2152,15 @@ implements RestrictedAccess, Threadable, TemplateVariable { $this->collaborators = null; $this->recipients = null; - //Log an internal note - $note = sprintf(_S('%s changed ticket ownership to %s'), - $thisstaff->getName(), $user->getName()); - - //Remove the new owner from list of collaborators + // Remove the new owner from list of collaborators $c = Collaborator::lookup(array( - 'user_id' => $user->getId(), - 'thread_id' => $this->getThreadId())); - if ($c && $c->delete()) - $note.= ' '._S('(removed as collaborator)'); + 'user_id' => $user->getId(), + 'thread_id' => $this->getThreadId() + )); + if ($c) + $c->delete(); - $this->logNote('Ticket ownership changed', $note); + $this->logEvent('edited', array('owner' => $user->getId())); return true; } @@ -2184,18 +2199,11 @@ implements RestrictedAccess, Threadable, TemplateVariable { if (($user=User::fromVars($recipient))) if ($c=$this->addCollaborator($user, $info, $errors)) - $collabs[] = sprintf('%s%s', - (string) $c, - $recipient['source'] - ? " ".sprintf(_S('via %s'), $recipient['source']) - : '' - ); + $collabs[] = array((string)$c, $recipient['source']); } //TODO: Can collaborators add others? if ($collabs) { - //TODO: Change EndUser to name of user. - $this->logNote(_S('Collaborators added by end user'), - implode("<br>", $collabs), _S('End User'), false); + $this->logEvent('collab', array('add' => $collabs)); } } @@ -2411,31 +2419,8 @@ implements RestrictedAccess, Threadable, TemplateVariable { } // History log -- used for statistics generation (pretty reports) - function logEvent($state, $annul=null, $staff=null) { - global $thisstaff; - - if ($staff === null) { - if ($thisstaff) $staff=$thisstaff->getUserName(); - else $staff='SYSTEM'; # XXX: Security Violation ? - } - # Annul previous entries if requested (for instance, reopening a - # ticket will annul an 'closed' entry). This will be useful to - # easily prevent repeated statistics. - if ($annul) { - db_query('UPDATE '.TICKET_EVENT_TABLE.' SET annulled=1' - .' WHERE ticket_id='.db_input($this->getId()) - .' AND state='.db_input($annul)); - } - - return db_query('INSERT INTO '.TICKET_EVENT_TABLE - .' SET ticket_id='.db_input($this->getId()) - .', staff_id='.db_input($this->getStaffId()) - .', team_id='.db_input($this->getTeamId()) - .', dept_id='.db_input($this->getDeptId()) - .', topic_id='.db_input($this->getTopicId()) - .', timestamp=NOW(), state='.db_input($state) - .', staff='.db_input($staff)) - && db_affected_rows() == 1; + function logEvent($state, $data=null, $annul=null, $staff=null) { + $this->getThread()->getEvents()->log($this, $state, $data, $annul, $staff); } //Insert Internal Notes @@ -2593,7 +2578,6 @@ implements RestrictedAccess, Threadable, TemplateVariable { $fields['slaId'] = array('type'=>'int', 'required'=>0, 'error'=>__('Select a valid SLA')); $fields['duedate'] = array('type'=>'date', 'required'=>0, 'error'=>__('Invalid date format - must be MM/DD/YY')); - $fields['note'] = array('type'=>'text', 'required'=>1, 'error'=>__('A reason for the update is required')); $fields['user_id'] = array('type'=>'int', 'required'=>0, 'error'=>__('Invalid user-id')); if(!Validator::process($fields, $vars, $errors) && !$errors['err']) @@ -2644,16 +2628,16 @@ implements RestrictedAccess, Threadable, TemplateVariable { if(!db_query($sql) || !db_affected_rows()) return false; - if(!$vars['note']) - $vars['note']=sprintf(_S('Ticket details updated by %s'), $thisstaff->getName()); - - $this->logNote(_S('Ticket Updated'), $vars['note'], $thisstaff); + if ($vars['note']) + $this->logNote(_S('Ticket Updated'), $vars['note'], $thisstaff); // Decide if we need to keep the just selected SLA $keepSLA = ($this->getSLAId() != $vars['slaId']); // Update dynamic meta-data + $changes = array(); foreach ($forms as $f) { + $changes += $f->getChanges(); // Drop deleted forms $idx = array_search($f->getId(), $vars['forms']); if ($idx === false) { @@ -2665,6 +2649,9 @@ implements RestrictedAccess, Threadable, TemplateVariable { } } + if ($changes) + $this->logEvent('edited', array('fields' => $changes)); + // Reload the ticket so we can do further checking $this->reload(); @@ -3262,10 +3249,7 @@ implements RestrictedAccess, Threadable, TemplateVariable { } //TODO: Can collaborators add others? if ($collabs) { - //TODO: Change EndUser to name of user. - $ticket->logNote(sprintf(_S('Collaborators for %s organization added'), - $org->getName()), - implode("<br>", $collabs), $org->getName(), false); + $ticket->logEvent('collab', array('org' => $org->getId())); } } @@ -3418,11 +3402,6 @@ implements RestrictedAccess, Threadable, TemplateVariable { } $ticket->logNote(_S('New Ticket'), $vars['note'], $thisstaff, false); } - else { - // Not assignment and no internal note - log activity - $ticket->logActivity(_S('New Ticket by Agent'), - sprintf(_S('Ticket created by agent - %s'), $thisstaff->getName())); - } $ticket->reload(); @@ -3493,9 +3472,8 @@ implements RestrictedAccess, Threadable, TemplateVariable { if(($res=db_query($sql)) && db_num_rows($res)) { while(list($id)=db_fetch_row($res)) { - if(($ticket=Ticket::lookup($id)) && $ticket->markOverdue()) - $ticket->logActivity(_S('Ticket Marked Overdue'), - _S('Ticket flagged as overdue by the system.')); + if ($ticket=Ticket::lookup($id)) + $ticket->markOverdue(); } } else { //TODO: Trigger escalation on already overdue tickets - make sure last overdue event > grace_period. diff --git a/include/class.user.php b/include/class.user.php index 751d17368b442f956b55fe6d76843894eaaaef2b..337eaf9501aba38f68752d0b1cdc7a1e187c773a 100644 --- a/include/class.user.php +++ b/include/class.user.php @@ -39,7 +39,7 @@ class UserModel extends VerySimpleModel { static $meta = array( 'table' => USER_TABLE, 'pk' => array('id'), - 'select_related' => array('default_email'), + 'select_related' => array('account', 'default_email'), 'joins' => array( 'emails' => array( 'reverse' => 'UserEmailModel.user', @@ -123,6 +123,9 @@ class UserModel extends VerySimpleModel { return $this->default_email; } + function hasAccount() { + return !is_null($this->account); + } function getAccount() { return $this->account; } @@ -264,6 +267,30 @@ implements TemplateVariable { function getEmail() { return new EmailAddress($this->default_email->address); } + /** + * Get either a Gravatar URL or complete image tag for a specified email address. + * + * @param string $email The email address + * @param string $s Size in pixels, defaults to 80px [ 1 - 2048 ] + * @param string $d Default imageset to use [ 404 | mm | identicon | monsterid | wavatar ] + * @param string $r Maximum rating (inclusive) [ g | pg | r | x ] + * @param boole $img True to return a complete IMG tag False for just the URL + * @param array $atts Optional, additional key/value attributes to include in the IMG tag + * @return String containing either just a URL or a complete image tag + * @source http://gravatar.com/site/implement/images/php/ + */ + function get_gravatar($s = 80, $img = false, $atts = array(), $d = 'retro', $r = 'g' ) { + $url = '//www.gravatar.com/avatar/'; + $url .= md5( strtolower( $this->default_email->address ) ); + $url .= "?s=$s&d=$d&r=$r"; + if ( $img ) { + $url = '<img src="' . $url . '"'; + foreach ( $atts as $key => $val ) + $url .= ' ' . $key . '="' . $val . '"'; + $url .= ' />'; + } + return $url; + } function getFullName() { return $this->name; diff --git a/include/client/templates/thread-entries.tmpl.php b/include/client/templates/thread-entries.tmpl.php new file mode 100644 index 0000000000000000000000000000000000000000..d031182cad2fed9947e5bf75a953b05fb81b0374 --- /dev/null +++ b/include/client/templates/thread-entries.tmpl.php @@ -0,0 +1,49 @@ +<?php +$events = $events + ->filter(array('state__in' => array('created', 'closed', 'reopened', 'edited', 'collab'))) + ->order_by('id'); +$events = $events->getIterator(); +$events->rewind(); +$event = $events->current(); + +if (count($entries)) { + // Go through all the entries and bucket them by time frame + $buckets = array(); + $rel = 0; + foreach ($entries as $i=>$E) { + // First item _always_ shows up + if ($i != 0) + // Set relative time resolution to 12 hours + $rel = Format::relativeTime(Misc::db2gmtime($E->created, false, 43200)); + $buckets[$rel][] = $E; + } + + // Go back through the entries and render them on the page + $i = 0; + foreach ($buckets as $rel=>$entries) { + // TODO: Consider adding a date boundary to indicate significant + // changes in dates between thread items. + foreach ($entries as $entry) { + // Emit all events prior to this entry + while ($event && $event->timestamp <= $entry->created) { + $event->render(ThreadEvent::MODE_CLIENT); + $events->next(); + $event = $events->current(); + } + include 'thread-entry.tmpl.php'; + } + $i++; + } +} + +// Emit all other events +while ($event) { + $event->render(ThreadEvent::MODE_CLIENT); + $events->next(); + $event = $events->current(); +} + +// This should never happen +if (count($entries) + count($events) == 0) { + echo '<p><em>'.__('No entries have been posted to this thread.').'</em></p>'; +} diff --git a/include/client/templates/thread-entry.tmpl.php b/include/client/templates/thread-entry.tmpl.php new file mode 100644 index 0000000000000000000000000000000000000000..70e4bbb5c6fae24ba42a48a2c67520b044d03e58 --- /dev/null +++ b/include/client/templates/thread-entry.tmpl.php @@ -0,0 +1,91 @@ +<?php +$entryTypes = array('M'=>'message', 'R'=>'response', 'N'=>'note'); +$user = $entry->getUser() ?: $entry->getStaff(); +$name = $user ? $user->getName() : $entry->poster; +$avatar = ''; +if ($user && ($url = $user->get_gravatar(48))) + $avatar = "<img class=\"avatar\" src=\"{$url}\"> "; +?> + +<div class="thread-entry <?php echo $entryTypes[$entry->type]; ?> <?php if ($avatar) echo 'avatar'; ?>"> +<?php if ($avatar) { ?> + <span class="<?php echo ($entry->type == 'M') ? 'pull-left' : 'pull-right'; ?> avatar"> +<?php echo $avatar; ?> + </span> +<?php } ?> + <div class="header"> + <div class="pull-right"> +<?php if ($entry->hasActions()) { + $actions = $entry->getActions(); ?> + <span class="muted-button pull-right" data-dropdown="#entry-action-more-<?php echo $entry->getId(); ?>"> + <i class="icon-caret-down"></i> + </span> + <div id="entry-action-more-<?php echo $entry->getId(); ?>" class="action-dropdown anchor-right"> + <ul class="title"> +<?php foreach ($actions as $group => $list) { + foreach ($list as $id => $action) { ?> + <li> + <a class="no-pjax" href="#" onclick="javascript: + <?php echo str_replace('"', '\\"', $action->getJsStub()); ?>; return false;"> + <i class="<?php echo $action->getIcon(); ?>"></i> <?php + echo $action->getName(); + ?></a></li> +<?php } + } ?> + </ul> + </div> +<?php } ?> + <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'); + ?>"><?php echo __('Edited'); ?></span> + <?php } ?> + </span> + </div> +<?php + echo sprintf(__('<b>%s</b> posted %s'), $name, + sprintf('<time class="relative" datetime="%s" title="%s">%s</time>', + date(DateTime::W3C, Misc::db2gmtime($entry->created)), + Format::daydatetime($entry->created), + Format::relativeTime(Misc::db2gmtime($entry->created)) + ) + ); ?> + <span style="max-width:500px" class="faded title truncate"><?php + echo $entry->title; ?></span> + </span> + </div> + <div class="thread-body" id="thread-id-<?php echo $entry->getId(); ?>"> + <div><?php echo $entry->getBody()->toHtml(); ?></div> +<?php + if ($entry->has_attachments) { ?> + <div class="attachments"><?php + foreach ($entry->attachments as $A) { + if ($A->inline) + continue; + $size = ''; + if ($A->file->size) + $size = sprintf('<small class="filesize faded">%s</small>', Format::file_size($A->file->size)); +?> + <span class="attachment-info"> + <i class="icon-paperclip icon-flip-horizontal"></i> + <a class="no-pjax truncate filename" href="<?php echo $A->file->getDownloadUrl(); + ?>" download="<?php echo Format::htmlchars($A->getFilename()); ?>" + target="_blank"><?php echo Format::htmlchars($A->getFilename()); + ?></a><?php echo $size;?> + </span> +<?php } ?> + </div> +<?php } ?> + </div> +<?php + if ($urls = $entry->getAttachmentUrls()) { ?> + <script type="text/javascript"> + $('#thread-id-<?php echo $entry->getId(); ?>') + .data('urls', <?php + echo JsonDataEncoder::encode($urls); ?>) + .data('id', <?php echo $entry->getId(); ?>); + </script> +<?php + } ?> +</div> diff --git a/include/client/templates/thread-event.tmpl.php b/include/client/templates/thread-event.tmpl.php new file mode 100644 index 0000000000000000000000000000000000000000..42fd8027e0024324e15407e0cd4add7c0b69dc83 --- /dev/null +++ b/include/client/templates/thread-event.tmpl.php @@ -0,0 +1,11 @@ +<?php +$desc = $event->getDescription(ThreadEvent::MODE_CLIENT); +if (!$desc) + return; +?> +<div class="thread-event <?php if ($event->uid) echo 'action'; ?>"> + <span class="type-icon"> + <i class="faded icon-<?php echo $event->getIcon(); ?>"></i> + </span> + <span class="faded description"><?php echo $desc; ?></span> +</div> diff --git a/include/client/view.inc.php b/include/client/view.inc.php index 2aead70b0e28f0bad62c61c7dca9c2546107cae7..498e6d6aecd75a1aaca5e61a432ee731e1e0f12f 100644 --- a/include/client/view.inc.php +++ b/include/client/view.inc.php @@ -126,55 +126,13 @@ foreach (DynamicFormEntry::forTicket($ticket->getId()) as $form) { </tr> </table> <br> + <div id="ticketThread"> <?php -if($ticket->getThreadCount() && ($thread=$ticket->getClientThread())) { - $threadType=array('M' => 'message', 'R' => 'response'); - foreach($thread as $entry) { - - //Making sure internal notes are not displayed due to backend MISTAKES! - if(!$threadType[$entry->type]) continue; - $poster = $entry->poster; - if($entry->type=='R' && ($cfg->hideStaffName() || !$entry->staff_id)) - $poster = ' '; - ?> - <table class="thread-entry <?php echo $threadType[$entry->type]; ?>" cellspacing="0" cellpadding="1" width="800" border="0"> - <tr><th><div> -<?php echo Format::datetime($entry->created); ?> - <span class="textra"></span> - <span><?php echo $poster; ?></span> - </div> - </th></tr> - <tr><td class="thread-body"><div><?php echo Format::clickableurls($entry->getBody()->toHtml()); ?></div></td></tr> - <?php - $urls = null; - if ($entry->has_attachments - && ($urls = $entry->getAttachmentUrls())) { ?> - <tr> - <td class="info"><?php - foreach ($entry->attachments as $A) { - if ($A->inline) continue; - $size = ''; - if ($A->file->size) - $size = sprintf('<em>(%s)</em>', - Format::file_size($A->file->size)); -?> - <i class="icon-paperclip"></i> - <a class="no-pjax" href="<?php echo $A->file->getDownloadUrl(); - ?>" download="<?php echo Format::htmlchars($A->file->name); ?>" - target="_blank"> - <?php echo Format::htmlchars($A->file->name); - ?></a><?php echo $size;?> -<?php } ?> - </td> - </tr> -<?php } ?> - </table> - <?php - } -} + $ticket->getThread()->render(array('M', 'R'), Thread::MODE_CLIENT); ?> </div> + <div class="clear" style="padding-bottom:10px;"></div> <?php if($errors['err']) { ?> <div id="msg_error"><?php echo $errors['err']; ?></div> diff --git a/include/html2text.php b/include/html2text.php index 1d12c733c3d3582bc3daede37cd9580b08a88314..8da0755fc90297d10ed076ede0f5eae10503691a 100644 --- a/include/html2text.php +++ b/include/html2text.php @@ -48,8 +48,18 @@ function convert_html_to_text($html, $width=74) { $elements->getRoot()->addStylesheet( HtmlStylesheet::fromArray(array( 'html' => array('white-space' => 'pre'), # Don't wrap footnotes + 'center' => array('text-align' => 'center'), 'p' => array('margin-bottom' => '1em'), 'pre' => array('white-space' => 'pre'), + 'u' => array('text-decoration' => 'underline'), + 'a' => array('text-decoration' => 'underline'), + 'b' => array('text-transform' => 'uppercase'), + 'strong' => array('text-transform' => 'uppercase'), + 'h4' => array('text-transform' => 'uppercase'), + + // Crazy M$ styles + '.MsoNormal' => array('margin' => 0, 'margin-bottom' => 0.0001), + '.MsoPlainText' => array('margin' => 0, 'margin-bottom' => 0.0001), )) ); $options = array(); @@ -108,6 +118,7 @@ function identify_node($node, $parent=null) { case "head": case "html": case "body": + case "center": case "div": case "p": case "pre": @@ -129,13 +140,6 @@ function identify_node($node, $parent=null) { case "a": return new HtmlAElement($node, $parent); - case "b": - case "strong": - return new HtmlBElement($node, $parent); - - case "u": - return new HtmlUElement($node, $parent); - case "ol": return new HtmlListElement($node, $parent); case "ul": @@ -152,8 +156,8 @@ function identify_node($node, $parent=null) { default: // print out contents of unknown tags - if ($node->hasChildNodes() && $node->childNodes->length == 1) - return identify_node($node->childNodes->item(0), $parent); + //if ($node->hasChildNodes() && $node->childNodes->length == 1) + // return identify_node($node->childNodes->item(0), $parent); return new HtmlInlineElement($node, $parent); } @@ -170,9 +174,10 @@ class HtmlInlineElement { $this->parent = $parent; $this->node = $node; $this->traverse($node); + $this->style = new CssStyleRules(); if ($node instanceof DomElement && ($style = $this->node->getAttribute('style'))) - $this->style = new CssStyleRules($style); + $this->style->add($style); } function traverse($node) { @@ -188,6 +193,19 @@ class HtmlInlineElement { $output = ''; $after_block = false; $this->ws = $this->getStyle('white-space', 'normal'); + // Direction + $dir = $this->node->getAttribute('dir'); + // Ensure we have a value, but don't emit a control char unless + // direction is declared + $this->dir = $dir ?: 'ltr'; + switch (strtolower($dir)) { + case 'ltr': + $output .= "\xE2\x80\x8E"; # LEFT-TO-RIGHT MARK + break; + case 'rtl': + $output .= "\xE2\x80\x8F"; # RIGHT-TO-LEFT MARK + break; + } foreach ($this->children as $c) { if ($c instanceof DOMText) { // Collapse white-space @@ -201,6 +219,10 @@ class HtmlInlineElement { case 'normal': default: if ($after_block) $more = ltrim($more); + if ($this instanceof HtmlBlockElement && trim($more) == '') + // Ignore pure whitespace in-between elements inside + // block elements + $more = ''; $more = preg_replace('/[ \r\n\t\f]+/mu', ' ', $more); } } @@ -209,6 +231,10 @@ class HtmlInlineElement { } else { $more = $c; + if (!$after_block) + // Prepend a newline. Block elements should start to the + // far left + $output .= "\n"; } $after_block = ($c instanceof HtmlBlockElement); if ($more instanceof PreFormattedText) @@ -216,7 +242,22 @@ class HtmlInlineElement { elseif (is_string($more)) $output .= $more; } + switch ($this->getStyle('text-transform', 'none')) { + case 'uppercase': + $output = mb_strtoupper($output); + break; + } + switch ($this->getStyle('text-decoration', 'none')) { + case 'underline': + // Split diacritics and underline chars which do not go below + // the baseline + if (class_exists('Normalizer')) + $output = Normalizer::normalize($output, Normalizer::FORM_D); + $output = preg_replace("/[a-fhik-or-xzA-PR-Z0-9#]/u", "$0\xcc\xb2", $output); + break; + } if ($this->footnotes) { + $output = rtrim($output, "\n"); $output .= "\n\n" . str_repeat('-', $width/2) . "\n"; $id = 1; foreach ($this->footnotes as $name=>$content) @@ -232,15 +273,19 @@ class HtmlInlineElement { if ($c instanceof HtmlInlineElement) $this->weight += $c->getWeight(); elseif ($c instanceof DomText) - $this->weight += mb_strwidth($c->wholeText); + $this->weight += mb_strwidth2($c->wholeText); } } return $this->weight; } + function setStyle($property, $value) { + $this->style->set($property, $value); + } + function getStyle($property, $default=null, $tag=false, $classes=false) { if ($this->style && $this->style->has($property)) - return $this->style->get($property); + return $this->style->get($property, $default); if ($tag === false) $tag = $this->node->nodeName; @@ -284,6 +329,14 @@ class HtmlInlineElement { class HtmlBlockElement extends HtmlInlineElement { var $min_width = false; + var $pad_left; + var $pad_right; + + function __construct($node, $parent) { + parent::__construct($node, $parent); + $this->pad_left = str_repeat(' ', $this->getStyle('padding-left', 0.0)); + $this->pad_right = str_repeat(' ', $this->getStyle('padding-right', 0.0)); + } function render($width, $options) { // Allow room for the border. @@ -295,12 +348,16 @@ class HtmlBlockElement extends HtmlInlineElement { $output = parent::render($width, $options); if ($output instanceof PreFormattedText) // TODO: Consider CSS rules - return new PreFormattedText("\n" . $output); + return $output; + // Leading and trailing whitespace is ignored in block elements $output = trim($output); if (!strlen($output)) return ""; + // Padding + $width -= strlen($this->pad_left) + strlen($this->pad_right); + // Wordwrap the content to the width switch ($this->ws) { case 'nowrap': @@ -313,17 +370,41 @@ class HtmlBlockElement extends HtmlInlineElement { $output = mb_wordwrap($output, $width, "\n", true); } - // Apply stylesheet styles - // TODO: Padding - // TODO: Justification + // Justification + static $aligns = array( + 'left' => STR_PAD_RIGHT, + 'right' => STR_PAD_LEFT, + 'center' => STR_PAD_BOTH, + ); + $talign = $this->getStyle('text-align', 'none'); + $self = $this; + if (isset($aligns[$talign])) { + // Explode lines, justify, implode again + $output = array_map(function($l) use ($talign, $aligns, $width, $self) { + return $self->pad_left.mb_str_pad($l, $width, ' ', $aligns[$talign]).$self->pad_right; + }, explode("\n", $output) + ); + $output = implode("\n", $output); + } + // Apply left and right padding, if specified + elseif ($this->pad_left || $this->pad_right) { + $output = array_map(function($l) use ($self) { + return $self->pad_left.$l.$self->pad_right; + }, explode("\n", $output) + ); + $output = implode("\n", $output); + } + // Border if ($bw) $output = self::borderize($output, $width); + // Margin - $mb = $this->getStyle('margin-bottom', 0); + $mb = $this->getStyle('margin-bottom', 0.0) + + $this->getStyle('padding-bottom', 0.0); $output .= str_repeat("\n", (int)$mb); - return "\n" . $output; + return $output."\n"; } function borderize($what, $width) { @@ -340,11 +421,11 @@ class HtmlBlockElement extends HtmlInlineElement { if ($c instanceof HtmlBlockElement) $this->min_width = max($c->getMinWidth(), $this->min_width); elseif ($c instanceof DomText) - $this->min_width = max(max(array_map('mb_strwidth', + $this->min_width = max(max(array_map('mb_strwidth2', explode(' ', $c->wholeText))), $this->min_width); } } - return $this->min_width; + return $this->min_width + strlen($this->pad_left) + strlen($this->pad_right); } } @@ -353,25 +434,10 @@ class HtmlBrElement extends HtmlBlockElement { return "\n"; } } -class HtmlUElement extends HtmlInlineElement { - function render($width, $options) { - $output = parent::render($width, $options); - return "_".str_replace(" ", "_", $output)."_"; - } - function getWeight() { return parent::getWeight() + 2; } -} - -class HtmlBElement extends HtmlInlineElement { - function render($width, $options) { - $output = parent::render($width, $options); - return "*".$output."*"; - } - function getWeight() { return parent::getWeight() + 2; } -} class HtmlHrElement extends HtmlBlockElement { function render($width, $options) { - return "\n".str_repeat('-', $width)."\n"; + return str_repeat("\xE2\x94\x80", $width)."\n"; } function getWeight() { return 1; } function getMinWidth() { return 0; } @@ -384,18 +450,19 @@ class HtmlHeadlineElement extends HtmlBlockElement { return ""; switch ($this->node->nodeName) { case 'h1': + $line = "\xE2\x95\x90"; # U+2505 + break; case 'h2': - $line = '='; + $line = "\xE2\x94\x81"; # U+2501 break; case 'h3': - case 'h4': - $line = '-'; + $line = "\xE2\x94\x80"; # U+2500 break; default: return $headline; } - $length = max(array_map('mb_strwidth', explode("\n", $headline))); - $headline .= "\n" . str_repeat($line, $length) . "\n"; + $length = max(array_map('mb_strwidth2', explode("\n", $headline))); + $headline .= str_repeat($line, $length) . "\n"; return $headline; } } @@ -430,7 +497,7 @@ class HtmlImgElement extends HtmlInlineElement { return "[image:$alt$title] "; } function getWeight() { - return mb_strwidth($this->node->getAttribute("alt")) + 8; + return mb_strwidth2($this->node->getAttribute("alt")) + 8; } } @@ -447,8 +514,8 @@ class HtmlAElement extends HtmlInlineElement { } elseif (strpos($href, 'mailto:') === 0) { $href = substr($href, 7); $output = (($href != $output) ? "$href " : '') . "<$output>"; - } elseif (mb_strwidth($href) > $width / 2) { - if (mb_strwidth($output) > $width / 2) { + } elseif (mb_strwidth2($href) > $width / 2) { + if (mb_strwidth2($output) > $width / 2) { // Parse URL and use relative path part if ($PU = parse_url($output)) $output = $PU['host'] . $PU['path']; @@ -498,10 +565,10 @@ class HtmlListItem extends HtmlBlockElement { function render($width, $options) { $prefix = sprintf($options['marker'], $this->number); - $lines = explode("\n", trim(parent::render($width-mb_strwidth($prefix), $options))); + $lines = explode("\n", trim(parent::render($width-mb_strwidth2($prefix), $options))); $lines[0] = $prefix . $lines[0]; return new PreFormattedText( - implode("\n".str_repeat(" ", mb_strwidth($prefix)), $lines)."\n"); + implode("\n".str_repeat(" ", mb_strwidth2($prefix)), $lines)."\n"); } } @@ -516,11 +583,23 @@ class HtmlCodeElement extends HtmlInlineElement { } class HtmlTable extends HtmlBlockElement { + var $body; + var $foot; + var $rows; + var $border = true; + var $padding = true; + function __construct($node, $parent) { $this->body = array(); $this->foot = array(); $this->rows = &$this->body; parent::__construct($node, $parent); + $A = $this->node->getAttribute('border'); + if (isset($A)) + $this->border = (bool) $A; + $A = $this->node->getAttribute('cellpadding'); + if (isset($A)) + $this->padding = (bool) $A; } function getMinWidth() { @@ -529,7 +608,7 @@ class HtmlTable extends HtmlBlockElement { foreach ($r as $cell) $this->min_width = max($this->min_width, $cell->getMinWidth()); } - return $this->min_width + 4; + return $this->min_width + ($this->border ? 2 : 0) + ($this->padding ? 2 : 0); } function getWeight() { @@ -628,6 +707,7 @@ class HtmlTable extends HtmlBlockElement { $i = 0; foreach ($r as $cell) { for ($j=0; $j<$cell->cols; $j++) { + // TODO: Use cell-specified width $weights[$i] = max($weights[$i], $cell->getWeight()); $mins[$i] = max($mins[$i], $cell->getMinWidth()); } @@ -636,7 +716,8 @@ class HtmlTable extends HtmlBlockElement { } # Subtract internal padding and borders from the available width - $inner_width = $width - $cols*3 - 1; + $inner_width = $width - ($this->border ? $cols + 1 : 0) + - ($this->padding ? $cols*2 : 0); # Optimal case, where the preferred width of all the columns is # doable @@ -655,7 +736,9 @@ class HtmlTable extends HtmlBlockElement { $widths[] = (int)($inner_width * $c / $total); $this->_fixupWidths($widths, $mins); } - $outer_width = array_sum($widths) + $cols*3 + 1; + $outer_width = array_sum($widths) + + ($this->border ? $cols + 1 : 0) + + ($this->padding ? $cols * 2 : 0); $contents = array(); $heights = array(); @@ -671,7 +754,8 @@ class HtmlTable extends HtmlBlockElement { # Compute the effective cell width for spanned columns # Add extra space for the unneeded border padding for # spanned columns - $cwidth = ($cell->cols - 1) * 3; + $cwidth = ($this->border ? ($cell->cols - 1) : 0) + + ($this->padding ? ($cell->cols - 1) * 2 : 0); for ($j = 0; $j < $cell->cols; $j++) $cwidth += $widths[$x+$j]; # Stash the computed width so it doesn't need to be @@ -679,7 +763,8 @@ class HtmlTable extends HtmlBlockElement { $cell->width = $cwidth; unset($data); $data = explode("\n", $cell->render($cwidth, $options)); - $heights[$y] = max(count($data), $heights[$y]); + // NOTE: block elements have trailing newline + $heights[$y] = max(count($data)-1, $heights[$y]); $contents[$y][$i] = &$data; $x += $cell->cols; } @@ -687,29 +772,34 @@ class HtmlTable extends HtmlBlockElement { # Build the header $header = ""; - for ($i = 0; $i < $cols; $i++) - $header .= "+-" . str_repeat("-", $widths[$i]) . "-"; - $header .= "+"; + if ($this->border) { + $padding = $this->padding ? '-' : ''; + for ($i = 0; $i < $cols; $i++) { + $header .= '+'.$padding.str_repeat("-", $widths[$i]).$padding; + } + $header .= "+\n"; + } # Emit the rows - $output = "\n"; if (isset($this->caption)) { $this->caption = $this->caption->render($outer_width, $options); } + $border = $this->border ? '|' : ''; + $padding = $this->padding ? ' ' : ''; foreach ($rows as $y=>$r) { - $output .= $header . "\n"; + $output .= $header; for ($x = 0, $k = 0; $k < $heights[$y]; $k++) { - $output .= "|"; + $output .= $border; foreach ($r as $x=>$cell) { $content = (isset($contents[$y][$x][$k])) ? $contents[$y][$x][$k] : ""; - $output .= " ".mb_str_pad($content, $cell->width)." |"; + $output .= $padding.mb_str_pad($content, $cell->width).$padding.$border; $x += $cell->cols; } $output .= "\n"; } } - $output .= $header . "\n"; + $output .= $header; return new PreFormattedText($output); } } @@ -722,10 +812,14 @@ class HtmlTableCell extends HtmlBlockElement { if (!$this->cols) $this->cols = 1; if (!$this->rows) $this->rows = 1; + + // Upgrade old attributes + if ($A = $this->node->getAttribute('align')) + $this->setStyle('text-align', $A); } function render($width, $options) { - return ltrim(parent::render($width, $options)); + return parent::render($width, $options); } function getWeight() { @@ -784,13 +878,48 @@ class HtmlStylesheet { class CssStyleRules { var $rules = array(); - function __construct($rules) { + static $compact_rules = array( + 'padding' => 1, + ); + + function __construct($rules='') { + if ($rules) + $this->add($rules); + } + + function add($rules) { foreach (explode(';', $rules) as $r) { if (strpos($r, ':') === false) continue; list($prop, $val) = explode(':', $r); - $this->rules[trim($prop)] = trim($val); + $prop = trim($prop); // TODO: Explode compact rules, like 'border', 'margin', etc. + if (isset(self::$compact_rules[$prop])) + $this->expand($prop, trim($val)); + else + $this->rules[$prop] = trim($val); + } + } + + function expand($prop, $val) { + switch (strtolower($prop)) { + case 'padding': + @list($a, $b, $c, $d) = preg_split('/\s+/', $val); + if (!isset($b)) { + $d = $c = $b = $a; + } + elseif (!isset($c)) { + $d = $b; + $c = $a; + } + elseif (!isset($d)) { + $d = $b; + } + $this->rules['padding-top'] = $a; + $this->styles['padding-right'] = $b; + $this->rules['padding-bottom'] = $c; + $this->rules['padding-left'] = $d; + } } @@ -816,18 +945,26 @@ class CssStyleRules { return $val; } - static function convert($value, $units) { + function set($prop, $value) { + $this->rules[$prop] = $value; + } + + static function convert($value, $units, $max=0) { if ($value === null) return $value; // Converts common CSS units to units of characters switch ($units) { + default: + if (substr($units, -1) == '%') { + return ((float) $value) * 0.01 * $max; + } case 'px': - return $value / 20.0; + // 600px =~ 60chars + return (int) ($value / 10.0); case 'pt': return $value / 12.0; case 'em': - default: return $value; } } @@ -853,6 +990,10 @@ if (!function_exists('mb_strwidth')) { return mb_strlen($string); } } +function mb_strwidth2($string) { + $junk = array(); + return mb_strwidth($string) - preg_match_all("/\p{M}/u", $string, $junk); +} // Thanks http://www.php.net/manual/en/function.wordwrap.php#107570 // @see http://www.tads.org/t3doc/doc/htmltads/linebrk.htm @@ -864,7 +1005,8 @@ function mb_wordwrap($string, $width=75, $break="\n", $cut=false) { if ($cut) { // Match anything 1 to $width chars long followed by whitespace or EOS, // otherwise match anything $width chars long - $search = '/(.{1,'.$width.'})(?:\s|$|(\p{Ps}))|(.{'.$width.'})/uS'; + $search = '/((?>[^\n\p{M}]\p{M}*){1,'.$width.'})(?:[ \n]|$|(\p{Ps}))|((?>[^\n\p{M}]\p{M}*){' + .$width.'})/uS'; # <?php $replace = '$1$3'.$break.'$2'; } else { // Anchor the beginning of the pattern with a lookahead @@ -878,8 +1020,9 @@ function mb_wordwrap($string, $width=75, $break="\n", $cut=false) { // Thanks http://www.php.net/manual/en/ref.mbstring.php#90611 function mb_str_pad($input, $pad_length, $pad_string=" ", $pad_style=STR_PAD_RIGHT) { + $marks = preg_match_all('/\p{M}/u', $input, $match); return str_pad($input, - strlen($input)-mb_strwidth($input)+$pad_length, $pad_string, + strlen($input)-mb_strwidth($input)+$marks+$pad_length, $pad_string, $pad_style); } diff --git a/include/i18n/en_US/form.yaml b/include/i18n/en_US/form.yaml index 7d1eb9aa2f5303d1c35b2c04c9a9b15102b1e9de..cea0b559ceba8378f1dfc571eebe780d750d5c2a 100644 --- a/include/i18n/en_US/form.yaml +++ b/include/i18n/en_US/form.yaml @@ -7,7 +7,8 @@ # title: Bold section title of the form # instructions: Title deck, detailed instructions on entering form data # notes: Notes for the form, shown under the fields -# deletable: True if the form can be removed from the system +# flags: +# 0x0001 If the form can be removed from the system # fields: List of fields for the form # type: Field type (short name) (eg. 'text', 'memo', 'phone', ...) # label: Field label shown to the user @@ -35,7 +36,7 @@ - id: 1 type: U # notrans title: Contact Information - deletable: false + flags: 0 fields: - type: text # notrans name: email # notrans @@ -75,7 +76,7 @@ This form will be attached to every ticket, regardless of its source. You can add any fields to this form and they will be available to all tickets, and will be searchable with advanced search and filterable. - deletable: false + flags: 0 fields: - id: 20 type: text # notrans @@ -102,7 +103,7 @@ - type: C # notrans title: Company Information instructions: Details available in email templates - deletable: false + flags: 0 fields: - type: text # notrans name: name # notrans @@ -140,7 +141,7 @@ - type: O # notrans title: Organization Information instructions: Details on user organization - deletable: false + flags: 0 fields: - type: text # notrans name: name # notrans @@ -186,7 +187,7 @@ instructions: Please Describe The Issue notes: | This form is used to create a task. - deletable: false + flags: 0 fields: - type: text # notrans name: title # notrans diff --git a/include/i18n/en_US/sla.yaml b/include/i18n/en_US/sla.yaml index 3b91325475edfed4025cfc9e95bdcd9e78fe12c4..4bbcfae98c8921eb014ae3d8e3d58e465efe2617 100644 --- a/include/i18n/en_US/sla.yaml +++ b/include/i18n/en_US/sla.yaml @@ -3,20 +3,21 @@ # # Fields: # id - (int:optional) id number in the database -# isactive - (bool:0|1) true of false if the SLA should initially be active -# enable_priority_escalation - (bool:0|1) true or false if the SLA should +# flags - (int:bitmask) +# isactive - (flag:1) true of false if the SLA should initially be active +# enable_priority_escalation - (flag:2) true or false if the SLA should # cause the ticket priority to be escalated when it is marked overdue -# disable_overdue_alerts - (bool:0|1) - true or false if the overdue alert +# disable_overdue_alerts - (flag:4) - true or false if the overdue alert # emails should _not_ go out for tickets assigned to this SLA +# transient - (flag:8) - true if the SLA should change when changing +# department or help topic. # grace_period - (int) number or hours after the ticket is opened before it # is marked overdue # name - (string) descriptive name of the SLA # notes - (string) administrative notes (viewable internally only) --- - id: 1 - isactive: 1 - enable_priority_escalation: 1 - disable_overdue_alert: 0 + flags: 3 grace_period: 48 name: Default SLA notes: | diff --git a/include/staff/dynamic-forms.inc.php b/include/staff/dynamic-forms.inc.php index 55f1fc04fb0404c63618a19da932994774ef4f1c..47754fd054b9749ca5bab4a5c553109d96ea7916 100644 --- a/include/staff/dynamic-forms.inc.php +++ b/include/staff/dynamic-forms.inc.php @@ -7,8 +7,12 @@ <div class="clear"></div> <?php +$other_forms = DynamicForm::objects() + ->filter(array('type'=>'G')) + ->exclude(array('flags__hasbit' => DynamicForm::FLAG_DELETED)); + $page = ($_GET['p'] && is_numeric($_GET['p'])) ? $_GET['p'] : 1; -$count = DynamicForm::objects()->filter(array('type__in'=>array('G')))->count(); +$count = $other_forms->count(); $pageNav = new Pagenate($count, $page, PAGE_LIMIT); $pageNav->setURL('forms.php'); $showing=$pageNav->showing().' '._N('form','forms',$count); @@ -56,8 +60,7 @@ $showing=$pageNav->showing().' '._N('form','forms',$count); </tr> </thead> <tbody> - <?php foreach (DynamicForm::objects()->filter(array('type'=>'G')) - ->order_by('title') +<?php foreach ($other_forms->order_by('title') ->limit($pageNav->getLimit()) ->offset($pageNav->getStart()) as $form) { $sel=false; diff --git a/include/staff/faq-category.inc.php b/include/staff/faq-category.inc.php index afc5194bb7c08ebfe4ea4caeed74072a5b434db4..b6287bcb9eb3779a9aab6aa5a49dc244683e1856 100644 --- a/include/staff/faq-category.inc.php +++ b/include/staff/faq-category.inc.php @@ -11,7 +11,7 @@ if(!defined('OSTSTAFFINC') || !$category || !$thisstaff) die('Access Denied'); <div> <strong><?php echo $category->getName() ?></strong> <span>(<?php echo $category->isPublic()?__('Public'):__('Internal'); ?>)</span> - <time> <?php echo __('Last updated').' '. Format::daydatetime($category->getUpdateDate()); ?></time> + <time class="faq"> <?php echo __('Last updated').' '. Format::daydatetime($category->getUpdateDate()); ?></time> </div> <div class="cat-desc"> <?php echo Format::display($category->getDescription()); ?> diff --git a/include/staff/settings-system.inc.php b/include/staff/settings-system.inc.php index e166413c5e288838032c536667b34c22ca378274..3e09d725c19bd2715eb0265b4d59759905fb63dc 100644 --- a/include/staff/settings-system.inc.php +++ b/include/staff/settings-system.inc.php @@ -72,8 +72,8 @@ $gmtime = Misc::gmtime(); <tr> <td><?php echo __('Enable Rich Text'); ?>:</td> <td> - <input type="checkbox" name="enable_html_thread" <?php - echo $config['enable_html_thread']?'checked="checked"':''; ?>> + <input type="checkbox" name="enable_richtext" <?php + echo $config['enable_richtext']?'checked="checked"':''; ?>> <?php echo __('Enable html in thread entries and email correspondence.'); ?> <i class="help-tip icon-question-sign" href="#enable_richtext"></i> </td> diff --git a/include/staff/templates/thread-entries.tmpl.php b/include/staff/templates/thread-entries.tmpl.php index 2e7da2c389cf2b276b139a5573e040fb0e6590a9..f15ea1e061997647ba4ec246a630008f412a0c8e 100644 --- a/include/staff/templates/thread-entries.tmpl.php +++ b/include/staff/templates/thread-entries.tmpl.php @@ -1,90 +1,47 @@ <?php -$entryTypes = array('M'=>'message', 'R'=>'response', 'N'=>'note'); -if ($entries) { - foreach ($entries as $entry) { ?> - <table class="thread-entry <?php echo $entryTypes[$entry->type]; ?>" cellspacing="0" cellpadding="1" width="940" border="0"> - <tr> - <th colspan="4" width="100%"> - <div> - <span class="pull-left"> - <span style="display:inline-block"><?php - echo Format::datetime($entry->created);?></span> - <span style="display:inline-block;padding:0 1em;max-width: 500px" class="faded title truncate"><?php - echo $entry->title; ?></span> - </span> - <div class="pull-right"> -<?php if ($entry->hasActions()) { - $actions = $entry->getActions(); ?> - <span class="action-button pull-right" data-dropdown="#entry-action-more-<?php echo $entry->getId(); ?>"> - <i class="icon-caret-down"></i> - <span ><i class="icon-cog"></i></span> - </span> - <div id="entry-action-more-<?php echo $entry->getId(); ?>" class="action-dropdown anchor-right"> - <ul class="title"> -<?php foreach ($actions as $group => $list) { - foreach ($list as $id => $action) { ?> - <li> - <a class="no-pjax" href="#" onclick="javascript: - <?php echo str_replace('"', '\\"', $action->getJsStub()); ?>; return false;"> - <i class="<?php echo $action->getIcon(); ?>"></i> <?php - echo $action->getName(); - ?></a></li> -<?php } - } ?> - </ul> - </div> -<?php } ?> - <span style="vertical-align:middle"> - <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'); - ?>"><?php echo __('Edited'); ?></span> - <?php } ?> - </span> - <span style="vertical-align:middle;" - class="tmeta faded title"><?php - echo Format::htmlchars($entry->getName()); ?></span> - </span> - </div> - </th> - </tr> - <tr><td colspan="4" class="thread-body" id="thread-id-<?php - echo $entry->getId(); ?>"><div><?php - echo $entry->getBody()->toHtml(); ?></div></td></tr> - <?php - $urls = null; - if ($entry->has_attachments - && ($urls = $entry->getAttachmentUrls())) { ?> - <tr> - <td class="info" colspan="4"><?php - foreach ($entry->attachments as $A) { - if ($A->inline) continue; - $size = ''; - if ($A->file->size) - $size = sprintf('<em>(%s)</em>', - Format::file_size($A->file->size)); -?> - <a class="Icon file no-pjax" href="<?php echo $A->file->getDownloadUrl(); - ?>" download="<?php echo Format::htmlchars($A->file->name); ?>" - target="_blank"><?php echo Format::htmlchars($A->file->name); - ?></a><?php echo $size;?> -<?php } ?> - </td> - </tr> <?php +$events = $events->order_by('id'); +$events = $events->getIterator(); +$events->rewind(); +$event = $events->current(); + +if (count($entries)) { + // Go through all the entries and bucket them by time frame + $buckets = array(); + $rel = 0; + foreach ($entries as $i=>$E) { + // First item _always_ shows up + if ($i != 0) + // Set relative time resolution to 12 hours + $rel = Format::relativeTime(Misc::db2gmtime($E->created, false, 43200)); + $buckets[$rel][] = $E; + } + + // Go back through the entries and render them on the page + $i = 0; + foreach ($buckets as $rel=>$entries) { + // TODO: Consider adding a date boundary to indicate significant + // changes in dates between thread items. + foreach ($entries as $entry) { + // Emit all events prior to this entry + while ($event && $event->timestamp <= $entry->created) { + $event->render(ThreadEvent::MODE_STAFF); + $events->next(); + $event = $events->current(); + } + include STAFFINC_DIR . 'templates/thread-entry.tmpl.php'; } - if ($urls) { ?> - <script type="text/javascript"> - $('#thread-id-<?php echo $entry->getId(); ?>') - .data('urls', <?php - echo JsonDataEncoder::encode($urls); ?>) - .data('id', <?php echo $entry->getId(); ?>); - </script> -<?php - } ?> - </table> - <?php + $i++; } -} else { +} + +// Emit all other events +while ($event) { + $event->render(ThreadEvent::MODE_STAFF); + $events->next(); + $event = $events->current(); +} + +// This should never happen +if (count($entries) + count($events) == 0) { echo '<p><em>'.__('No entries have been posted to this thread.').'</em></p>'; -}?> +} diff --git a/include/staff/templates/thread-entry.tmpl.php b/include/staff/templates/thread-entry.tmpl.php new file mode 100644 index 0000000000000000000000000000000000000000..01839aac5e37639df635dfc366cb289a159242a8 --- /dev/null +++ b/include/staff/templates/thread-entry.tmpl.php @@ -0,0 +1,91 @@ +<?php +$entryTypes = array('M'=>'message', 'R'=>'response', 'N'=>'note'); +$user = $entry->getUser() ?: $entry->getStaff(); +$name = $user ? $user->getName() : $entry->poster; +$avatar = ''; +if ($user && ($url = $user->get_gravatar(48))) + $avatar = "<img class=\"avatar\" src=\"{$url}\"> "; +?> + +<div class="thread-entry <?php echo $entryTypes[$entry->type]; ?> <?php if ($avatar) echo 'avatar'; ?>"> +<?php if ($avatar) { ?> + <span class="<?php echo ($entry->type == 'M') ? 'pull-right' : 'pull-left'; ?> avatar"> +<?php echo $avatar; ?> + </span> +<?php } ?> + <div class="header"> + <div class="pull-right"> +<?php if ($entry->hasActions()) { + $actions = $entry->getActions(); ?> + <span class="muted-button pull-right" data-dropdown="#entry-action-more-<?php echo $entry->getId(); ?>"> + <i class="icon-caret-down"></i> + </span> + <div id="entry-action-more-<?php echo $entry->getId(); ?>" class="action-dropdown anchor-right"> + <ul class="title"> +<?php foreach ($actions as $group => $list) { + foreach ($list as $id => $action) { ?> + <li> + <a class="no-pjax" href="#" onclick="javascript: + <?php echo str_replace('"', '\\"', $action->getJsStub()); ?>; return false;"> + <i class="<?php echo $action->getIcon(); ?>"></i> <?php + echo $action->getName(); + ?></a></li> +<?php } + } ?> + </ul> + </div> +<?php } ?> + <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'); + ?>"><?php echo __('Edited'); ?></span> + <?php } ?> + </span> + </div> +<?php + echo sprintf(__('<b>%s</b> posted %s'), $name, + sprintf('<time class="relative" datetime="%s" title="%s">%s</time>', + date(DateTime::W3C, Misc::db2gmtime($entry->created)), + Format::daydatetime($entry->created), + Format::relativeTime(Misc::db2gmtime($entry->created)) + ) + ); ?> + <span style="max-width:500px" class="faded title truncate"><?php + echo $entry->title; ?></span> + </span> + </div> + <div class="thread-body" id="thread-id-<?php echo $entry->getId(); ?>"> + <div><?php echo $entry->getBody()->toHtml(); ?></div> +<?php + if ($entry->has_attachments) { ?> + <div class="attachments"><?php + foreach ($entry->attachments as $A) { + if ($A->inline) + continue; + $size = ''; + if ($A->file->size) + $size = sprintf('<small class="filesize faded">%s</small>', Format::file_size($A->file->size)); +?> + <span class="attachment-info"> + <i class="icon-paperclip icon-flip-horizontal"></i> + <a class="no-pjax truncate filename" href="<?php echo $A->file->getDownloadUrl(); + ?>" download="<?php echo Format::htmlchars($A->getFilename()); ?>" + target="_blank"><?php echo Format::htmlchars($A->getFilename()); + ?></a><?php echo $size;?> + </span> +<?php } ?> + </div> +<?php } ?> + </div> +<?php + if ($urls = $entry->getAttachmentUrls()) { ?> + <script type="text/javascript"> + $('#thread-id-<?php echo $entry->getId(); ?>') + .data('urls', <?php + echo JsonDataEncoder::encode($urls); ?>) + .data('id', <?php echo $entry->getId(); ?>); + </script> +<?php + } ?> +</div> diff --git a/include/staff/templates/thread-event.tmpl.php b/include/staff/templates/thread-event.tmpl.php new file mode 100644 index 0000000000000000000000000000000000000000..f98a1e3200776ca8727d586ee1dc47ead485540a --- /dev/null +++ b/include/staff/templates/thread-event.tmpl.php @@ -0,0 +1,8 @@ +<div class="thread-event <?php if ($event->uid) echo 'action'; ?>"> + <span class="type-icon"> + <i class="faded icon-<?php echo $event->getIcon(); ?>"></i> + </span> + <span class="faded description"> + <?php echo $event->getDescription(ThreadEvent::MODE_STAFF); ?> + </span> +</div> diff --git a/include/staff/ticket-view.inc.php b/include/staff/ticket-view.inc.php index 8929929ff839934f99d417c8dd59b4459ca4f493..b981e2024b28cdeab8ec45cdd79162695228c181 100644 --- a/include/staff/ticket-view.inc.php +++ b/include/staff/ticket-view.inc.php @@ -425,7 +425,7 @@ foreach (DynamicFormEntry::forTicket($ticket->getId()) as $form) { <?php $tcount = $ticket->getThreadEntries($types)->count(); ?> -<ul class="tabs threads" id="ticket_tabs" > +<ul class="tabs clean threads" id="ticket_tabs" > <li class="active"><a href="#ticket_thread"><?php echo sprintf(__('Ticket Thread (%d)'), $tcount); ?></a></li> <li><a id="ticket_tasks" href="#tasks" data-url="<?php @@ -440,6 +440,7 @@ $tcount = $ticket->getThreadEntries($types)->count(); <?php $ticket->getThread()->render(array('M', 'R', 'N')); ?> + </div> <div class="clear" style="padding-bottom:10px;"></div> <?php if($errors['err']) { ?> <div id="msg_error"><?php echo $errors['err']; ?></div> diff --git a/include/upgrader/streams/core/9143a511-00000000.cleanup.sql b/include/upgrader/streams/core/9143a511-00000000.cleanup.sql new file mode 100644 index 0000000000000000000000000000000000000000..9597462e1f906207b921b12513a18924c0b71983 --- /dev/null +++ b/include/upgrader/streams/core/9143a511-00000000.cleanup.sql @@ -0,0 +1,72 @@ +/** + * @signature 959aca6ed189cd918d227a3ea8a135a3 + * @version v1.9.6 + * @title Retire `private`, `required`, and `edit_mask` for fields + * + */ + +ALTER TABLE `%TABLE_PREFIX%form_field` + DROP `private`, + DROP `required`, + DROP `edit_mask`; + +ALTER TABLE `%TABLE_PREFIX%content` + DROP `lang`; + +-- DROP IF EXISTS `%content.content_id` +SET @s = (SELECT IF( + (SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = '%TABLE_PREFIX%content' + AND table_schema = DATABASE() + AND column_name = 'content_id' + ) > 0, + "SELECT 1", + "ALTER TABLE `%TABLE_PREFIX%content` DROP `content_id`" +)); +PREPARE stmt FROM @s; +EXECUTE stmt; + +-- DROP IF EXISTS `%task.sla_id` +SET @s = (SELECT IF( + (SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = '%TABLE_PREFIX%task' + AND table_schema = DATABASE() + AND column_name = 'sla_id' + ) > 0, + "SELECT 1", + "ALTER TABLE `%TABLE_PREFIX%task` DROP `sla_id`" +)); +PREPARE stmt FROM @s; +EXECUTE stmt; + +-- Retire %team.[flag fields] +ALTER TABLE `%TABLE_PREFIX%team` + DROP `isenabled`, + DROP `noalerts`; + +-- Retire %dept.[flag fields] +DELETE FROM `%TABLE_PREFIX%config` +WHERE `key`='assign_members_only' AND `namespace` LIKE 'dept.%'; + +-- Retire %sla.[flag fields] +ALTER TABLE `%TABLE_PREFIX%sla` + DROP `isactive`, + DROP `enable_priority_escalation`, + DROP `disable_overdue_alerts`; + +DELETE FROM `%TABLE_PREFIX%config` +WHERE `key`='transient' AND `namespace` LIKE 'sla.%'; + +DELETE FROM `%TABLE_PREFIX%config` +WHERE `key`='configuration' AND `namespace` LIKE 'list.%'; + +DELETE FROM `%TABLE_PREFIX%config` +WHERE `key`='name_format' AND `namespace` = 'core'; + +-- Orphan users who don't know they're orphans +UPDATE `%TABLE_PREFIX%user` A1 + LEFT JOIN `%TABLE_PREFIX%organization` A2 ON (A1.`org_id` = A2.`id`) + SET A1.`org_id` = 0 + WHERE A2.`id` IS NULL; diff --git a/include/upgrader/streams/core/9143a511-00000000.patch.sql b/include/upgrader/streams/core/9143a511-00000000.patch.sql new file mode 100644 index 0000000000000000000000000000000000000000..dd28bbed98dfcff312264e17889231ab9f4547e7 --- /dev/null +++ b/include/upgrader/streams/core/9143a511-00000000.patch.sql @@ -0,0 +1,119 @@ +/** + * @signature 959aca6ed189cd918d227a3ea8a135a3 + * @version v1.10.0 + * @title Retire `private`, `required`, and `edit_mask` for fields + * + */ + +ALTER TABLE `%TABLE_PREFIX%ticket_event` + ADD `id` int(10) unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY FIRST, + CHANGE `ticket_id` `thread_id` int(11) unsigned NOT NULL default '0', + CHANGE `staff` `username` varchar(128) NOT NULL default 'SYSTEM', + CHANGE `state` `state` enum('created','closed','reopened','assigned','transferred','overdue','edited','viewed','error','collab','resent') NOT NULL, + ADD `data` varchar(1024) DEFAULT NULL COMMENT 'Encoded differences' AFTER `state`, + ADD `uid` int(11) unsigned DEFAULT NULL AFTER `username`, + ADD `uid_type` char(1) NOT NULL DEFAULT 'S' AFTER `uid`, + RENAME TO `%TABLE_PREFIX%thread_event`; + +-- Change the `ticket_id` column to the values in `%thread`.`id` +CREATE TABLE `%TABLE_PREFIX%_ticket_thread_evt` + (PRIMARY KEY (`object_id`)) + SELECT `object_id`, `id` FROM `%TABLE_PREFIX%thread` + WHERE `object_type` = 'T'; + +UPDATE `%TABLE_PREFIX%thread_event` A1 + 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`; + +-- Attempt to connect the `username` to the staff_id +UPDATE `%TABLE_PREFIX%thread_event` A1 + LEFT JOIN `%TABLE_PREFIX%staff` A2 ON (A2.`username` = A1.`username`) + SET A1.`uid` = A2.`staff_id` + WHERE A1.`username` != 'SYSTEM'; + +ALTER TABLE `%TABLE_PREFIX%user_email` + ADD `flags` int(10) unsigned NOT NULL DEFAULT 0 AFTER `user_id`; + +ALTER TABLE `%TABLE_PREFIX%thread_entry` + ADD `editor` int(10) unsigned NULL AFTER `poster`, + ADD `editor_type` char(1) NULL AFTER `editor`; + +ALTER TABLE `%TABLE_PREFIX%form` + CHANGE `deletable` `flags` int(10) unsigned NOT NULL DEFAULT 1; + +-- Previous versions did not correctly mark the internal forms as NOT deletable +UPDATE `%TABLE_PREFIX%form` + SET `flags` = 0 WHERE `type` IN ('T','U','C','O','A'); + +ALTER TABLE `%TABLE_PREFIX%team` + ADD `flags` int(10) unsigned NOTN ULL default 1 AFTER `lead_id`; + +UPDATE `%TABLE_PREFIX%team` + SET `flags` = CASE WHEN `isenabled` THEN 1 ELSE 0 END + + CASE WHEN `noalerts` THEN 2 ELSE 0 END; + +-- Migrate %config[namespace=dept.x, key=alert_members_only] +ALTER TABLE `%TABLE_PREFIX%department` + ADD `flags` int(10) unsigned NOT NULL default 0 AFTER `manager_id`; + +UPDATE `%TABLE_PREFIX%department` A1 + JOIN (SELECT `value` FROM `%TABLE_PREFIX%config`) `config` + ON (`config`.`namespace` = CONCAT('dept.', A1.`id`) AND `config`.`key` = 'assign_members_only') + SET A1.`flags` = 1 WHERE `config`.`value` != ''; + +-- Migrate %config[namespace=sla.x, key=transient] +ALTER TABLE `%TABLE_PREFIX%sla` + ADD `flags` int(10) unsigned NOT NULL default 3 AFTER `id`; + +UPDATE `%TABLE_PREFIX%sla` A1 + SET A1.`flags` = + (CASE WHEN A1.`isactive` THEN 1 ELSE 0 END) + | (CASE WHEN A1.`enable_priority_escalation` THEN 2 ELSE 0 END) + | (CASE WHEN A1.`disable_overdue_alerts` THEN 4 ELSE 0 END) + | (CASE WHEN (SELECT `value` FROM `%TABLE_PREFIX%config` `config` + WHERE`config`.`namespace` = CONCAT('sla.', A1.`id`) AND `config`.`key` = 'transient') + = '1' THEN 8 ELSE 0 END); + +ALTER TABLE `%TABLE_PREFIX%ticket` + ADD `source_extra` varchar(40) NULL default NULL AFTER `source`; + +-- Retire %config[namespace=list.x, key=configuration] +ALTER TABLE `%TABLE_PREFIX%list` + ADD `configuration` text NOT NULL DEFAULT '' AFTER `type`; + +UPDATE `%TABLE_PREFIX%list` A1 + JOIN (SELECT `value` FROM `%TABLE_PREFIX%config`) `config` + ON (`config`.`namespace` = CONCAT('list.', A1.`id`) AND `config`.`key` = 'configuration') + SET A1.`configuration` = `config`.`value`; + +-- Rebuild %ticket__cdata as UTF8 +DROP TABLE IF EXISTS `%TABLE_PREFIX%ticket__cdata`; + +-- Move `enable_html_thread` to `enable_richtext` +UPDATE `%TABLE_PREFIX%config` + SET `key` = 'enable_richtext' + WHERE `namespace` = 'core' AND `key` = 'enable_html_thread'; + +SET @name_format = (SELECT `value` FROM `%TABLE_PREFIX%config` A1 + WHERE A1.`namespace` = 'core' AND A1.`key` = 'name_format'); +INSERT INTO `%TABLE_PREFIX%config` + (`namespace`, `key`, `value`) VALUES + ('core', 'agent_name_format', @name_format), + ('core', 'client_name_format', @name_format); + +-- Drop search table and turn on reindexing +DROP TABLE IF EXISTS `%TABLE_PREFIX%_search`; + +UPDATE `%TABLE_PREFIX%config` SET `value` = '1' + WHERE `key` = 'reindex' and `namespace` = 'mysqlsearch'; + +-- Support varying names for duplicated content +ALTER TABLE `%TABLE_PREFIX%attachment` + ADD `name` varchar(255) NULL default NULL AFTER `file_id`; + +-- Finished with patch +UPDATE `%TABLE_PREFIX%config` + SET `value` = '00000000000000000000000000000000' + WHERE `key` = 'schema_signature' AND `namespace` = 'core'; diff --git a/include/upgrader/streams/core/f5692e24-4323a6a8.patch.sql b/include/upgrader/streams/core/f5692e24-4323a6a8.patch.sql index 39d7cb4698d1aa81e7f3fc2e48b0a7943fe86931..67a21aa7759080efc95cc08428285ce8490462c9 100644 --- a/include/upgrader/streams/core/f5692e24-4323a6a8.patch.sql +++ b/include/upgrader/streams/core/f5692e24-4323a6a8.patch.sql @@ -69,11 +69,7 @@ ALTER TABLE `%TABLE_PREFIX%help_topic` -- Add `content_id` to the content table to allow for translations RENAME TABLE `%TABLE_PREFIX%page` TO `%TABLE_PREFIX%content`; ALTER TABLE `%TABLE_PREFIX%content` - CHANGE `type` `type` varchar(32) NOT NULL default 'other', - ADD `content_id` int(10) unsigned NOT NULL default 0 AFTER `id`; - -UPDATE `%TABLE_PREFIX%content` - SET `content_id` = `id`; + CHANGE `type` `type` varchar(32) NOT NULL default 'other'; DROP TABLE IF EXISTS `%TABLE_PREFIX%user_account`; CREATE TABLE `%TABLE_PREFIX%user_account` ( diff --git a/js/filedrop.field.js b/js/filedrop.field.js index 4e82280aeeb894a8249c8723fefd22570635d1c8..8b863fc9f6f897b3c85b72f61d0422dc31394c7b 100644 --- a/js/filedrop.field.js +++ b/js/filedrop.field.js @@ -109,7 +109,7 @@ // Upload failed. TODO: Add a button to the UI to retry on // HTTP 500 return e.remove(); - e.find('[name="'+that.options.name+'"]').val(json.id); + e.find('[name="'+that.options.name+'"]').val(''+json.id+','+file.name); e.data('fileId', json.id); e.find('.progress-bar') .width('100%') diff --git a/scp/css/scp.css b/scp/css/scp.css index 145e4bc0b74750dfcfc0aba8a954365ccde34b73..fc168fe44e15e5df124f6fa15f2e9a1b88103ece 100644 --- a/scp/css/scp.css +++ b/scp/css/scp.css @@ -55,10 +55,21 @@ div#header a { color: #666; color: rgba(0,0,0,0.5); } +.faded b { + color: #333; + color: rgba(0,0,0,0.75); +} +.faded strong { + color: #444; + color: rgba(0,0,0,0.6); +} .faded-more { color: #aaa; color: rgba(0,0,0,0.35); } +time[title]:hover { + text-decoration: underline; +} .small[class^="icon-"], .small[class*=" icon-"] { @@ -452,7 +463,7 @@ a.Icon:hover { background:#fff; } -a:not(.re-icon) { +a { color:#184E81; } @@ -821,9 +832,6 @@ h2 .reload { border:1px solid #f90; } - - - #ticket_actions { padding:5px; background:#eee; @@ -831,66 +839,151 @@ h2 .reload { border-bottom:none; margin:0; } - -#toggle_ticket_thread { - background:url(../images/icons/open.gif) 10px 50% no-repeat; +.thread-entry { + margin-bottom: 15px; + z-index: 0; } - -#toggle_notes { - background:url(../images/icons/note.gif) 10px 50% no-repeat; +.thread-entry::after { + content: ""; + border-bottom: 2px solid white; + display: block; } - -table.thread-entry { - margin-top:10px; - border:1px solid #aaa; - border-bottom:2px solid #aaa; +.thread-entry::before { + content: ""; + display: block; + border-top: 2px solid white; } - -#ticket_notes table { - margin-top:10px; - border:1px solid #ddd; - border-bottom:2px solid #ddd; +.thread-entry.avatar { + margin-left: 60px; } - -table.thread-entry th, #ticket_notes table th { - text-align:left; - border-bottom:1px solid #aaa; - font-size:10pt; - padding:5px; +.thread-entry.message.avatar { + margin-right: 60px; + margin-left: 0; } - -#ticket_notes table th { - text-align:left; - border-bottom:1px solid #ddd; - font-size:10pt; - padding:5px; - background:#F4FAFF; +.thread-entry > .avatar { + margin-left: -60px; + display:inline-block; + width:48px; + height:auto; + border-radius: 5px; } - -#ticket_notes table th em { - font-weight:normal; - font-size:10pt; - color:#666; +.thread-entry.message > .avatar { + margin-left: initial; + margin-right: -60px; } - -#ticket_notes .date { - font-weight:normal; - font-size:10pt; - color:#888; - text-align:right; +img.avatar { + border-radius: inherit; +} +.thread-entry .header { + padding: 8px 0.9em; + border: 1px solid #ccc; + border-color: rgba(0,0,0,0.2); + border-radius: 5px 5px 0 0; +} +.thread-entry.avatar .header:before { + position: absolute; + top: 7px; + right: -8px; + content: ''; + border-top: 8px solid transparent; + border-bottom: 8px solid transparent; + border-left: 8px solid #9cadcc; + display: inline-block; +} +.thread-entry.avatar .header:after { + position: absolute; + top: 7px; + right: -8px; + content: ''; + border-top: 7px solid transparent; + border-bottom: 7px solid transparent; + display: inline-block; + margin-top: 1px; +} + +.thread-entry.avatar .header { + position: relative; } -.thread-entry.message th { +.thread-entry.message .header { background:#C3D9FF; } +.thread-entry.avatar.message .header:after { + border-left: 7px solid #C3D9FF; + margin-right: 1px; +} -.thread-entry.response th { +.thread-entry.response .header { background:#FFE0B3; } +.thread-entry.avatar.response .header:before, +.thread-entry.avatar.note .header:before { + top: 7px; + left: -8px; + right: initial; + border-left: none; + border-right: 8px solid #CCC; +} +.thread-entry.note:not(.avatar) .header { + background-color: #f4f4f4; +} +.thread-entry.avatar.response .header:before { + border-right-color: #ccb3af; +} +.thread-entry.avatar.note .header:before { + border-right-color: #ccccb0; +} +.thread-entry.avatar.response .header:after, +.thread-entry.avatar.note .header:after { + top: 7px; + left: -8px; + right: initial; + border-left: none; + border-right: 7px solid #FFE0B3; + margin-left: 1px; +} -.thread-entry.note th { +.thread-entry.note .header { background:#FFE; } +.thread-entry.avatar.note .header:after { + border-right-color: #FFE; +} +.thread-entry .header .title { + max-width: 500px; + vertical-align: bottom; + display: inline-block; + margin-left: 15px; +} + +.thread-entry .thread-body { + border: 1px solid #ddd; + border-top: none; + border-bottom:2px solid #aaa; + border-radius: 0 0 5px 5px; +} +.thread-body .attachments { + background-color: #f4faff; + margin: 0 -0.9em; + position: relative; + top: 0.9em; + padding: 0.3em 0.9em; + border-top: 1px dotted #ccc; + border-top-color: rgba(0,0,0,0.2); + border-radius: 0 0 6px 6px; +} +.thread-body .attachments .filesize { + margin-left: 0.5em; +} +.thread-body .attachment-info { + margin-right: 10px; + display: inline-block; + width: 48%; +} +.thread-body .attachment-info .filename { + max-width: 80%; + max-width: calc(100% - 70px); +} #ticket_notes table td { padding:5px; @@ -912,7 +1005,7 @@ table.thread-entry th, #ticket_notes table th { } #response_options { - margin-top:30px; + margin-top:10px; } #response_options > form { @@ -1403,7 +1496,7 @@ h2 > i.help-tip { background-color:#e9f5ff; } -time { +time.faq { display:inline-block; float:right; color:#777; @@ -1825,6 +1918,23 @@ div.selected-signature .inner { top: 4px; right: 5px; } +.muted-button:hover { + border: 1px solid #aaa; + border: 1px solid rgba(0,0,0,0.3); + cursor: pointer; + background: rgba(255,255,255,0.1); + color: black; +} +.muted-button { + border-radius: 5px; + padding: 1px 5px; + margin: -1px 0 -1px 5px; + border: 1px solid rgba(0,0,0,0.15); + color: #666; + color: rgba(0,0,0,0.5); + background-color: rgba(0,0,0,0.1); + background: linear-gradient(0, rgba(0,0,0,0.1), rgba(255,255,255,0.1)); +} .sortable-rows tr td:hover { cursor: move; @@ -2006,6 +2116,7 @@ tr.disabled th { } .tab_content:not(.left) { padding: 12px 0; + margin: 5px 0; } .left-tabs { margin-left: 48px; @@ -2129,6 +2240,7 @@ button a:hover { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + vertical-align: bottom; } td.indented { padding-left: 20px; @@ -2257,3 +2369,68 @@ td.indented { border-color:transparent; } } + +#ticket_thread::before { + border-left: 2px dotted #ddd; + border-bottom-color: rgba(0,0,0,0.1); + position: absolute; + margin-left: 74px; + z-index: -1; + content: ""; + top: 0; + bottom: 0; + right: 0; + left: 0; +} +#ticket_thread { + z-index: 0; + position: relative; + border-bottom: 2px solid #ddd; + border-bottom-color: rgba(0,0,0,0.1); +} +.thread-event { + padding: 0 2px 15px; + margin-left: 60px; +} +.type-icon { + border-radius: 8px; + background-color: #f4f4f4; + padding: 4px 6px; + margin-right: 5px; + text-align: center; + display: inline-block; + font-size: 1.1em; + border: 1px solid #eee; + vertical-align: top; + position: relative; +} +.thread-event .type-icon::after { + content: ""; + border: 16px solid white; + position: absolute; + top: -3px; + bottom: 0; + left: -3px; + right: 0; + z-index: -1; +} +.type-icon.dark { + border-color: #666; + background-color: #949494; +} +.thread-event img.avatar { + vertical-align: middle; + border-radius: 3px; + width: auto; + max-height: 24px; + margin: -3px 3px 0; +} +.thread-event .description { + margin-left: -30px; + padding-top: 6px; + padding-left: 30px; + display: inline-block; + width: 772px; + width: calc(100% - 95px); + line-height: 1.4em; +} diff --git a/scp/js/scp.js b/scp/js/scp.js index 0c2e7ee7ab3116bdea0ad407c4c81a7a4037f3df..b76f236ee41ae6fe5cb49b7a8881f1303e191725 100644 --- a/scp/js/scp.js +++ b/scp/js/scp.js @@ -600,6 +600,15 @@ $(document).on('focus', 'form.spellcheck textarea, form.spellcheck input[type=te $(this).attr({'spellcheck':'true', 'lang': lang}); }); +$(document).on('click', '.thread-entry-group a', function() { + var inner = $(this).parent().find('.thread-entry-group-inner'); + if (inner.is(':visible')) + inner.slideUp(); + else + inner.slideDown(); + return false; +}); + $.toggleOverlay = function (show) { if (typeof(show) === 'undefined') { return $.toggleOverlay(!$('#overlay').is(':visible')); diff --git a/scp/tickets.php b/scp/tickets.php index 7de153323f229917c242a70039ce9cd2a16bddab..9e244bcc61115fb39e9817c0f6283ecea41bf438 100644 --- a/scp/tickets.php +++ b/scp/tickets.php @@ -186,14 +186,6 @@ if($_POST && !$errors): $errors['assignId']=__('Ticket already assigned to the team.'); } - //Comments are not required on self-assignment (claim) - if($claim && !$_POST['assign_comments']) - $_POST['assign_comments'] = sprintf(__('Ticket claimed by %s'),$thisstaff->getName()); - elseif(!$_POST['assign_comments']) - $errors['assign_comments'] = __('Assignment comments required'); - elseif(strlen($_POST['assign_comments'])<5) - $errors['assign_comments'] = __('Comment too short'); - if(!$errors && $ticket->assign($_POST['assignId'], $_POST['assign_comments'], !$claim)) { if($claim) { $msg = __('Ticket is NOW assigned to you!'); diff --git a/setup/inc/streams/core/install-mysql.sql b/setup/inc/streams/core/install-mysql.sql index 26f129681118b2c67eea32dc5f4b74f78d30a656..963adce7f9fa57eed7c8bd5c81f1ba33cba1a68f 100644 --- a/setup/inc/streams/core/install-mysql.sql +++ b/setup/inc/streams/core/install-mysql.sql @@ -21,6 +21,7 @@ CREATE TABLE `%TABLE_PREFIX%attachment` ( `object_id` int(11) unsigned NOT NULL, `type` char(1) NOT NULL, `file_id` int(11) unsigned NOT NULL, + `name` varchar(255) NULL default NULL, `inline` tinyint(1) unsigned NOT NULL DEFAULT '0', `lang` varchar(16), PRIMARY KEY (`id`), @@ -83,9 +84,7 @@ CREATE TABLE `%TABLE_PREFIX%sequence` ( DROP TABLE IF EXISTS `%TABLE_PREFIX%sla`; CREATE TABLE `%TABLE_PREFIX%sla` ( `id` int(11) unsigned NOT NULL auto_increment, - `isactive` tinyint(1) unsigned NOT NULL default '1', - `enable_priority_escalation` tinyint(1) unsigned NOT NULL default '1', - `disable_overdue_alerts` tinyint(1) unsigned NOT NULL default '0', + `flags` int(10) unsigned NOT NULL default 3, `grace_period` int(10) unsigned NOT NULL default '0', `name` varchar(64) NOT NULL default '', `notes` text, @@ -117,7 +116,7 @@ CREATE TABLE `%TABLE_PREFIX%form` ( `id` int(11) unsigned NOT NULL auto_increment, `pid` int(10) unsigned DEFAULT NULL, `type` varchar(8) NOT NULL DEFAULT 'G', - `deletable` tinyint(1) NOT NULL DEFAULT 1, + `flags` int(10) unsigned NOT NULL DEFAULT 1, `title` varchar(255) NOT NULL, `instructions` varchar(512), `name` varchar(64) NOT NULL DEFAULT '', @@ -134,9 +133,6 @@ CREATE TABLE `%TABLE_PREFIX%form_field` ( `flags` int(10) unsigned DEFAULT 1, `type` varchar(255) NOT NULL DEFAULT 'text', `label` varchar(255) NOT NULL, - `required` tinyint(1) NOT NULL DEFAULT 0, - `private` tinyint(1) NOT NULL DEFAULT 0, - `edit_mask` tinyint(1) NOT NULL DEFAULT 0, `name` varchar(64) NOT NULL, `configuration` text, `sort` int(11) unsigned NOT NULL, @@ -178,6 +174,7 @@ CREATE TABLE `%TABLE_PREFIX%list` ( `sort_mode` enum('Alpha', '-Alpha', 'SortCol') NOT NULL DEFAULT 'Alpha', `masks` int(11) unsigned NOT NULL DEFAULT 0, `type` VARCHAR( 16 ) NULL DEFAULT NULL, + `configuration` text NOT NULL DEFAULT '', `notes` text, `created` datetime NOT NULL, `updated` datetime NOT NULL, @@ -209,6 +206,7 @@ CREATE TABLE `%TABLE_PREFIX%department` ( `email_id` int(10) unsigned NOT NULL default '0', `autoresp_email_id` int(10) unsigned NOT NULL default '0', `manager_id` int(10) unsigned NOT NULL default '0', + `flags` int(10) unsigned NOT NULL default 0, `name` varchar(128) NOT NULL default '', `signature` text NOT NULL, `ispublic` tinyint(1) unsigned NOT NULL default '1', @@ -593,8 +591,7 @@ DROP TABLE IF EXISTS `%TABLE_PREFIX%team`; CREATE TABLE `%TABLE_PREFIX%team` ( `team_id` int(10) unsigned NOT NULL auto_increment, `lead_id` int(10) unsigned NOT NULL default '0', - `isenabled` tinyint(1) unsigned NOT NULL default '1', - `noalerts` tinyint(1) unsigned NOT NULL default '0', + `flags` int(10) unsigned NOTN ULL default 1, `name` varchar(125) NOT NULL default '', `notes` text, `created` datetime NOT NULL, @@ -635,6 +632,8 @@ CREATE TABLE `%TABLE_PREFIX%thread_entry` ( `type` char(1) NOT NULL default '', `flags` int(11) unsigned NOT NULL default '0', `poster` varchar(128) NOT NULL default '', + `editor` int(10) unsigned NULL, + `editor_type` char(1) NULL, `source` varchar(32) NOT NULL default '', `title` varchar(255), `body` text NOT NULL, @@ -677,6 +676,7 @@ CREATE TABLE `%TABLE_PREFIX%ticket` ( `flags` int(10) unsigned NOT NULL default '0', `ip_address` varchar(64) NOT NULL default '', `source` enum('Web','Email','Phone','API','Other') NOT NULL default 'Other', + `source_extra` varchar(40) NULL default NULL, `isoverdue` tinyint(1) unsigned NOT NULL default '0', `isanswered` tinyint(1) unsigned NOT NULL default '0', `duedate` datetime default NULL, @@ -712,18 +712,22 @@ CREATE TABLE `%TABLE_PREFIX%lock` ( KEY `staff_id` (`staff_id`) ) DEFAULT CHARSET=utf8; -DROP TABLE IF EXISTS `%TABLE_PREFIX%ticket_event`; -CREATE TABLE `%TABLE_PREFIX%ticket_event` ( - `ticket_id` int(11) unsigned NOT NULL default '0', +DROP TABLE IF EXISTS `%TABLE_PREFIX%thread_event`; +CREATE TABLE `%TABLE_PREFIX%thread_event` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `thread_id` int(11) unsigned NOT NULL default '0', `staff_id` int(11) unsigned NOT NULL, `team_id` int(11) unsigned NOT NULL, `dept_id` int(11) unsigned NOT NULL, `topic_id` int(11) unsigned NOT NULL, - `state` enum('created','closed','reopened','assigned','transferred','overdue') NOT NULL, - `staff` varchar(255) NOT NULL default 'SYSTEM', + `state` enum('created','closed','reopened','assigned','transferred','overdue','edited','viewed','error','collab','resent') NOT NULL, + `data` varchar(1024) DEFAULT NULL COMMENT 'Encoded differences', + `username` varchar(128) NOT NULL default 'SYSTEM', + `uid` int(11) unsigned DEFAULT NULL, + `uid_type` char(1) NOT NULL DEFAULT 'S', `annulled` tinyint(1) unsigned NOT NULL default '0', `timestamp` datetime NOT NULL, - KEY `ticket_state` (`ticket_id`, `state`, `timestamp`), + KEY `ticket_state` (`thread_id`, `state`, `timestamp`), KEY `ticket_stats` (`timestamp`, `state`) ) DEFAULT CHARSET=utf8; @@ -778,7 +782,6 @@ CREATE TABLE `%TABLE_PREFIX%task` ( `object_type` char(1) NOT NULL, `number` varchar(20) DEFAULT NULL, `dept_id` int(10) unsigned NOT NULL DEFAULT '0', - `sla_id` int(10) unsigned NOT NULL DEFAULT '0', `staff_id` int(10) unsigned NOT NULL DEFAULT '0', `team_id` int(10) unsigned NOT NULL DEFAULT '0', `lock_id` int(11) unsigned NOT NULL DEFAULT '0', @@ -798,12 +801,10 @@ CREATE TABLE `%TABLE_PREFIX%task` ( -- pages CREATE TABLE IF NOT EXISTS `%TABLE_PREFIX%content` ( `id` int(10) unsigned NOT NULL auto_increment, - `content_id` int(10) unsigned NOT NULL default '0', `isactive` tinyint(1) unsigned NOT NULL default '0', `type` varchar(32) NOT NULL default 'other', `name` varchar(255) NOT NULL, `body` text NOT NULL, - `lang` varchar(16) NOT NULL default 'en_US', `notes` text, `created` datetime NOT NULL, `updated` datetime NOT NULL, @@ -872,6 +873,7 @@ DROP TABLE IF EXISTS `%TABLE_PREFIX%user_email`; CREATE TABLE `%TABLE_PREFIX%user_email` ( `id` int(10) unsigned NOT NULL auto_increment, `user_id` int(10) unsigned NOT NULL, + `flags` int(10) unsigned NOT NULL DEFAULT 0, `address` varchar(128) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `address` (`address`), diff --git a/tickets.php b/tickets.php index 0c6dd8947c1925f025552e20971b8f8cc0079c82..875fdfec40dc2d4770e4891c8a5cd76832c3a5d7 100644 --- a/tickets.php +++ b/tickets.php @@ -49,6 +49,7 @@ if ($_POST && is_object($ticket) && $ticket->getId()) { $errors['err']=__('Access Denied. Possibly invalid ticket ID'); else { $forms=DynamicFormEntry::forTicket($ticket->getId()); + $changes = array(); foreach ($forms as $form) { $form->setSource($_POST); if (!$form->isValid()) @@ -56,11 +57,13 @@ if ($_POST && is_object($ticket) && $ticket->getId()) { } } if (!$errors) { - foreach ($forms as $f) $f->save(); + foreach ($forms as $f) { + $changes += $f->getChanges(); + $f->save(); + } + if ($changes) + $ticket->logEvent('edited', array('fields' => $changes)); $_REQUEST['a'] = null; //Clear edit action - going back to view. - $ticket->logNote(__('Ticket details updated'), sprintf( - __('Ticket details were updated by client %s <%s>'), - $thisclient->getName(), $thisclient->getEmail())); } break; case 'reply':