This commit is contained in:
Ty Clifford
2026-07-03 07:31:09 -04:00
commit cebb0d3af1
800 changed files with 89782 additions and 0 deletions
+981
View File
@@ -0,0 +1,981 @@
<?php
class pluginSearch extends Plugin
{
private $pagesFound = array();
private $numberOfItems = 0;
private $searchTerm = '';
public function init()
{
// Fields and default values for the database of this plugin
$this->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 = '<div class="alert alert-primary" role="alert">';
$html .= $this->description();
$html .= '</div>';
$html .= '<div>';
$html .= '<label>' . $L->get('Label') . '</label>';
$html .= '<input name="label" type="text" dir="auto" value="' . htmlspecialchars($this->getValue('label'), ENT_QUOTES, 'UTF-8') . '">';
$html .= '<span class="tip">' . $L->get('This title is almost always used in the sidebar of the site') . '</span>';
$html .= '</div>';
$html .= '<div>';
$html .= '<label>' . $L->get('Minimum number of characters when searching') . '</label>';
$html .= '<input name="minChars" type="number" dir="auto" value="' . (int) $this->getValue('minChars') . '" min="1" max="50">';
$html .= '</div>';
$html .= '<div>';
$html .= '<label>' . $L->get('Words to cache per page') . '</label>';
$html .= '<input name="wordsToCachePerPage" type="number" value="' . (int) $this->getValue('wordsToCachePerPage') . '" min="50" max="5000">';
$html .= '</div>';
$html .= '<div>';
$html .= '<label>' . $L->get('Show button search') . '</label>';
$html .= '<select name="showButtonSearch">';
$html .= '<option value="true" ' . ($this->getValue('showButtonSearch') === true ? 'selected' : '') . '>' . $L->get('enabled') . '</option>';
$html .= '<option value="false" ' . ($this->getValue('showButtonSearch') === false ? 'selected' : '') . '>' . $L->get('disabled') . '</option>';
$html .= '</select>';
$html .= '</div>';
$html .= '<div>';
$html .= '<label>' . $L->get('Highlight search terms in results') . '</label>';
$html .= '<select name="highlightResults">';
$html .= '<option value="true" ' . ($this->getValue('highlightResults') === true ? 'selected' : '') . '>' . $L->get('enabled') . '</option>';
$html .= '<option value="false" ' . ($this->getValue('highlightResults') === false ? 'selected' : '') . '>' . $L->get('disabled') . '</option>';
$html .= '</select>';
$html .= '</div>';
$html .= '<div>';
$html .= '<label>'. $L->get('Show result count') . '</label>';
$html .= '<select name="showResultCount">';
$html .= '<option value="true" ' . ($this->getValue('showResultCount') === true ? 'selected' : '') . '>' . $L->get('enabled') . '</option>';
$html .= '<option value="false" ' . ($this->getValue('showResultCount') === false ? 'selected' : '') . '>' . $L->get('disabled') . '</option>';
$html .= '</select>';
$html .= '</div>';
$html .= '<div>';
$html .= '<label>' . $L->get('Search in tags') . '</label>';
$html .= '<select name="searchInTags">';
$html .= '<option value="true" ' . ($this->getValue('searchInTags') === true ? 'selected' : '') . '>' . $L->get('enabled') . '</option>';
$html .= '<option value="false" ' . ($this->getValue('searchInTags') === false ? 'selected' : '') . '>' . $L->get('disabled') . '</option>';
$html .= '</select>';
$html .= '</div>';
$html .= '<div>';
$html .= '<label>' . $L->get('Search in categories') . '</label>';
$html .= '<select name="searchInCategories">';
$html .= '<option value="true" ' . ($this->getValue('searchInCategories') === true ? 'selected' : '') . '>' . $L->get('enabled') . '</option>';
$html .= '<option value="false" ' . ($this->getValue('searchInCategories') === false ? 'selected' : '') . '>' . $L->get('disabled') . '</option>';
$html .= '</select>';
$html .= '</div>';
$html .= '<div>';
$html .= '<label>' . $L->get('Excerpt length (characters)') . '</label>';
$html .= '<input name="excerptLength" type="number" value="' . $this->getValue('excerptLength') . '" min="50" max="500">';
$html .= '</div>';
return $html;
}
// HTML for sidebar
public function siteSidebar()
{
global $L;
$label = $this->getValue('label');
$labelEscaped = htmlspecialchars($label, ENT_QUOTES, 'UTF-8');
$searchText = $L->get('Search');
$searchTextEscaped = htmlspecialchars($searchText, ENT_QUOTES, 'UTF-8');
$html = '<div class="plugin plugin-search">';
$html .= '<h2 class="plugin-label">' . $labelEscaped . '</h2>';
$html .= '<div class="plugin-content">';
$html .= '<form class="search-plugin-form" role="search" onsubmit="return pluginSearchSubmit()">';
$html .= '<label for="jspluginSearchText" class="sr-only visually-hidden">' . $searchTextEscaped . '</label>';
$html .= '<input type="search" id="jspluginSearchText" name="search" placeholder="' . $searchTextEscaped . '..." autocomplete="off" dir="auto" aria-label="' . $searchTextEscaped . '">';
if ($this->getValue('showButtonSearch')) {
$html .= '<button type="submit">' . $searchTextEscaped . '</button>';
}
$html .= '</form>';
$html .= '</div>';
$html .= '</div>';
$domainBase = json_encode(DOMAIN_BASE);
$minChars = max(1, (int) $this->getValue('minChars'));
$minCharsMessage = json_encode('Please enter at least ' . $minChars . ' characters to search.');
$html .= <<<EOF
<script>
function pluginSearchSubmit() {
var text = document.getElementById("jspluginSearchText").value.trim();
if (text.length < {$minChars}) {
alert({$minCharsMessage});
return false;
}
window.location.href = {$domainBase} + 'search/' + encodeURIComponent(text);
return false;
}
</script>
EOF;
return $html;
}
// Inject CSS styles for search results (works with any theme)
public function siteHead()
{
$css = <<<'CSS'
<style>
/* Search Plugin Styles - Theme agnostic */
.search-results-header {
padding: 2rem 0;
margin-bottom: 1.5rem;
border-bottom: 1px solid rgba(0,0,0,0.1);
}
.search-results-header h1 {
font-size: 1.75rem;
margin: 0 0 0.5rem 0;
}
.search-results-header .search-query {
color: #0071e3;
font-weight: 600;
}
.search-results-header .search-count {
color: #6e6e73;
font-size: 0.9375rem;
margin: 0;
}
.search-no-results {
text-align: center;
padding: 3rem 1rem;
}
.search-no-results h2 {
font-size: 1.5rem;
margin-bottom: 0.75rem;
}
.search-no-results p {
color: #6e6e73;
margin-bottom: 1.5rem;
}
.search-no-results .search-suggestions {
text-align: left;
max-width: 400px;
margin: 0 auto;
}
.search-no-results .search-suggestions h3 {
font-size: 1rem;
margin-bottom: 0.5rem;
}
.search-no-results .search-suggestions ul {
padding-left: 1.25rem;
color: #6e6e73;
}
.search-no-results .search-suggestions li {
margin-bottom: 0.25rem;
}
.search-highlight {
background-color: rgba(255, 230, 0, 0.4);
padding: 0.1em 0.2em;
border-radius: 2px;
font-weight: 500;
}
.search-result-excerpt {
color: #1d1d1f;
line-height: 1.6;
}
.search-inline-form {
max-width: 400px;
margin: 1.5rem auto 0;
}
.search-inline-form input[type="search"] {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 1px solid rgba(0,0,0,0.1);
border-radius: 8px;
}
.search-inline-form input[type="search"]:focus {
outline: none;
border-color: #0071e3;
box-shadow: 0 0 0 4px rgba(0,113,227,0.1);
}
/* Sidebar search form styling */
.plugin-search .search-plugin-form {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.plugin-search .search-plugin-form input[type="search"] {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid rgba(0,0,0,0.15);
border-radius: 6px;
font-size: 0.9375rem;
max-width: 100%;
}
.plugin-search .search-plugin-form input[type="search"]:focus {
outline: none;
border-color: #0071e3;
}
.plugin-search .search-plugin-form button {
padding: 0.5rem 1rem;
margin: 0 auto;
background: #0071e3;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
width: 100%;
}
.plugin-search .search-plugin-form button:hover {
background: #0077ed;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.search-results-header {
border-bottom-color: rgba(255,255,255,0.1);
}
.search-results-header .search-count,
.search-no-results p,
.search-no-results .search-suggestions ul {
color: #a1a1a6;
}
.search-result-excerpt {
color: #f5f5f7;
}
.search-highlight {
background-color: rgba(255, 214, 10, 0.3);
}
.search-inline-form input[type="search"],
.plugin-search .search-plugin-form input[type="search"] {
background: #1d1d1f;
border-color: rgba(255,255,255,0.1);
color: #f5f5f7;
}
}
/* Accessibility - screen reader only class */
.sr-only, .visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
CSS;
// Add JavaScript for automatic highlighting on search results page
$webhook = 'search';
if ($this->webhook($webhook, false, false) && $this->getValue('highlightResults')) {
$searchTerm = $this->getSearchTerm();
$searchTermJS = json_encode($searchTerm);
$js = '<script>' . PHP_EOL;
$js .= 'document.addEventListener("DOMContentLoaded", function() {' . PHP_EOL;
$js .= ' var searchTerm = ' . $searchTermJS . ';' . PHP_EOL;
$js .= ' if (!searchTerm || searchTerm.length < 2) return;' . PHP_EOL;
$js .= ' var seen = {};' . PHP_EOL;
$js .= ' var words = searchTerm.toLowerCase().split(/\\s+/).filter(function(w) {' . PHP_EOL;
$js .= ' if (w.length < 2 || seen[w]) return false;' . PHP_EOL;
$js .= ' seen[w] = true;' . PHP_EOL;
$js .= ' return true;' . PHP_EOL;
$js .= ' });' . PHP_EOL;
$js .= ' if (words.length === 0) return;' . PHP_EOL;
$js .= ' words.sort(function(a, b) { return b.length - a.length; });' . PHP_EOL;
$js .= ' var escapedWords = words.map(function(w) {' . PHP_EOL;
$js .= ' return w.split("").map(function(ch) {' . PHP_EOL;
$js .= ' return ".+*?^${}()|[]\\\\".indexOf(ch) === -1 ? ch : "\\\\" + ch;' . PHP_EOL;
$js .= ' }).join("");' . PHP_EOL;
$js .= ' });' . PHP_EOL;
$js .= ' var pattern = new RegExp("(" + escapedWords.join("|") + ")", "gi");' . PHP_EOL;
$js .= ' var articles = document.querySelectorAll("article, main, [role=main], .content");' . PHP_EOL;
$js .= ' articles.forEach(function(article) {' . PHP_EOL;
$js .= ' if (article.closest(".search-results-header") || article.closest("form")) return;' . PHP_EOL;
$js .= ' var walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT, null, false);' . PHP_EOL;
$js .= ' var textNodes = [];' . PHP_EOL;
$js .= ' while(walker.nextNode()) {' . PHP_EOL;
$js .= ' var parent = walker.currentNode.parentNode;' . PHP_EOL;
$js .= ' if (parent && (parent.tagName === "SCRIPT" || parent.tagName === "STYLE" || parent.tagName === "MARK")) continue;' . PHP_EOL;
$js .= ' textNodes.push(walker.currentNode);' . PHP_EOL;
$js .= ' }' . PHP_EOL;
$js .= ' textNodes.forEach(function(textNode) {' . PHP_EOL;
$js .= ' var text = textNode.nodeValue;' . PHP_EOL;
$js .= ' pattern.lastIndex = 0;' . PHP_EOL;
$js .= ' if (!pattern.test(text)) return;' . PHP_EOL;
$js .= ' pattern.lastIndex = 0;' . PHP_EOL;
$js .= ' var fragment = document.createDocumentFragment();' . PHP_EOL;
$js .= ' var lastIndex = 0;' . PHP_EOL;
$js .= ' var match;' . PHP_EOL;
$js .= ' while ((match = pattern.exec(text)) !== null) {' . PHP_EOL;
$js .= ' if (match.index > lastIndex) fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));' . PHP_EOL;
$js .= ' var mark = document.createElement("mark");' . PHP_EOL;
$js .= ' mark.className = "search-highlight";' . PHP_EOL;
$js .= ' mark.textContent = match[0];' . PHP_EOL;
$js .= ' fragment.appendChild(mark);' . PHP_EOL;
$js .= ' lastIndex = pattern.lastIndex;' . PHP_EOL;
$js .= ' }' . PHP_EOL;
$js .= ' if (lastIndex < text.length) fragment.appendChild(document.createTextNode(text.slice(lastIndex)));' . PHP_EOL;
$js .= ' textNode.parentNode.replaceChild(fragment, textNode);' . PHP_EOL;
$js .= ' });' . PHP_EOL;
$js .= ' });' . PHP_EOL;
$js .= '});' . PHP_EOL;
$js .= '</script>' . PHP_EOL;
$css .= $js;
}
return $css;
}
// Display search header before content
public function siteBodyBegin()
{
$webhook = 'search';
if (!$this->webhook($webhook, false, false)) {
return false;
}
global $L;
$searchTerm = $this->getSearchTerm();
$searchTermEscaped = htmlspecialchars($searchTerm, ENT_QUOTES, 'UTF-8');
$count = $this->numberOfItems;
if (empty($searchTerm)) {
return '';
}
$html = '<div class="container">';
$html .= '<div class="search-results-header">';
$html .= '<h1>' . htmlspecialchars($L->get('Search results for'), ENT_QUOTES, 'UTF-8') . ' "<span class="search-query">' . $searchTermEscaped . '</span>"</h1>';
if ($this->getValue('showResultCount')) {
if ($count === 0) {
$html .= '<p class="search-count">' . $L->get('No pages found') . '</p>';
} elseif ($count === 1) {
$html .= '<p class="search-count">1 ' . strtolower($L->get('result found')) . '</p>';
} else {
$html .= '<p class="search-count">' . $count . ' ' . strtolower($L->get('results found')) . '</p>';
}
}
$html .= '</div>';
$html .= '</div>';
// If no results, show helpful message
if ($count === 0) {
$domainBase = json_encode(DOMAIN_BASE);
$minChars = max(1, (int) $this->getValue('minChars'));
$searchLabel = htmlspecialchars($L->get('Search'), ENT_QUOTES, 'UTF-8');
$html .= '<div class="container">';
$html .= '<div class="search-no-results">';
$html .= '<h2>' . $L->get('No results found') . '</h2>';
$html .= '<p>' . sprintf($L->get('no-pages-found-for-the-search'), $searchTermEscaped) . '</p>';
$html .= '<div class="search-suggestions">';
$html .= '<h3>' . $L->get('Suggestions') . ':</h3>';
$html .= '<ul>';
$html .= '<li>' . $L->get('Check your spelling') . '</li>';
$html .= '<li>' . $L->get('Try different keywords') . '</li>';
$html .= '<li>' . $L->get('Try more general keywords') . '</li>';
$html .= '</ul>';
$html .= '</div>';
$html .= '<div class="search-inline-form">';
$html .= '<form role="search" onsubmit="return searchAgain()">';
$html .= '<input type="search" id="searchAgainInput" value="' . $searchTermEscaped . '" placeholder="' . $searchLabel . '..." aria-label="' . $searchLabel . '">';
$html .= '</form>';
$html .= '</div>';
$html .= '</div>';
$html .= '</div>';
$html .= <<<EOF
<script>
function searchAgain() {
var text = document.getElementById("searchAgainInput").value.trim();
if (text.length < {$minChars}) return false;
window.location.href = {$domainBase} + 'search/' + encodeURIComponent(text);
return false;
}
</script>
EOF;
}
return $html;
}
public function install($position = 0)
{
parent::install($position);
return $this->createCache();
}
// Method called when the user click on button save in the settings of the plugin
public function post()
{
parent::post();
return $this->createCache();
}
public function afterPageCreate()
{
$this->createCache();
}
public function afterPageModify()
{
$this->createCache();
}
public function afterPageDelete()
{
$this->createCache();
}
// Get the current search term
public function getSearchTerm()
{
return $this->searchTerm;
}
// Get the number of results
public function getResultCount()
{
return $this->numberOfItems;
}
public function beforeAll()
{
// Check if the URL match with the webhook
$webhook = 'search';
if ($this->webhook($webhook, false, false)) {
global $site;
global $url;
// Change the whereAmI to avoid load pages in the rule 69.pages
// This is only for performance purpose
$url->setWhereAmI('search');
// Get the string to search from the URL
$stringToSearch = $this->webhook($webhook, true, false);
$stringToSearch = trim($stringToSearch, '/');
$stringToSearch = urldecode($stringToSearch);
$stringToSearch = preg_replace('/\s+/u', ' ', $stringToSearch);
$stringToSearch = trim($stringToSearch);
if (mb_strlen($stringToSearch, 'UTF-8') > 120) {
$stringToSearch = mb_substr($stringToSearch, 0, 120, 'UTF-8');
}
$this->searchTerm = $stringToSearch;
// Search the string in the cache and get all pages with matches
$list = $this->search($stringToSearch);
$this->numberOfItems = count($list);
// Split the content in pages
// The first page number is 1, so the real is 0
$realPageNumber = $url->pageNumber() - 1;
$itemsPerPage = $site->itemsPerPage();
if ($itemsPerPage <= 0) {
if ($realPageNumber === 0) {
$this->pagesFound = $list;
}
} else {
$chunks = array_chunk($list, $itemsPerPage);
if (isset($chunks[$realPageNumber])) {
$this->pagesFound = $chunks[$realPageNumber];
}
}
}
}
public function paginator()
{
$webhook = 'search';
if ($this->webhook($webhook, false, false)) {
// Get the pre-defined variable from the rule 99.paginator.php
// Is necessary to change this variable to fit the paginator with the result from the search
global $numberOfItems;
$numberOfItems = $this->numberOfItems;
}
}
public function beforeSiteLoad()
{
$webhook = 'search';
if ($this->webhook($webhook, false, false)) {
global $url;
global $WHERE_AM_I;
$WHERE_AM_I = 'search';
// Get the pre-defined variable from the rule 69.pages.php
// We change the content to show in the website
global $content;
$content = array();
foreach ($this->pagesFound as $pageKey) {
try {
$page = new Page($pageKey);
$excerpt = $this->getSearchExcerpt($page->content(), $this->searchTerm);
if (!empty($excerpt)) {
$page->setField('content', '<p class="search-result-excerpt">' . $this->highlightTerms($excerpt, $this->searchTerm) . '</p>');
}
array_push($content, $page);
} catch (Exception $e) {
// continue
}
}
}
}
/**
* Highlight search terms in plain text and return safe HTML.
*/
public function highlightTerms($text, $searchTerm)
{
$safeText = htmlspecialchars((string) $text, ENT_QUOTES, 'UTF-8');
if (!$this->getValue('highlightResults') || empty($searchTerm)) {
return $safeText;
}
$words = preg_split('/\s+/u', trim($searchTerm), -1, PREG_SPLIT_NO_EMPTY);
$words = array_filter($words, function($word) {
return mb_strlen($word, 'UTF-8') >= 2;
});
$words = array_values(array_unique($words));
if (empty($words)) {
return $safeText;
}
usort($words, function($a, $b) {
return mb_strlen($b, 'UTF-8') - mb_strlen($a, 'UTF-8');
});
$escapedWords = array_map(function($word) {
return preg_quote(htmlspecialchars($word, ENT_QUOTES, 'UTF-8'), '/');
}, $words);
$pattern = '/(' . implode('|', $escapedWords) . ')/iu';
$highlighted = preg_replace($pattern, '<mark class="search-highlight">$1</mark>', $safeText);
if ($highlighted === null) {
return $safeText;
}
return $highlighted;
}
/**
* Get excerpt around the search term
*/
public function getSearchExcerpt($content, $searchTerm, $length = null)
{
if ($length === null) {
$length = $this->getValue('excerptLength');
}
$length = min(800, max(80, (int) $length));
$content = $this->plainText($content);
if (empty($content)) {
return '';
}
$words = preg_split('/\s+/u', trim($searchTerm), -1, PREG_SPLIT_NO_EMPTY);
$words = array_filter($words, function($word) {
return mb_strlen($word, 'UTF-8') >= 2;
});
$firstPos = false;
foreach ($words as $word) {
$pos = mb_stripos($content, $word, 0, 'UTF-8');
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, 'UTF-8');
$spacePos = mb_strrpos($prefix, ' ', 0, 'UTF-8');
if ($spacePos !== false) {
$start = $spacePos + 1;
}
}
$excerpt = mb_substr($content, $start, $length, 'UTF-8');
$contentLength = mb_strlen($content, 'UTF-8');
if ($start > 0) {
$excerpt = '...' . $excerpt;
}
if ($start + $length < $contentLength) {
$lastSpace = mb_strrpos($excerpt, ' ', 0, 'UTF-8');
if ($lastSpace !== false && $lastSpace > max(20, $length - 40)) {
$excerpt = mb_substr($excerpt, 0, $lastSpace, 'UTF-8');
}
$excerpt .= '...';
}
return $excerpt;
}
private function plainText($text)
{
$text = Text::removeHTMLTags((string) $text);
$text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
$plainText = preg_replace('/\s+/u', ' ', $text);
if ($plainText === null) {
$plainText = preg_replace('/\s+/', ' ', $text);
}
if ($plainText === null) {
$plainText = $text;
}
return trim((string) $plainText);
}
// Generate the cache file
// This function is necessary to call it when you create, edit or remove content
private function createCache()
{
// Get all pages published
global $pages;
global $categories;
$list = $pages->getList($pageNumber = 1, $numberOfItems = -1, $published = true, $static = true, $sticky = true, $draft = false, $scheduled = false);
$cache = array();
foreach ($list as $pageKey) {
$page = buildPage($pageKey);
if ($page === false) {
continue;
}
// Process content
$characterLimit = max(250, (int) $this->getValue('wordsToCachePerPage') * 6);
$content = Text::truncate($this->plainText($page->content()), $characterLimit, '');
// Include the page to the cache
$cache[$pageKey] = array(
'title' => $this->plainText($page->title()),
'description' => $this->plainText($page->description()),
'content' => $content,
'tags' => '',
'category' => ''
);
// Add tags if enabled
if ($this->getValue('searchInTags')) {
$pageTags = $page->tags(true);
if (!empty($pageTags)) {
$cache[$pageKey]['tags'] = implode(' ', $pageTags);
}
}
// Add category if enabled
if ($this->getValue('searchInCategories')) {
$categoryKey = $page->categoryKey();
if (!empty($categoryKey) && isset($categories)) {
try {
$category = new Category($categoryKey);
$cache[$pageKey]['category'] = $this->plainText($category->name());
} catch (Exception $e) {
// Category not found
}
}
}
$cache[$pageKey]['search'] = $this->buildSearchFields($cache[$pageKey]);
}
// Generate JSON file with the cache
$json = json_encode($cache, JSON_UNESCAPED_UNICODE);
return file_put_contents($this->cacheFile(), $json, LOCK_EX);
}
// Returns the absolute path of the cache file
private function cacheFile()
{
return $this->workspace() . 'cache.json';
}
// Search text inside the cache
// Returns an array with the pages keys related to the text
// The array is sorted by score
private function search($text)
{
$text = trim($text);
// Check minimum characters
$minChars = max(1, (int) $this->getValue('minChars'));
if (mb_strlen($text, 'UTF-8') < $minChars) {
return array();
}
// Read the cache file
$cacheFile = $this->cacheFile();
if (!file_exists($cacheFile)) {
$this->createCache();
}
$json = file_get_contents($cacheFile);
$cache = json_decode($json, true);
if (!is_array($cache)) {
$this->createCache();
$json = file_get_contents($cacheFile);
$cache = json_decode($json, true);
}
if (empty($cache) || !is_array($cache)) {
return array();
}
$terms = $this->tokenizeSearchTerm($text);
if (empty($terms)) {
return array();
}
$scores = array();
foreach ($cache as $pageKey => $data) {
if (!is_array($data)) {
continue;
}
$score = $this->scoreSearchResult($data, $text, $terms);
if ($score > 0) {
$scores[$pageKey] = $score;
}
}
arsort($scores, SORT_NUMERIC);
return array_keys($scores);
}
private function buildSearchFields($data)
{
$fields = array(
'title' => $this->normalizeSearchText(isset($data['title']) ? $data['title'] : ''),
'description' => $this->normalizeSearchText(isset($data['description']) ? $data['description'] : ''),
'content' => $this->normalizeSearchText(isset($data['content']) ? $data['content'] : ''),
'tags' => $this->normalizeSearchText(isset($data['tags']) ? $data['tags'] : ''),
'category' => $this->normalizeSearchText(isset($data['category']) ? $data['category'] : '')
);
$fields['all'] = trim(implode(' ', $fields));
return $fields;
}
private function searchableFields($data)
{
if (isset($data['search']) && is_array($data['search'])) {
return $data['search'] + array(
'title' => '',
'description' => '',
'content' => '',
'tags' => '',
'category' => '',
'all' => ''
);
}
return $this->buildSearchFields($data);
}
private function normalizeSearchText($text)
{
$text = $this->plainText($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('UTF-8', 'ASCII//TRANSLIT//IGNORE', $text);
if ($transliterated !== false) {
$text = $transliterated;
}
}
$text = mb_strtolower($text, 'UTF-8');
$normalized = preg_replace('/[^\p{L}\p{N}]+/u', ' ', $text);
if ($normalized === null) {
$normalized = preg_replace('/[^a-z0-9]+/i', ' ', $text);
}
if ($normalized === null) {
$normalized = $text;
}
$collapsed = preg_replace('/\s+/u', ' ', $normalized);
if ($collapsed === null) {
$collapsed = preg_replace('/\s+/', ' ', $normalized);
}
if ($collapsed === null) {
$collapsed = $normalized;
}
return trim((string) $collapsed);
}
private function tokenizeSearchTerm($text)
{
$normalized = $this->normalizeSearchText($text);
$words = preg_split('/\s+/u', $normalized, -1, PREG_SPLIT_NO_EMPTY);
if (!is_array($words)) {
return array();
}
$tokens = array();
foreach ($words as $word) {
if (mb_strlen($word, 'UTF-8') >= 2) {
$tokens[$word] = true;
}
}
return array_keys($tokens);
}
private function scoreSearchResult($data, $query, $terms)
{
$fields = $this->searchableFields($data);
$query = $this->normalizeSearchText($query);
$score = 0;
if (!empty($query)) {
$phraseWeights = array(
'title' => 220,
'tags' => 180,
'category' => 160,
'description' => 110,
'content' => 70
);
foreach ($phraseWeights as $field => $weight) {
if (!empty($fields[$field]) && strpos($fields[$field], $query) !== false) {
$score += $weight + min(60, $this->countOccurrences($fields[$field], $query) * 8);
}
}
}
$matchedTerms = 0;
foreach ($terms as $term) {
$termScore = 0;
$termScore += $this->scoreTermInField($fields['title'], $term, 95);
$termScore += $this->scoreTermInField($fields['tags'], $term, 80);
$termScore += $this->scoreTermInField($fields['category'], $term, 75);
$termScore += $this->scoreTermInField($fields['description'], $term, 45);
$termScore += $this->scoreTermInField($fields['content'], $term, 25);
if ($termScore === 0) {
$termScore += $this->fuzzyTermScore($term, trim($fields['title'] . ' ' . $fields['tags'] . ' ' . $fields['category']), 24);
$termScore += $this->fuzzyTermScore($term, $fields['content'], 10);
}
if ($termScore > 0) {
$matchedTerms++;
$score += $termScore;
}
}
if ($matchedTerms === 0) {
return 0;
}
if (count($terms) > 1) {
if ($matchedTerms === count($terms)) {
$score += 45;
} else {
$score -= (count($terms) - $matchedTerms) * 25;
}
}
return max(0, $score);
}
private function scoreTermInField($field, $term, $weight)
{
if (empty($field)) {
return 0;
}
$quoted = preg_quote($term, '/');
$fullMatches = preg_match_all('/(^| )' . $quoted . '(?= |$)/u', $field, $matches);
if ($fullMatches > 0) {
return $weight + min(40, $fullMatches * 6);
}
if (mb_strlen($term, 'UTF-8') >= 4 && strpos($field, $term) !== false) {
return (int) ($weight * 0.55) + min(25, $this->countOccurrences($field, $term) * 4);
}
return 0;
}
private function countOccurrences($field, $needle)
{
if ($needle === '') {
return 0;
}
return substr_count($field, $needle);
}
private function fuzzyTermScore($term, $field, $weight)
{
if (mb_strlen($term, 'UTF-8') < 4 || empty($field)) {
return 0;
}
$words = preg_split('/\s+/u', $field, -1, PREG_SPLIT_NO_EMPTY);
if (!is_array($words)) {
return 0;
}
$bestScore = 0;
$checked = array();
$checkedCount = 0;
$termLength = strlen($term);
$allowedDistance = ($termLength <= 5) ? 1 : 2;
foreach ($words as $word) {
if (isset($checked[$word])) {
continue;
}
$checked[$word] = true;
if (strlen($word) > 64 || abs(strlen($word) - $termLength) > $allowedDistance) {
continue;
}
$checkedCount++;
if ($checkedCount > 250) {
break;
}
$distance = levenshtein($term, $word);
if ($distance <= $allowedDistance) {
$bestScore = max($bestScore, $weight - ($distance * 5));
}
}
return $bestScore;
}
}