dbFields = array( 'label' => 'Search', 'minChars' => 3, 'wordsToCachePerPage' => 800, 'showButtonSearch' => false, 'highlightResults' => true, 'showResultCount' => true, 'searchInTags' => true, 'searchInCategories' => true, 'excerptLength' => 200 ); } public function form() { global $L; $html = '
' . $L->get('No pages found') . '
'; } elseif ($count === 1) { $html .= '1 ' . strtolower($L->get('result found')) . '
'; } else { $html .= '' . $count . ' ' . strtolower($L->get('results found')) . '
'; } } $html .= '' . sprintf($L->get('no-pages-found-for-the-search'), $searchTermEscaped) . '
'; $html .= '' . $this->highlightTerms($excerpt, $this->searchTerm) . '
'); } $content[] = $page; } catch (Exception $e) { $this->logSearchError('Result page failed: ' . $e->getMessage()); } } return true; } public function highlightTerms($text, $searchTerm) { $safeText = htmlspecialchars((string) $text, ENT_QUOTES, 'UTF-8'); if (!$this->getValue('highlightResults') || empty($searchTerm)) { return $safeText; } $terms = $this->queryTerms($searchTerm); if (empty($terms)) { return $safeText; } usort($terms, function($a, $b) { return mb_strlen($b, CHARSET) - mb_strlen($a, CHARSET); }); $escapedTerms = array_map(function($term) { return preg_quote(htmlspecialchars($term, ENT_QUOTES, 'UTF-8'), '/'); }, $terms); $pattern = '/(' . implode('|', $escapedTerms) . ')/iu'; $highlighted = preg_replace($pattern, '$1', $safeText); return ($highlighted === null) ? $safeText : $highlighted; } public function getSearchExcerpt($content, $searchTerm, $length = null) { $length = ($length === null) ? $this->getValue('excerptLength') : $length; $length = min(800, max(80, (int) $length)); $content = $this->plainText($content); if (empty($content)) { return ''; } $firstPos = false; foreach ($this->queryTerms($searchTerm) as $term) { $pos = mb_stripos($content, $term, 0, CHARSET); if ($pos !== false && ($firstPos === false || $pos < $firstPos)) { $firstPos = $pos; } } $start = ($firstPos === false) ? 0 : max(0, $firstPos - (int) ($length / 3)); if ($start > 0) { $prefix = mb_substr($content, 0, $start, CHARSET); $spacePos = mb_strrpos($prefix, ' ', 0, CHARSET); if ($spacePos !== false) { $start = $spacePos + 1; } } $excerpt = mb_substr($content, $start, $length, CHARSET); $contentLength = mb_strlen($content, CHARSET); if ($start > 0) { $excerpt = '...' . $excerpt; } if ($start + $length < $contentLength) { $lastSpace = mb_strrpos($excerpt, ' ', 0, CHARSET); if ($lastSpace !== false && $lastSpace > max(20, $length - 40)) { $excerpt = mb_substr($excerpt, 0, $lastSpace, CHARSET); } $excerpt .= '...'; } return $excerpt; } private function plainText($text) { $text = Text::removeHTMLTags((string) $text); $text = html_entity_decode($text, ENT_QUOTES, CHARSET); $plainText = preg_replace('/\s+/u', ' ', $text); if ($plainText === null) { $plainText = preg_replace('/\s+/', ' ', $text); } if ($plainText === null) { $plainText = $text; } return trim((string) $plainText); } private function createCache() { global $pages; $list = $pages->getList($pageNumber = 1, $numberOfItems = -1, $published = true, $static = true, $sticky = true, $draft = false, $scheduled = false); if (!is_array($list)) { return false; } $cache = array(); foreach ($list as $pageKey) { $page = buildPage($pageKey); if ($page === false) { continue; } $characterLimit = max(250, (int) $this->getValue('wordsToCachePerPage') * 6); $cache[$pageKey] = array( 'title' => $this->plainText($page->title()), 'description' => $this->plainText($page->description()), 'content' => Text::truncate($this->plainText($this->pageRawContent($page)), $characterLimit, ''), 'tags' => '', 'category' => '' ); if ($this->getValue('searchInTags')) { $pageTags = $page->tags(true); if (!empty($pageTags)) { $cache[$pageKey]['tags'] = $this->plainText(implode(' ', $pageTags)); } } if ($this->getValue('searchInCategories')) { $cache[$pageKey]['category'] = $this->plainText($page->category()); } } $workspace = $this->workspace(); if (!is_dir($workspace)) { $permissions = defined('DIR_PERMISSIONS') ? DIR_PERMISSIONS : 0755; mkdir($workspace, $permissions, true); } $json = json_encode($cache, JSON_UNESCAPED_UNICODE); return file_put_contents($this->cacheFile(), $json, LOCK_EX); } private function cacheFile() { return $this->workspace() . 'cache.json'; } private function search($text) { $query = $this->normalizeQuery($text); if (mb_strlen($query, CHARSET) < $this->minChars()) { return array(); } $cache = $this->loadCache(); $terms = $this->queryTerms($query); if (empty($cache) || empty($terms)) { return array(); } $scores = array(); foreach ($cache as $pageKey => $pageData) { $score = $this->scorePage($pageData, $query, $terms); if ($score > 0) { $scores[$pageKey] = $score; } } if (empty($scores)) { return array(); } arsort($scores, SORT_NUMERIC); return array_keys($scores); } private function loadCache() { $cacheFile = $this->cacheFile(); if (!file_exists($cacheFile)) { $this->createCache(); } $cache = $this->readCache(); if (!is_array($cache) || $this->cacheIsLegacy($cache)) { $this->createCache(); $cache = $this->readCache(); } return is_array($cache) ? $cache : array(); } private function readCache() { $cacheFile = $this->cacheFile(); if (!file_exists($cacheFile)) { return array(); } $json = file_get_contents($cacheFile); if ($json === false || $json === '') { return array(); } $cache = json_decode($json, true); return is_array($cache) ? $cache : array(); } private function cacheIsLegacy($cache) { foreach ($cache as $pageData) { if (is_array($pageData) && isset($pageData['search'])) { return true; } return false; } return false; } private function scorePage($pageData, $query, $terms) { if (!is_array($pageData)) { return 0; } $fields = array( 'title' => $this->normalizeSearchText($this->arrayValue($pageData, 'title')), 'tags' => $this->normalizeSearchText($this->arrayValue($pageData, 'tags')), 'category' => $this->normalizeSearchText($this->arrayValue($pageData, 'category')), 'description' => $this->normalizeSearchText($this->arrayValue($pageData, 'description')), 'content' => $this->normalizeSearchText($this->arrayValue($pageData, 'content')) ); $weights = array( 'title' => 100, 'tags' => 80, 'category' => 70, 'description' => 45, 'content' => 20 ); $score = 0; $matches = 0; foreach ($fields as $fieldName => $fieldText) { if ($fieldText === '') { continue; } if (strpos($fieldText, $query) !== false) { $score += $weights[$fieldName] * 3; } foreach ($terms as $term) { $termScore = $this->scoreTerm($fieldText, $term, $weights[$fieldName]); if ($termScore > 0) { $score += $termScore; $matches++; } } } if ($matches === 0) { $score += $this->fuzzyTitleScore($fields, $terms); } if (count($terms) > 1 && $matches >= count($terms)) { $score += 40; } return $score; } private function scoreTerm($fieldText, $term, $weight) { if ($fieldText === '' || $term === '') { return 0; } $quoted = preg_quote($term, '/'); $wholeWordMatches = preg_match_all('/(^| )' . $quoted . '(?= |$)/u', $fieldText, $matches); if ($wholeWordMatches > 0) { return $weight + min(50, $wholeWordMatches * 8); } if (mb_strlen($term, CHARSET) >= 4 && strpos($fieldText, $term) !== false) { return (int) ($weight * 0.45); } return 0; } private function fuzzyTitleScore($fields, $terms) { $text = trim($fields['title'] . ' ' . $fields['tags'] . ' ' . $fields['category']); if ($text === '') { return 0; } $words = $this->splitWords($text); if (empty($words)) { return 0; } $score = 0; foreach ($terms as $term) { if (mb_strlen($term, CHARSET) < 4) { continue; } $allowedDistance = (mb_strlen($term, CHARSET) <= 5) ? 1 : 2; foreach ($words as $word) { if (abs(strlen($word) - strlen($term)) > $allowedDistance) { continue; } if (levenshtein($term, $word) <= $allowedDistance) { $score += 30; break; } } } return $score; } private function normalizeQuery($text) { $text = $this->plainText(urldecode((string) $text)); if (mb_strlen($text, CHARSET) > 120) { $text = mb_substr($text, 0, 120, CHARSET); } return $text; } private function searchTermFromUrl() { $term = $this->webhook('search', true, false); if (!is_string($term)) { return ''; } return $this->normalizeQuery(trim($term, '/')); } private function queryTerms($query) { $normalized = $this->normalizeSearchText($query); $terms = array(); foreach ($this->splitWords($normalized) as $word) { if (mb_strlen($word, CHARSET) >= 2) { $terms[$word] = true; } } return array_keys($terms); } private function splitWords($text) { $words = preg_split('/\s+/u', trim((string) $text), -1, PREG_SPLIT_NO_EMPTY); if (!is_array($words)) { $words = preg_split('/\s+/', trim((string) $text), -1, PREG_SPLIT_NO_EMPTY); } return is_array($words) ? $words : array(); } private function normalizeSearchText($text) { $text = $this->plainText($text); $originalText = $text; if (function_exists('transliterator_transliterate')) { $transliterated = @transliterator_transliterate('Any-Latin; Latin-ASCII;', $text); if ($transliterated !== false) { $text = $transliterated; } } elseif (function_exists('iconv')) { $transliterated = @iconv(CHARSET, 'ASCII//TRANSLIT//IGNORE', $text); if ($transliterated !== false) { $text = $transliterated; } } $text = mb_strtolower($text, CHARSET); $text = preg_replace('/[^\p{L}\p{N}]+/u', ' ', $text); if ($text === null) { $text = preg_replace('/[^a-z0-9]+/i', ' ', $originalText); } if ($text === null) { return ''; } $text = preg_replace('/\s+/u', ' ', $text); return trim((string) $text); } private function pageRawContent($page) { if (method_exists($page, 'contentRaw')) { return $page->contentRaw(); } return $page->content(); } private function minChars() { return max(1, (int) $this->getValue('minChars')); } private function isSearchRequest() { return $this->webhook('search', false, false); } private function arrayValue($array, $key) { return (is_array($array) && isset($array[$key])) ? $array[$key] : ''; } private function logSearchError($message) { if (class_exists('Log') && defined('LOG_SEP')) { Log::set(__METHOD__ . LOG_SEP . $message); } } }