64cb76a846
What changed: Replaced the cache/search/scoring flow with a simpler weighted search. Search cache now stores only plain page fields: title, description, raw content, tags, category. Removed the persisted normalized search payloads and legacy helper paths. Search renders through normal listing templates for theme compatibility. Added fail-open behavior: unexpected search errors log and return no results instead of blanking the page. Kept excerpts and highlighting, but made them plain-text based and safer.
957 lines
27 KiB
PHP
957 lines
27 KiB
PHP
<?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()
|
|
{
|
|
if (!$this->isSearchRequest()) {
|
|
return false;
|
|
}
|
|
|
|
global $site;
|
|
global $url;
|
|
|
|
// Stop the normal page builder from treating /search/... as a page.
|
|
$url->setWhereAmI('search');
|
|
|
|
$this->searchTerm = $this->searchTermFromUrl();
|
|
try {
|
|
$list = $this->search($this->searchTerm);
|
|
} catch (Throwable $e) {
|
|
$this->logSearchError('Search failed: ' . $e->getMessage());
|
|
$list = array();
|
|
}
|
|
$this->numberOfItems = count($list);
|
|
|
|
$pageNumber = max(1, (int) $url->pageNumber());
|
|
$itemsPerPage = max(0, (int) $site->itemsPerPage());
|
|
|
|
if ($itemsPerPage === 0) {
|
|
$this->pagesFound = ($pageNumber === 1) ? $list : array();
|
|
return true;
|
|
}
|
|
|
|
$offset = ($pageNumber - 1) * $itemsPerPage;
|
|
$this->pagesFound = array_slice($list, $offset, $itemsPerPage);
|
|
|
|
return true;
|
|
}
|
|
|
|
public function paginator()
|
|
{
|
|
if ($this->isSearchRequest()) {
|
|
// 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()
|
|
{
|
|
if (!$this->isSearchRequest()) {
|
|
return false;
|
|
}
|
|
|
|
global $url;
|
|
global $WHERE_AM_I;
|
|
global $content;
|
|
|
|
// Render with regular listing templates; many themes only know home/page.
|
|
$url->setWhereAmI('home');
|
|
$WHERE_AM_I = 'home';
|
|
$content = array();
|
|
|
|
foreach ($this->pagesFound as $pageKey) {
|
|
try {
|
|
$page = new Page($pageKey);
|
|
$excerpt = $this->getSearchExcerpt($this->pageRawContent($page), $this->searchTerm);
|
|
if (!empty($excerpt)) {
|
|
$page->setField('content', '<p class="search-result-excerpt">' . $this->highlightTerms($excerpt, $this->searchTerm) . '</p>');
|
|
}
|
|
$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, '<mark class="search-highlight">$1</mark>', $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);
|
|
}
|
|
}
|
|
}
|