diff --git a/application/utils.inc.php b/application/utils.inc.php index 7881ccc2d..4a86debcc 100644 --- a/application/utils.inc.php +++ b/application/utils.inc.php @@ -90,12 +90,31 @@ class utils * @since 3.0.0 */ public const ENUM_SANITIZATION_FILTER_VARIABLE_NAME = 'variable_name'; - /** * @var string * @since 3.0.0 */ public const ENUM_SANITIZATION_FILTER_RAW_DATA = 'raw_data'; + + /** + * @var string + * @since 3.0.0 + * @used-by static::GetMentionedObjectsFromText + */ + public const ENUM_TEXT_FORMAT_PLAIN = 'text'; + /** + * @var string + * @since 3.0.0 + * @used-by static::GetMentionedObjectsFromText + */ + public const ENUM_TEXT_FORMAT_HTML = 'html'; + /** + * @var string + * @since 3.0.0 + * @used-by static::GetMentionedObjectsFromText + */ + public const ENUM_TEXT_FORMAT_MARKDOWN = 'markdown'; + /** * @var string * @since 3.0.0 @@ -2867,4 +2886,56 @@ HTML; return $sAcronym; } + + //---------------------------------------------- + // Text manipulation + //---------------------------------------------- + + /** + * @param string $sText Text containing the mentioned objects to be found + * @param string $sFormat {@uses static::ENUM_TEXT_FORMAT_HTML, ...} + * + * @return array Array of object classes / IDs for the ones found in $sText + * @throws \Exception + * @since 3.0.0 + */ + public static function GetMentionedObjectsFromText(string $sText, string $sFormat = self::ENUM_TEXT_FORMAT_HTML): array + { + // First transform text so it can be parsed + switch ($sFormat) { + case static::ENUM_TEXT_FORMAT_HTML: + $sText = static::HtmlToText($sText); + break; + + default: + // Don't transform it + break; + } + + // Then parse text to find objects + $aMentionedObjects = array(); + $aMentionMatches = array(); + + // Note: As the sanitizer (or CKEditor autocomplete plugin? 🤔) removes data-* attributes from the hyperlink, we can't use the following (simpler) regexp: '/]*)data-object-class="([^"]*)"\s*data-object-id="([^"]*)">/i' + // If we change the sanitizer, we might want to use this regexp as it's easier to read + // Note 2: This is only working for backoffice URLs... + $sAppRootUrlForRegExp = addcslashes(utils::GetAbsoluteUrlAppRoot(), '/&'); + preg_match_all("/\[([^\]]*)\]\({$sAppRootUrlForRegExp}[^\)]*\&class=([^\)\&]*)\&id=([\d]*)[^\)]*\)/i", $sText, $aMentionMatches); + + foreach ($aMentionMatches[0] as $iMatchIdx => $sCompleteMatch) { + $sMatchedClass = $aMentionMatches[2][$iMatchIdx]; + $sMatchedId = $aMentionMatches[3][$iMatchIdx]; + + // Prepare array for matched class if not already present + if (!array_key_exists($sMatchedClass, $aMentionedObjects)) { + $aMentionedObjects[$sMatchedClass] = array(); + } + // Add matched ID if not already there + if (!in_array($sMatchedId, $aMentionedObjects[$sMatchedClass])) { + $aMentionedObjects[$sMatchedClass][] = $sMatchedId; + } + } + + return $aMentionedObjects; + } } diff --git a/core/dbobject.class.php b/core/dbobject.class.php index e4273fd7c..0f49f6640 100644 --- a/core/dbobject.class.php +++ b/core/dbobject.class.php @@ -3136,39 +3136,15 @@ abstract class DBObject implements iDisplay } // 2 - Find mentioned objects $aMentionedObjects = array(); - foreach($aUpdatedLogAttCodes as $sAttCode) - { + foreach ($aUpdatedLogAttCodes as $sAttCode) { /** @var \ormCaseLog $oUpdatedCaseLog */ $oUpdatedCaseLog = $this->Get($sAttCode); - $aMentionMatches = array(); - // Note: As the sanitizer (or CKEditor autocomplete plugin? 🤔) removes data-* attributes from the hyperlink, we can't use the following (simpler) regexp: '/]*)data-object-class="([^"]*)"\s*data-object-id="([^"]*)">/i' - // If we change the sanitizer, we might want to use this regexp as it's easier to read - // Note 2: This is only working for backoffice URLs... - $sAppRootUrlForRegExp = addcslashes(utils::GetAbsoluteUrlAppRoot(), '/&'); - preg_match_all("/\[([^\]]*)\]\({$sAppRootUrlForRegExp}[^\)]*\&class=([^\)\&]*)\&id=([\d]*)[^\)]*\)/i", $oUpdatedCaseLog->GetModifiedEntry(), $aMentionMatches); - - foreach($aMentionMatches[0] as $iMatchIdx => $sCompleteMatch) - { - $sMatchedClass = $aMentionMatches[2][$iMatchIdx]; - $sMatchedId = $aMentionMatches[3][$iMatchIdx]; - - // Prepare array for matched class if not already present - if(!array_key_exists($sMatchedClass, $aMentionedObjects)) - { - $aMentionedObjects[$sMatchedClass] = array(); - } - // Add matched ID if not already there - if(!in_array($sMatchedId, $aMentionedObjects[$sMatchedClass])) - { - $aMentionedObjects[$sMatchedClass][] = $sMatchedId; - } - } + $aMentionedObjects = array_merge_recursive($aMentionedObjects, utils::GetMentionedObjectsFromText($oUpdatedCaseLog->GetModifiedEntry())); } // 3 - Trigger for those objects - foreach($aMentionedObjects as $sMentionedClass => $aMentionedIds) - { - foreach($aMentionedIds as $sMentionedId) - { + // TODO: This should be refactored and moved into the caselogs loop, otherwise, we won't be able to know which case log triggered the action. + foreach ($aMentionedObjects as $sMentionedClass => $aMentionedIds) { + foreach ($aMentionedIds as $sMentionedId) { /** @var \DBObject $oMentionedObject */ $oMentionedObject = MetaModel::GetObject($sMentionedClass, $sMentionedId); // Important: Here the "$this->object()$" placeholder is actually the mentioned object and not the current object. The current object can be used through the $source->object()$ placeholder. diff --git a/test/application/UtilsTest.php b/test/application/UtilsTest.php index 61bbdce74..bbb3c4af3 100644 --- a/test/application/UtilsTest.php +++ b/test/application/UtilsTest.php @@ -440,6 +440,9 @@ class UtilsTest extends \Combodo\iTop\Test\UnitTest\ItopTestCase $this->assertEquals($sTestedAcronym, $sExceptedAcronym, "Acronym for '$sInput' doesn't match. Got '$sTestedAcronym', expected '$sExceptedAcronym'."); } + /** + * @since 3.0.0 + */ public function ToAcronymProvider() { return [ @@ -481,4 +484,72 @@ class UtilsTest extends \Combodo\iTop\Test\UnitTest\ItopTestCase ], ]; } + + /** + * @dataProvider GetMentionedObjectsFromTextProvider + * @covers utils::GetMentionedObjectsFromText + * + * @param string $sInput + * @param string $sFormat + * @param array $aExceptedMentionedObjects + * + * @throws \Exception + */ + public function testGetMentionedObjectsFromText(string $sInput, string $sFormat, array $aExceptedMentionedObjects) + { + $aTestedMentionedObjects = utils::GetMentionedObjectsFromText($sInput, $sFormat); + + $sExpectedAsString = print_r($aExceptedMentionedObjects, true); + $sTestedAsString = print_r($aTestedMentionedObjects, true); + + $this->assertEquals($sTestedAsString, $sExpectedAsString, "Found mentioned objects don't match. Got: $sTestedAsString, expected $sExpectedAsString"); + } + + /** + * @since 3.0.0 + */ + public function GetMentionedObjectsFromTextProvider(): array + { + $sAbsUrlAppRoot = utils::GetAbsoluteUrlAppRoot(); + + return [ + 'No object' => [ + "Begining + Second line + End", + utils::ENUM_TEXT_FORMAT_HTML, + [], + ], + '1 UserRequest' => [ + "Begining + Before link R-012345 After link + End", + utils::ENUM_TEXT_FORMAT_HTML, + [ + 'UserRequest' => ['12345'], + ], + ], + '2 UserRequests' => [ + "Begining + Before link R-012345 After link + And R-987654 + End", + utils::ENUM_TEXT_FORMAT_HTML, + [ + 'UserRequest' => ['12345', '987654'], + ], + ], + '1 UserRequest, 1 Person' => [ + "Begining + Before link R-012345 After link + And Claude Monet + End", + utils::ENUM_TEXT_FORMAT_HTML, + [ + 'UserRequest' => ['12345'], + 'Person' => ['3'], + ], + ], + ]; + } }