setSearchPreview($result, $options); } } /** * Update the given entity model to set attributes used for previews of the item * primarily within search result lists. */ protected function setSearchPreview(Entity $entity, SearchOptions $options) { $textProperty = $entity->textField; $textContent = $entity->$textProperty; $terms = array_merge($options->exacts, $options->searches); $matchRefs = $this->getMatchPositions($textContent, $terms); $mergedRefs = $this->sortAndMergeMatchPositions($matchRefs); $content = $this->formatTextUsingMatchPositions($mergedRefs, $textContent); $entity->setAttribute('preview_content', new HtmlString($content)); } /** * Get positions of the given terms within the given text. * Is in the array format of [int $startIndex => int $endIndex] where the indexes * are positions within the provided text. * * @return array */ protected function getMatchPositions(string $text, array $terms): array { $matchRefs = []; $text = strtolower($text); foreach ($terms as $term) { $offset = 0; $term = strtolower($term); $pos = strpos($text, $term, $offset); while ($pos !== false) { $end = $pos + strlen($term); $matchRefs[$pos] = $end; $offset = $end; $pos = strpos($text, $term, $offset); } } return $matchRefs; } /** * Sort the given match positions before merging them where they're * adjacent or where they overlap. * * @param array $matchPositions * @return array */ protected function sortAndMergeMatchPositions(array $matchPositions): array { ksort($matchPositions); $mergedRefs = []; $lastStart = 0; $lastEnd = 0; foreach ($matchPositions as $start => $end) { if ($start > $lastEnd) { $mergedRefs[$start] = $end; $lastStart = $start; $lastEnd = $end; } else if ($end > $lastEnd) { $mergedRefs[$lastStart] = $end; $lastEnd = $end; } } return $mergedRefs; } /** * Format the given original text, returning a version where terms are highlighted within. * Returned content is in HTML text format. */ protected function formatTextUsingMatchPositions(array $matchPositions, string $originalText): string { $contextRange = 32; $targetLength = 260; $maxEnd = strlen($originalText); $lastEnd = 0; $firstStart = null; $content = ''; foreach ($matchPositions as $start => $end) { // Get our outer text ranges for the added context we want to show upon the result. $contextStart = max($start - $contextRange, 0, $lastEnd); $contextEnd = min($end + $contextRange, $maxEnd); // Adjust the start if we're going to be touching the previous match. $startDiff = $start - $lastEnd; if ($startDiff < 0) { $contextStart = $start; $content = substr($content, 0, strlen($content) + $startDiff); } // Add ellipsis between results if ($contextStart !== 0 && $contextStart !== $start) { $content .= ' ...'; } // Add our content including the bolded matching text $content .= e(substr($originalText, $contextStart, $start - $contextStart)); $content .= '' . e(substr($originalText, $start, $end - $start)) . ''; $content .= e(substr($originalText, $end, $contextEnd - $end)); // Update our last end position $lastEnd = $contextEnd; // Update the first start position if it's not already been set if (is_null($firstStart)) { $firstStart = $contextStart; } // Stop if we're near our target if (strlen($content) >= $targetLength - 10) { break; } } // Just copy out the content if we haven't moved along anywhere. if ($lastEnd === 0) { $content = e(substr($originalText, 0, $targetLength)); $lastEnd = $targetLength; } // Pad out the end if we're low $remainder = $targetLength - strlen($content); if ($remainder > 10) { $content .= e(substr($originalText, $lastEnd, $remainder)); $lastEnd += $remainder; } // Pad out the start if we're still low $remainder = $targetLength - strlen($content); $firstStart = $firstStart ?: 0; if ($remainder > 10 && $firstStart !== 0) { $padStart = max(0, $firstStart - $remainder); $content = ($padStart === 0 ? '' : '...') . e(substr($originalText, $padStart, $firstStart - $padStart)) . substr($content, 4); } // Add ellipsis if we're not at the end if ($lastEnd < $maxEnd) { $content .= '...'; } return $content; } }