use Combodo\iTop\Application\UI\Base\Component\CollapsibleSection\CollapsibleSectionUIBlockFactory; use Combodo\iTop\Application\UI\Base\Component\Html\Html; use Combodo\iTop\Application\UI\Base\iUIBlock; use Combodo\iTop\Application\UI\Base\Layout\UIContentBlockUIBlockFactory; use Combodo\iTop\Application\WebPage\WebPage; use Combodo\iTop\Renderer\BlockRenderer; define('CASELOG_VISIBLE_ITEMS', 2); define('CASELOG_SEPARATOR', "\n".'========== %1$s : %2$s (%3$d) ============'."\n\n"); /** * Class to store a "case log" in a structured way, keeping track of its successive entries * * @copyright Copyright (C) 2010-2024 Combodo SAS * @license http://opensource.org/licenses/AGPL-3.0 */ class ormCaseLog { /** * @var string "plain text" format for the log * @since 3.0.0 */ public const ENUM_FORMAT_TEXT = 'text'; /** * @var string "HTML" format for the log * @since 3.0.0 */ public const ENUM_FORMAT_HTML = 'html'; protected $m_sLog; protected $m_aIndex; protected $m_bModified; /** * Initializes the log with the first (initial) entry * @param $sLog string The text of the whole case log * @param $aIndex array The case log index */ public function __construct($sLog = '', $aIndex = array()) { $this->m_sLog = $sLog; $this->m_aIndex = $aIndex; $this->m_bModified = false; } public function GetText($bConvertToPlainText = false) { if ($bConvertToPlainText) { // Rebuild the log, but filtering any HTML markup for the all {@see static::ENUM_FORMAT_HTML} entries in the log return $this->GetAsPlainText(); } else { return $this->m_sLog; } } public static function FromJSON($oJson) { if (!isset($oJson->items)) { throw new Exception("Missing 'items' elements"); } $oCaseLog = new ormCaseLog(); foreach($oJson->items as $oItem) { $oCaseLog->AddLogEntryFromJSON($oItem); } return $oCaseLog; } /** * Return a value that will be further JSON encoded */ public function GetForJSON() { // Order by ascending date $aRet = array('entries' => array_reverse($this->GetAsArray())); return $aRet; } /** * Return all the data, in a format that is suitable for programmatic usages: * -> dates not formatted * -> to preserve backward compatibility, to the returned structure must grow (new array entries) * * Format: * array ( * array ( * 'date' => , * 'user_login' => * 'user_id' => OPTIONAL * 'message' => * 'message_html' => * ) * * @return array * @throws DictExceptionMissingString */ public function GetAsArray() { $aEntries = array(); $iPos = 0; for($index=count($this->m_aIndex)-1 ; $index >= 0 ; $index--) { $iPos += $this->m_aIndex[$index]['separator_length']; $sTextEntry = substr($this->m_sLog, $iPos, $this->m_aIndex[$index]['text_length']); $iPos += $this->m_aIndex[$index]['text_length']; // Workaround: PHP < 5.3 cannot unserialize correctly DateTime objects, // therefore we have changed the format. To preserve the compatibility with existing // installations of iTop, both format are allowed: // the 'date' item is either a DateTime object, or a unix timestamp if (is_int($this->m_aIndex[$index]['date'])) { // Unix timestamp $sDate = date(AttributeDateTime::GetInternalFormat(),$this->m_aIndex[$index]['date']); } elseif (is_object($this->m_aIndex[$index]['date'])) { if (version_compare(phpversion(), '5.3.0', '>=')) { // DateTime $sDate = $this->m_aIndex[$index]['date']->format(AttributeDateTime::GetInternalFormat()); } else { // No Warning... but the date is unknown $sDate = ''; } } $sFormat = array_key_exists('format', $this->m_aIndex[$index]) ? $this->m_aIndex[$index]['format'] : static::ENUM_FORMAT_TEXT; switch($sFormat) { case static::ENUM_FORMAT_TEXT: $sHtmlEntry = utils::TextToHtml($sTextEntry); break; case static::ENUM_FORMAT_HTML: $sHtmlEntry = InlineImage::FixUrls($sTextEntry); $sTextEntry = utils::HtmlToText($sHtmlEntry); break; } $aEntries[] = array( 'date' => $sDate, 'user_login' => $this->m_aIndex[$index]['user_name'], 'user_id' => $this->m_aIndex[$index]['user_id'], 'message' => $sTextEntry, 'message_html' => $sHtmlEntry, ); } // Process the case of an eventual remainder (quick migration of AttributeText fields) if ($iPos < (utils::StrLen($this->m_sLog) - 1)) { $sTextEntry = substr($this->m_sLog, $iPos); $aEntries[] = array( 'date' => '', 'user_login' => '', 'user_id' => 0, 'message' => $sTextEntry, 'message_html' => utils::TextToHtml($sTextEntry), ); } return $aEntries; } /** * Returns a "plain text" version of the log (equivalent to $this->m_sLog) where all the HTML markup from the {@see static::ENUM_FORMAT_HTML} entries have been removed * * @return string */ public function GetAsPlainText() { $sPlainText = ''; $aJSON = $this->GetForJSON(); foreach($aJSON['entries'] as $aData) { $sSeparator = sprintf(CASELOG_SEPARATOR, $aData['date'], $aData['user_login'], $aData['user_id']); $sPlainText .= $sSeparator.$aData['message']; } return $sPlainText; } public function GetIndex() { return $this->m_aIndex; } public function __toString() { if($this->IsEmpty()) return ''; return $this->m_sLog; } public function IsEmpty() { return ($this->m_sLog === null); } /** * @return int The number of entries in this log * @since 3.0.0 */ public function GetEntryCount(): int { return count($this->m_aIndex); } public function ClearModifiedFlag() { $this->m_bModified = false; } /** * Produces an HTML representation, aimed at being used within an email */ public function GetAsEmailHtml() { $sStyleCaseLogHeader = ''; $sStyleCaseLogEntry = ''; $sHtml = '
'; // Use table-layout:fixed to force the with to be independent from the actual content $iPos = 0; $aIndex = $this->m_aIndex; for($index=count($aIndex)-1 ; $index >= 0 ; $index--) { $iPos += $aIndex[$index]['separator_length']; $sTextEntry = substr($this->m_sLog, $iPos, $aIndex[$index]['text_length']); $sCSSClass = 'caselog_entry_html'; if (!array_key_exists('format', $aIndex[$index]) || ($aIndex[$index]['format'] == static::ENUM_FORMAT_TEXT)) { $sCSSClass = 'caselog_entry'; $sTextEntry = str_replace(array("\r\n", "\n", "\r"), "
", utils::EscapeHtml($sTextEntry)); } else { $sTextEntry = InlineImage::FixUrls($sTextEntry); } $iPos += $aIndex[$index]['text_length']; $sEntry = '
'; // Workaround: PHP < 5.3 cannot unserialize correctly DateTime objects, // therefore we have changed the format. To preserve the compatibility with existing // installations of iTop, both format are allowed: // the 'date' item is either a DateTime object, or a unix timestamp if (is_int($aIndex[$index]['date'])) { // Unix timestamp $sDate = date((string)AttributeDateTime::GetFormat(), $aIndex[$index]['date']); } elseif (is_object($aIndex[$index]['date'])) { if (version_compare(phpversion(), '5.3.0', '>=')) { // DateTime $sDate = $aIndex[$index]['date']->format((string)AttributeDateTime::GetFormat()); } else { // No Warning... but the date is unknown $sDate = ''; } } $sEntry .= sprintf(Dict::S('UI:CaseLog:Header_Date_UserName'), ''.$sDate.'', ''.$aIndex[$index]['user_name'].''); $sEntry .= '
'; $sEntry .= '
'; $sEntry .= $sTextEntry; $sEntry .= '
'; $sHtml = $sHtml.$sEntry; } // Process the case of an eventual remainder (quick migration of AttributeText fields) if ($iPos < (utils::StrLen($this->m_sLog) - 1)) { $sTextEntry = substr($this->m_sLog, $iPos); $sTextEntry = str_replace(array("\r\n", "\n", "\r"), "
", utils::EscapeHtml($sTextEntry)); if (count($this->m_aIndex) == 0) { $sHtml .= '
'; $sHtml .= $sTextEntry; $sHtml .= '
'; } else { $sHtml .= '
'; $sHtml .= Dict::S('UI:CaseLog:InitialValue'); $sHtml .= '
'; $sHtml .= '
'; $sHtml .= $sTextEntry; $sHtml .= '
'; } } $sHtml .= '
'; return $sHtml; } /** * Produces an HTML representation, aimed at being used to produce a PDF with TCPDF (no table) */ public function GetAsSimpleHtml($aTransfoHandler = null) { $sStyleCaseLogEntry = ''; $sHtml = '
    '; $iPos = 0; $aIndex = $this->m_aIndex; for($index=count($aIndex)-1 ; $index >= 0 ; $index--) { $iPos += $aIndex[$index]['separator_length']; $sTextEntry = substr($this->m_sLog, $iPos, $aIndex[$index]['text_length']); $sCSSClass = 'case_log_simple_html_entry_html'; if (!array_key_exists('format', $aIndex[$index]) || ($aIndex[$index]['format'] == static::ENUM_FORMAT_TEXT)) { $sCSSClass = 'case_log_simple_html_entry'; $sTextEntry = str_replace(array("\r\n", "\n", "\r"), "
    ", utils::EscapeHtml($sTextEntry)); if (!is_null($aTransfoHandler)) { $sTextEntry = call_user_func($aTransfoHandler, $sTextEntry); } } else { if (!is_null($aTransfoHandler)) { $sTextEntry = call_user_func($aTransfoHandler, $sTextEntry, true /* wiki "links" only */); } $sTextEntry = InlineImage::FixUrls($sTextEntry); } $iPos += $aIndex[$index]['text_length']; $sEntry = '
  • '; // Workaround: PHP < 5.3 cannot unserialize correctly DateTime objects, // therefore we have changed the format. To preserve the compatibility with existing // installations of iTop, both format are allowed: // the 'date' item is either a DateTime object, or a unix timestamp if (is_int($aIndex[$index]['date'])) { // Unix timestamp $sDate = date((string)AttributeDateTime::GetFormat(),$aIndex[$index]['date']); } elseif (is_object($aIndex[$index]['date'])) { if (version_compare(phpversion(), '5.3.0', '>=')) { // DateTime $sDate = $aIndex[$index]['date']->format((string)AttributeDateTime::GetFormat()); } else { // No Warning... but the date is unknown $sDate = ''; } } $sEntry .= sprintf(Dict::S('UI:CaseLog:Header_Date_UserName'), ''.$sDate.'', ''.$aIndex[$index]['user_name'].''); $sEntry .= '
    '; $sEntry .= $sTextEntry; $sEntry .= '
    '; $sEntry .= '
  • '; $sHtml = $sHtml.$sEntry; } // Process the case of an eventual remainder (quick migration of AttributeText fields) if ($iPos < (utils::StrLen($this->m_sLog) - 1)) { $sTextEntry = substr($this->m_sLog, $iPos); $sTextEntry = str_replace(array("\r\n", "\n", "\r"), "
    ", utils::EscapeHtml($sTextEntry)); if (count($this->m_aIndex) == 0) { $sHtml .= '
  • '; $sHtml .= $sTextEntry; $sHtml .= '
  • '; } else { $sHtml .= '
  • '; $sHtml .= Dict::S('UI:CaseLog:InitialValue'); $sHtml .= '
    '; $sHtml .= $sTextEntry; $sHtml .= '
    '; $sHtml .= '
  • '; } } $sHtml .= '
'; return $sHtml; } /** * Produces an HTML representation, aimed at being used within the iTop framework */ public function GetAsHTML(WebPage $oP = null, $bEditMode = false, $aTransfoHandler = null) { $bPrintableVersion = (utils::ReadParam('printable', '0') == '1'); $oBlock = UIContentBlockUIBlockFactory::MakeStandard(null, ['ibo-caselog-list']); $iPos = 0; $aIndex = $this->m_aIndex; if (($bEditMode) && (count($aIndex) > 0) && $this->m_bModified) { // Don't display the first element, that is still considered as editable $aLastEntry = end($aIndex); $iPos = $aLastEntry['separator_length'] + $aLastEntry['text_length']; array_pop($aIndex); } for($index=count($aIndex)-1 ; $index >= 0 ; $index--) { if (!$bPrintableVersion && ($index < count($aIndex) - CASELOG_VISIBLE_ITEMS)) { $bIsOpen = false; } else { $bIsOpen = true; } $iPos += $aIndex[$index]['separator_length']; $sTextEntry = substr($this->m_sLog, $iPos, $aIndex[$index]['text_length']); if (!array_key_exists('format', $aIndex[$index]) || ($aIndex[$index]['format'] == static::ENUM_FORMAT_TEXT)) { $sTextEntry = str_replace(array("\r\n", "\n", "\r"), "
", utils::EscapeHtml($sTextEntry)); if (!is_null($aTransfoHandler)) { $sTextEntry = call_user_func($aTransfoHandler, $sTextEntry); } } else { if (!is_null($aTransfoHandler)) { $sTextEntry = call_user_func($aTransfoHandler, $sTextEntry, true /* wiki "links" only */); } $sTextEntry = InlineImage::FixUrls($sTextEntry); } $iPos += $aIndex[$index]['text_length']; // Workaround: PHP < 5.3 cannot unserialize correctly DateTime objects, // therefore we have changed the format. To preserve the compatibility with existing // installations of iTop, both format are allowed: // the 'date' item is either a DateTime object, or a unix timestamp if (is_int($aIndex[$index]['date'])) { // Unix timestamp $sDate = date((string)AttributeDateTime::GetFormat(),$aIndex[$index]['date']); } elseif (is_object($aIndex[$index]['date'])) { if (version_compare(phpversion(), '5.3.0', '>=')) { // DateTime $sDate = $aIndex[$index]['date']->format((string)AttributeDateTime::GetFormat()); } else { // No Warning... but the date is unknown $sDate = ''; } } $oCollapsibleBlock = CollapsibleSectionUIBlockFactory::MakeStandard( sprintf(Dict::S('UI:CaseLog:Header_Date_UserName'), $sDate, $aIndex[$index]['user_name'])); $oCollapsibleBlock->AddSubBlock(new Html($sTextEntry)); $oCollapsibleBlock->SetOpenedByDefault($bIsOpen); $oBlock->AddSubBlock($oCollapsibleBlock); } // Process the case of an eventual remainder (quick migration of AttributeText fields) if ($iPos < (utils::StrLen($this->m_sLog) - 1)) { // In this case the format is always "text" $sTextEntry = substr($this->m_sLog, $iPos); $sTextEntry = str_replace(array("\r\n", "\n", "\r"), "
", utils::EscapeHtml($sTextEntry)); if (!is_null($aTransfoHandler)) { $sTextEntry = call_user_func($aTransfoHandler, $sTextEntry); } if (count($this->m_aIndex) == 0) { $oCollapsibleBlock = CollapsibleSectionUIBlockFactory::MakeStandard(''); $oCollapsibleBlock->AddSubBlock(new Html($sTextEntry)); $oCollapsibleBlock->SetOpenedByDefault(true); $oBlock->AddSubBlock($oCollapsibleBlock); } else { if (!$bPrintableVersion && (count($this->m_aIndex) - CASELOG_VISIBLE_ITEMS > 0)) { $bIsOpen = false; } else { $bIsOpen = true; } $oCollapsibleBlock = CollapsibleSectionUIBlockFactory::MakeStandard( Dict::S('UI:CaseLog:InitialValue')); $oCollapsibleBlock->AddSubBlock(new Html($sTextEntry)); $oCollapsibleBlock->SetOpenedByDefault($bIsOpen); } } $oBlockRenderer = new BlockRenderer($oBlock); $sHtml = $oBlockRenderer->RenderHtml(); $sScript = $oBlockRenderer->RenderJsInlineRecursively($oBlock,iUIBlock::ENUM_JS_TYPE_ON_READY); $aJsFiles = $oBlockRenderer->GetJsFiles(); if ($sScript!=''){ if ($oP == null) { $sScript = ''; $sHtml .= $sScript; } else { $oP->add_ready_script($sScript); } } // Ugly hack as we use a block and strip its content above, we'll also need JS files it depends on if(count($aJsFiles) > 0){ foreach ($aJsFiles as $sFileAbsUrl) { if ($oP === null) { $sScript = ''; $sHtml .= $sScript; } else { $oP->LinkScriptFromURI($sFileAbsUrl); } } } return $sHtml; } /** * Add a new entry to the log or merge the given text into the currently modified entry * and updates the internal index * * @param string $sText The text of the new entry * @param string $sOnBehalfOf Display this name instead of current user name * @param null|int $iOnBehalfOfId Use this UserId to author this Entry. If $sOnBehalfOf equals '', it'll be replaced by this User friendlyname * * @throws \ArchivedObjectException * @throws \CoreException * @throws \OQLException * * @since 3.0.0 New $iOnBehalfOfId parameter * @since 3.0.0 May throw \ArchivedObjectException exception */ public function AddLogEntry(string $sText, $sOnBehalfOf = '', $iOnBehalfOfId = null) { $sText = HTMLSanitizer::Sanitize($sText); $sDate = date(AttributeDateTime::GetInternalFormat()); if ($sOnBehalfOf == '' && $iOnBehalfOfId === null) { $sOnBehalfOf = UserRights::GetUserFriendlyName(); $iUserId = UserRights::GetUserId(); } elseif ($iOnBehalfOfId !== null) { $iUserId = $iOnBehalfOfId; /* @var User $oUser */ $oUser = MetaModel::GetObject('User', $iUserId, false, true); if ($oUser !== null && $sOnBehalfOf === '') { $sOnBehalfOf = $oUser->GetFriendlyName(); } } else { $iUserId = null; } if ($this->m_bModified) { $aLatestEntry = end($this->m_aIndex); if ($aLatestEntry['user_name'] == $sOnBehalfOf) { // Append the new text to the previous one $sPreviousText = substr($this->m_sLog, $aLatestEntry['separator_length'], $aLatestEntry['text_length']); $sText = $sPreviousText."\n".$sText; // Cleanup the previous entry array_pop($this->m_aIndex); $this->m_sLog = substr($this->m_sLog, $aLatestEntry['separator_length'] + $aLatestEntry['text_length']); } } $sSeparator = sprintf(CASELOG_SEPARATOR, $sDate, $sOnBehalfOf, $iUserId); $iSepLength = strlen($sSeparator); $iTextlength = strlen($sText); $this->m_sLog = $sSeparator.$sText.$this->m_sLog; // Latest entry printed first $this->m_aIndex[] = array( 'user_name' => $sOnBehalfOf, 'user_id' => $iUserId, 'date' => time(), 'text_length' => $iTextlength, 'separator_length' => $iSepLength, 'format' => static::ENUM_FORMAT_HTML, ); $this->m_bModified = true; } public function AddLogEntryFromJSON($oJson, $bCheckUserId = true) { if (isset($oJson->user_id)) { if (!UserRights::IsAdministrator()) { throw new Exception("Only administrators can set the user id", RestResult::UNAUTHORIZED); } if ($bCheckUserId && ($oJson->user_id != 0)) { try { $oUser = RestUtils::FindObjectFromKey('User', $oJson->user_id); } catch(Exception $e) { throw new Exception('user_id: '.$e->getMessage(), $e->getCode()); } $iUserId = $oUser->GetKey(); $sOnBehalfOf = $oUser->GetFriendlyName(); } else { $iUserId = $oJson->user_id; $sOnBehalfOf = $oJson->user_login; } } else { $iUserId = UserRights::GetUserId(); $sOnBehalfOf = UserRights::GetUserFriendlyName(); } if (isset($oJson->date)) { $oDate = new DateTime($oJson->date); $iDate = (int) $oDate->format('U'); } else { $iDate = time(); } if (isset($oJson->format)) { $sFormat = $oJson->format; } else { // The default is HTML $sFormat = static::ENUM_FORMAT_HTML; } $sText = isset($oJson->message) ? $oJson->message : ''; if ($sFormat == static::ENUM_FORMAT_HTML) { $sText = HTMLSanitizer::Sanitize($sText); } $sDate = date(AttributeDateTime::GetInternalFormat(), $iDate); $sSeparator = sprintf(CASELOG_SEPARATOR, $sDate, $sOnBehalfOf, $iUserId); $iSepLength = strlen($sSeparator); $iTextlength = strlen($sText); $this->m_sLog = $sSeparator.$sText.$this->m_sLog; // Latest entry printed first $this->m_aIndex[] = array( 'user_name' => $sOnBehalfOf, 'user_id' => $iUserId, 'date' => $iDate, 'text_length' => $iTextlength, 'separator_length' => $iSepLength, 'format' => $sFormat, ); $this->m_bModified = true; } public function GetModifiedEntry($sFormat = self::ENUM_FORMAT_TEXT) { $sModifiedEntry = ''; if ($this->m_bModified) { $sModifiedEntry = $this->GetLatestEntry($sFormat); } return $sModifiedEntry; } /** * Get the latest entry from the log * @param string The expected output format text|html * @return string */ public function GetLatestEntry($sFormat = self::ENUM_FORMAT_TEXT) { $sRes = ''; $aLastEntry = end($this->m_aIndex); if ($aLastEntry !== false) { $sRaw = substr($this->m_sLog, $aLastEntry['separator_length'], $aLastEntry['text_length']); switch ($sFormat) { case static::ENUM_FORMAT_TEXT: if ($aLastEntry['format'] == static::ENUM_FORMAT_TEXT) { $sRes = $sRaw; } else { $sRes = utils::HtmlToText($sRaw); } break; case static::ENUM_FORMAT_HTML: if ($aLastEntry['format'] == static::ENUM_FORMAT_TEXT) { $sRes = utils::TextToHtml($sRaw); } else { $sRes = InlineImage::FixUrls($sRaw); } break; } } return $sRes; } /** * Get the index of the latest entry from the log * @return integer */ public function GetLatestEntryIndex() { $aKeys = array_keys($this->m_aIndex); $iLast = end($aKeys); // Strict standards: the parameter passed to 'end' must be a variable since it is passed by reference return $iLast; } /** * Get the text string corresponding to the given entry in the log (zero based index, older entries first) * @param integer $iIndex * @return string The text of the entry */ public function GetEntryAt($iIndex) { $iPos = 0; $index = count($this->m_aIndex) - 1; while($index > $iIndex) { $iPos += $this->m_aIndex[$index]['separator_length']; $iPos += $this->m_aIndex[$index]['text_length']; $index--; } $iPos += $this->m_aIndex[$index]['separator_length']; $sText = substr($this->m_sLog, $iPos, $this->m_aIndex[$index]['text_length']); return InlineImage::FixUrls($sText); } }